From 05507d6b0c26fd5c98168d3ebee2d6abcac898cb Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Fri, 26 Jun 2026 14:28:22 +0200 Subject: [PATCH 01/68] Add Ledger core model Add the core Ledger domain objects and focused model tests. This establishes the subsystem data model before integration points are introduced. Persistence, UI wiring, diagnostics, and documentation are intentionally left to later commits. --- .../model/ledger/LedgerModelTest.java | 847 ++++++++++++++++++ .../model/ledger/LedgerParameterTest.java | 86 ++ .../portfolio/model/ledger/Ledger.java | 31 + .../portfolio/model/ledger/LedgerEntry.java | 181 ++++ .../model/ledger/LedgerParameter.java | 161 ++++ .../portfolio/model/ledger/LedgerPosting.java | 169 ++++ .../ledger/LedgerTransactionMetadata.java | 53 ++ 7 files changed, 1528 insertions(+) create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerModelTest.java create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerParameterTest.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/Ledger.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerEntry.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerParameter.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerPosting.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerTransactionMetadata.java 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..6618a74cea --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerModelTest.java @@ -0,0 +1,847 @@ +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 projectionRef = new LedgerProjectionRef("projection-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.addProjectionRef(projectionRef); + 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))); + assertThat(entry.getProjectionRefs(), is(List.of(projectionRef))); + assertThrows(UnsupportedOperationException.class, () -> entry.getParameters().add(parameter)); + assertThrows(UnsupportedOperationException.class, () -> entry.getPostings().add(new LedgerPosting())); + assertThrows(UnsupportedOperationException.class, () -> entry.getProjectionRefs().add(new LedgerProjectionRef())); + 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(); + var projectionRef = new LedgerProjectionRef(); + + assertNotEquals(entry.getUUID(), otherEntry.getUUID()); + assertNotEquals(entry.getUUID(), posting.getUUID()); + assertNotEquals(posting.getUUID(), projectionRef.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); + + 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()); + } + + /** + * 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.SPIN_OFF, + CorporateActionKind.STOCK_DIVIDEND, CorporateActionKind.BONUS_ISSUE, + CorporateActionKind.RIGHTS_DISTRIBUTION, CorporateActionKind.BOND_CONVERSION, + CorporateActionKind.OTHER); + 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: projection ref carries projection identity and targeting fields. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testProjectionRefCarriesProjectionIdentityAndTargetingFields() + { + var account = new Account(); + var portfolio = new Portfolio(); + var projectionRef = new LedgerProjectionRef("projection-1"); + + projectionRef.setRole(LedgerProjectionRole.DELIVERY_INBOUND); + projectionRef.setAccount(account); + projectionRef.setPortfolio(portfolio); + projectionRef.setPrimaryPostingUUID("posting-1"); + projectionRef.setPostingGroupUUID("group-1"); + + assertThat(projectionRef.getUUID(), is("projection-1")); + assertThat(projectionRef.getRole(), is(LedgerProjectionRole.DELIVERY_INBOUND)); + assertSame(account, projectionRef.getAccount()); + assertSame(portfolio, projectionRef.getPortfolio()); + assertThat(projectionRef.getPrimaryPostingUUID(), is("posting-1")); + assertThat(projectionRef.getPostingGroupUUID(), is("group-1")); + } + + /** + * Checks the Ledger-V6 scenario: projection membership rows are projection-owned targeting data. + * The result must keep projection identity separate from posting truth. + * This protects against moving compatibility metadata into postings. + */ + @Test + public void testProjectionRefCarriesProjectionMemberships() + { + var projectionRef = new LedgerProjectionRef("projection-1"); + var primary = projectionRef.addMembership("posting-1", ProjectionMembershipRole.PRIMARY); + var fee = new ProjectionMembership("fee-posting", ProjectionMembershipRole.FEE_UNIT); + + projectionRef.addMembership(fee); + + assertThat(projectionRef.getMemberships(), is(List.of(primary, fee))); + assertThat(projectionRef.getPrimaryMembership().orElseThrow(), is(primary)); + assertThat(projectionRef.getMembershipsByRole(ProjectionMembershipRole.FEE_UNIT), is(List.of(fee))); + assertTrue(projectionRef.hasMembershipRole(ProjectionMembershipRole.PRIMARY)); + assertFalse(projectionRef.hasMembershipRole(ProjectionMembershipRole.TAX_UNIT)); + assertThrows(UnsupportedOperationException.class, + () -> projectionRef.getMemberships().add(new ProjectionMembership("tax-posting", + ProjectionMembershipRole.TAX_UNIT))); + } + + /** + * 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.SPIN_OFF, LedgerEntryType.STOCK_DIVIDEND, + LedgerEntryType.BONUS_ISSUE, LedgerEntryType.RIGHTS_DISTRIBUTION, + LedgerEntryType.BOND_CONVERSION); + var standardFamilies = EnumSet.complementOf(corporateActionFamilies); + + standardFamilies.forEach(this::assertStandardLegacyShape); + corporateActionFamilies.forEach(this::assertLedgerNativeTargetedShape); + + assertTrue(LedgerEntryType.SPIN_OFF.requiresTargetedProjectionRefs()); + assertTrue(LedgerEntryType.SPIN_OFF.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)); + } + + /** + * 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 protobuf ids are stable. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testConfigurableLedgerEntryTypeProtobufIdsAreStable() + { + assertStableProtobufIds(Arrays.stream(LedgerEntryType.values()).mapToInt(LedgerEntryType::getProtobufId) + .toArray()); + + for (LedgerEntryType type : LedgerEntryType.values()) + assertThat(LedgerEntryType.fromProtobufId(type.getProtobufId()), is(type)); + + var exception = assertThrows(IllegalArgumentException.class, () -> LedgerEntryType.fromProtobufId(0)); + + assertTrue(exception.getMessage(), exception.getMessage().contains(LedgerDiagnosticCode.LEDGER_CORE_017.prefix())); + assertTrue(exception.getMessage(), exception.getMessage().contains("LedgerEntryType")); + assertTrue(exception.getMessage(), exception.getMessage().contains("0")); + } + + /** + * 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.requiresTargetedProjectionRefs()); + assertFalse(type.usesSignedTargetedProjectionFacts()); + } + + private void assertLedgerNativeTargetedShape(LedgerEntryType type) + { + assertFalse(type.isLegacyFixedShape()); + assertTrue(type.isLedgerNativeTargeted()); + assertTrue(type.requiresTargetedProjectionRefs()); + 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 assertStableProtobufIds(int[] protobufIds) + { + assertThat(Arrays.stream(protobufIds).filter(id -> id == 0).count(), is(0L)); + assertThat(Arrays.stream(protobufIds).distinct().count(), is((long) protobufIds.length)); + } + + 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/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/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/LedgerEntry.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerEntry.java new file mode 100644 index 0000000000..d991cd4d62 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerEntry.java @@ -0,0 +1,181 @@ +package name.abuchen.portfolio.model.ledger; + +import java.time.Instant; +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 List> parameters = new ArrayList<>(); + private final List postings = new ArrayList<>(); + private final List projectionRefs = 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 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; + } + + public List getProjectionRefs() + { + return Collections.unmodifiableList(projectionRefs); + } + + public void addProjectionRef(LedgerProjectionRef projectionRef) + { + projectionRefs.add(Objects.requireNonNull(projectionRef)); + touch(); + } + + public boolean removeProjectionRef(LedgerProjectionRef projectionRef) + { + var removed = projectionRefs.remove(projectionRef); + + if (removed) + touch(); + + return removed; + } + + private void touch() + { + this.updatedAt = Instant.now(); + } +} 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..f7f26634d2 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerPosting.java @@ -0,0 +1,169 @@ +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.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 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 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/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; + } +} From 809492d166ad0bef251a99fb7281ae2080371f83 Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Fri, 26 Jun 2026 14:28:48 +0200 Subject: [PATCH 02/68] Add Ledger definitions and validation Add Ledger configuration, native definition, and structural validation files with their tests. This makes the Ledger rules reviewable separately from persistence and UI integration. Runtime projection wiring, import paths, and documentation are intentionally left unchanged in this commit. --- .../model/ledger/LedgerCodeTest.java | 139 ++ .../ledger/LedgerEntryDefinitionTest.java | 885 ++++++++++++ ...gerNativeEntryDefinitionValidatorTest.java | 502 +++++++ .../ledger/LedgerParameterCodeDomainTest.java | 91 ++ .../LedgerPostingTypeDefinitionTest.java | 201 +++ .../ledger/LedgerSpinOffScenarioTest.java | 943 +++++++++++++ ...LedgerSpinOffXmlDefinitionCheckerTest.java | 434 ++++++ .../ledger/LedgerStructuralValidatorTest.java | 1191 +++++++++++++++++ .../LedgerNativeEntryAssemblerTest.java | 744 ++++++++++ .../ledger/LedgerStructuralValidator.java | 891 ++++++++++++ .../configuration/CashCompensationKind.java | 39 + .../configuration/CorporateActionKind.java | 55 + .../configuration/CorporateActionLeg.java | 49 + .../configuration/CorporateActionSubtype.java | 40 + .../configuration/CostAllocationMethod.java | 41 + .../ledger/configuration/EventStage.java | 45 + .../model/ledger/configuration/FeeReason.java | 40 + .../configuration/FractionTreatment.java | 41 + .../ledger/configuration/LedgerCode.java | 19 + .../configuration/LedgerDownstreamResult.java | 21 + .../configuration/LedgerEntryDefinition.java | 425 ++++++ .../LedgerEntryDefinitionRegistry.java | 772 +++++++++++ .../ledger/configuration/LedgerEntryType.java | 92 ++ .../LedgerEventParameterDefinition.java | 40 + .../configuration/LedgerLegCardinality.java | 14 + .../configuration/LedgerLegDefinition.java | 199 +++ .../ledger/configuration/LedgerLegRole.java | 23 + .../LedgerNativeEntryDefinitionValidator.java | 668 +++++++++ .../configuration/LedgerNativeEntryShape.java | 21 + .../LedgerParameterCodeDomain.java | 58 + .../configuration/LedgerParameterType.java | 213 +++ .../LedgerPerformanceTreatment.java | 25 + .../configuration/LedgerPostingType.java | 150 +++ .../LedgerPostingTypeDefinition.java | 61 + .../LedgerPostingTypeDefinitionRegistry.java | 199 +++ .../configuration/LedgerReportingClass.java | 27 + .../ledger/configuration/QuotationStyle.java | 39 + .../configuration/RoundingModeCode.java | 41 + .../model/ledger/configuration/TaxReason.java | 41 + .../rule/LedgerParameterRule.java | 75 ++ .../rule/LedgerPostingGroupRule.java | 114 ++ .../configuration/rule/LedgerPostingRule.java | 108 ++ .../rule/LedgerProjectionRule.java | 80 ++ .../configuration/rule/LedgerRequirement.java | 17 + .../rule/LedgerRequirementGroup.java | 116 ++ .../LedgerNativeEntryAssembler.java | 475 +++++++ .../LedgerNativeEntryAssemblyException.java | 24 + .../LedgerNativeEntryAssemblyIssue.java | 23 + .../LedgerNativeEntryBuildResult.java | 45 + .../nativeentry/NativeCashCompensation.java | 130 ++ .../NativeCorporateActionEvent.java | 121 ++ .../nativeentry/NativeEntryMetadata.java | 58 + .../model/ledger/nativeentry/NativeFee.java | 112 ++ .../nativeentry/NativeParameterValue.java | 37 + .../ledger/nativeentry/NativeSecurityLeg.java | 194 +++ .../model/ledger/nativeentry/NativeTax.java | 123 ++ .../model/ledger/nativeentry/Ratio.java | 41 + 57 files changed, 11412 insertions(+) create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCodeTest.java create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerEntryDefinitionTest.java create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerParameterCodeDomainTest.java create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerPostingTypeDefinitionTest.java create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffScenarioTest.java create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffXmlDefinitionCheckerTest.java create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerStructuralValidatorTest.java create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblerTest.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerStructuralValidator.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/CashCompensationKind.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/CorporateActionKind.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/CorporateActionLeg.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/CorporateActionSubtype.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/CostAllocationMethod.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/EventStage.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/FeeReason.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/FractionTreatment.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerCode.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerDownstreamResult.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerEntryDefinition.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerEntryDefinitionRegistry.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerEntryType.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerEventParameterDefinition.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerLegCardinality.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerLegDefinition.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerLegRole.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryShape.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerParameterCodeDomain.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerParameterType.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerPerformanceTreatment.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerPostingType.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerPostingTypeDefinition.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerPostingTypeDefinitionRegistry.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerReportingClass.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/QuotationStyle.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/RoundingModeCode.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/TaxReason.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/rule/LedgerParameterRule.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/rule/LedgerPostingGroupRule.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/rule/LedgerPostingRule.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/rule/LedgerProjectionRule.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/rule/LedgerRequirement.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/rule/LedgerRequirementGroup.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssembler.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblyException.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblyIssue.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryBuildResult.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeCashCompensation.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeCorporateActionEvent.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeEntryMetadata.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeFee.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeParameterValue.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeSecurityLeg.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeTax.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/Ratio.java 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..980135a4b8 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCodeTest.java @@ -0,0 +1,139 @@ +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 kind codes map to ledger native entry types. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testCorporateActionKindCodesMapToLedgerNativeEntryTypes() + { + var relatedTypes = EnumSet.noneOf(LedgerEntryType.class); + + for (var kind : CorporateActionKind.values()) + { + if (kind == CorporateActionKind.OTHER) + { + assertTrue(kind.getRelatedEntryType().isEmpty()); + continue; + } + + var entryType = kind.getRelatedEntryType().orElseThrow(); + + assertTrue(entryType.isLedgerNativeTargeted()); + assertThat(kind.getCode(), is(entryType.name())); + assertTrue(entryType.name(), relatedTypes.add(entryType)); + } + + var nativeTypes = EnumSet.noneOf(LedgerEntryType.class); + + for (var entryType : LedgerEntryType.values()) + if (entryType.isLedgerNativeTargeted()) + nativeTypes.add(entryType); + + assertThat(relatedTypes, is(nativeTypes)); + } + + 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/LedgerEntryDefinitionTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerEntryDefinitionTest.java new file mode 100644 index 0000000000..8223214934 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerEntryDefinitionTest.java @@ -0,0 +1,885 @@ +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.Portfolio; +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()); + assertTrue(!definition.getRequiredPostingRules().isEmpty() || hasRequiredPostingAlternative(definition)); + assertFalse(definition.getPostingRules().isEmpty()); + assertFalse(definition.getEntryParameterTypes().isEmpty()); + assertFalse(definition.getRequiredEntryParameterRules().isEmpty()); + assertFalse(definition.getEntryParameterRules().isEmpty()); + assertFalse(definition.getPostingParameterTypes().isEmpty()); + assertFalse(definition.getRequiredPostingParameterRules().isEmpty()); + assertFalse(definition.getPostingParameterRules().isEmpty()); + assertFalse(definition.getProjectionRoles().isEmpty()); + assertFalse(definition.getProjectionRules().isEmpty()); + assertFalse(definition.getAlternativeRequirementGroups().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()); + assertFalse(definition.getProjectionRules().isEmpty()); + assertFalse(definition.getPostingGroupRules().isEmpty()); + assertFalse(definition.getAlternativeRequirementGroups().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.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)); + assertRequiredPosting(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); + assertRequiredProjection(definition, LedgerProjectionRole.OLD_SECURITY_LEG, true, false); + assertRequiredProjection(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.SPIN_OFF).orElseThrow(); + + var sourceLeg = assertLeg(definition, LedgerLegRole.SOURCE_SECURITY_LEG, LedgerPostingType.SECURITY, + LedgerLegCardinality.EXACTLY_ONE); + 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.EXACTLY_ONE); + 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.OPTIONAL); + 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 that stock dividends name the received security leg explicitly. + * The cash, fee, and tax legs remain optional support components and use + * the existing cash compensation group metadata. + */ + @Test + public void testStockDividendDefinitionDescribesFunctionalLegs() + { + var definition = LedgerEntryDefinitionRegistry.lookup(LedgerEntryType.STOCK_DIVIDEND).orElseThrow(); + + var receivedLeg = assertLeg(definition, LedgerLegRole.RECEIVED_SECURITY_LEG, LedgerPostingType.SECURITY, + LedgerLegCardinality.EXACTLY_ONE); + assertTrue(receivedLeg.getRequiredParameterTypes().contains(LedgerParameterType.CORPORATE_ACTION_LEG)); + assertTrue(receivedLeg.getRequiredParameterTypes().contains(LedgerParameterType.TARGET_SECURITY)); + assertTrue(receivedLeg.getRequiredParameterTypes().contains(LedgerParameterType.RATIO_NUMERATOR)); + assertTrue(receivedLeg.getRequiredParameterTypes().contains(LedgerParameterType.RATIO_DENOMINATOR)); + assertTrue(receivedLeg.getOptionalParameterTypes().contains(LedgerParameterType.SOURCE_SECURITY)); + assertThat(receivedLeg.getProjectionRole().orElseThrow(), is(LedgerProjectionRole.DELIVERY_INBOUND)); + assertTrue(receivedLeg.isPrimaryPostingExpected()); + assertFalse(receivedLeg.isPostingGroupExpected()); + + assertCashCompensationFeeAndTaxLegs(definition); + } + + /** + * Checks that bonus issues use the same received-security leg shape as + * stock dividends while preserving their own optional parameter vocabulary. + * This keeps the Java-only leg metadata aligned with the existing rules. + */ + @Test + public void testBonusIssueDefinitionDescribesFunctionalLegs() + { + var definition = LedgerEntryDefinitionRegistry.lookup(LedgerEntryType.BONUS_ISSUE).orElseThrow(); + + var receivedLeg = assertLeg(definition, LedgerLegRole.RECEIVED_SECURITY_LEG, LedgerPostingType.SECURITY, + LedgerLegCardinality.EXACTLY_ONE); + assertTrue(receivedLeg.getRequiredParameterTypes().contains(LedgerParameterType.CORPORATE_ACTION_LEG)); + assertTrue(receivedLeg.getRequiredParameterTypes().contains(LedgerParameterType.TARGET_SECURITY)); + assertTrue(receivedLeg.getRequiredParameterTypes().contains(LedgerParameterType.RATIO_NUMERATOR)); + assertTrue(receivedLeg.getRequiredParameterTypes().contains(LedgerParameterType.RATIO_DENOMINATOR)); + assertTrue(receivedLeg.getOptionalParameterTypes().contains(LedgerParameterType.SAME_SECURITY_AS_SOURCE)); + assertThat(receivedLeg.getProjectionRole().orElseThrow(), is(LedgerProjectionRole.DELIVERY_INBOUND)); + assertTrue(receivedLeg.isPrimaryPostingExpected()); + assertFalse(receivedLeg.isPostingGroupExpected()); + + assertCashCompensationFeeAndTaxLegs(definition); + } + + /** + * Checks that rights distributions expose the current distributed + * instrument alternative as leg metadata. + * The test does not add rights lifecycle behavior; it only mirrors the + * existing RIGHT-or-SECURITY alternative and projection rules. + */ + @Test + public void testRightsDistributionDefinitionDescribesFunctionalLegs() + { + var definition = LedgerEntryDefinitionRegistry.lookup(LedgerEntryType.RIGHTS_DISTRIBUTION).orElseThrow(); + + var rightLeg = assertLeg(definition, LedgerLegRole.DISTRIBUTED_RIGHT_LEG, LedgerPostingType.RIGHT, + LedgerLegCardinality.OPTIONAL); + assertTrue(rightLeg.getRequiredParameterTypes().contains(LedgerParameterType.CORPORATE_ACTION_LEG)); + assertTrue(rightLeg.getRequiredParameterTypes().contains(LedgerParameterType.SOURCE_SECURITY)); + assertTrue(rightLeg.getRequiredParameterTypes().contains(LedgerParameterType.RIGHT_SECURITY)); + assertTrue(rightLeg.getRequiredParameterTypes().contains(LedgerParameterType.RATIO_NUMERATOR)); + assertTrue(rightLeg.getRequiredParameterTypes().contains(LedgerParameterType.RATIO_DENOMINATOR)); + assertThat(rightLeg.getProjectionRole().orElseThrow(), is(LedgerProjectionRole.NEW_SECURITY_LEG)); + assertTrue(rightLeg.isPrimaryPostingExpected()); + assertFalse(rightLeg.isPostingGroupExpected()); + + var securityLeg = assertLeg(definition, LedgerLegRole.DISTRIBUTED_SECURITY_LEG, + LedgerPostingType.SECURITY, LedgerLegCardinality.OPTIONAL); + assertTrue(securityLeg.getRequiredParameterTypes().contains(LedgerParameterType.CORPORATE_ACTION_LEG)); + assertThat(securityLeg.getProjectionRole().orElseThrow(), is(LedgerProjectionRole.NEW_SECURITY_LEG)); + assertTrue(securityLeg.isPrimaryPostingExpected()); + + var sourceLeg = assertLeg(definition, LedgerLegRole.SOURCE_SECURITY_LEG, LedgerPostingType.SECURITY, + LedgerLegCardinality.OPTIONAL); + assertThat(sourceLeg.getProjectionRole().orElseThrow(), is(LedgerProjectionRole.OLD_SECURITY_LEG)); + assertTrue(sourceLeg.isPrimaryPostingExpected()); + + assertAlternativePostingGroup(definition, "RIGHTS_DISTRIBUTED_INSTRUMENT", LedgerRequirement.REQUIRED, + LedgerPostingType.RIGHT, LedgerPostingType.SECURITY); + assertCashCompensationFeeAndTaxLegs(definition); + } + + /** + * Checks that bond conversion separates the source bond, target security, + * cash, accrued interest, fee, and tax components. + * The required ratio and date alternatives remain the existing aggregate + * rules; the leg metadata does not add fixed-income behavior. + */ + @Test + public void testBondConversionDefinitionDescribesFunctionalLegs() + { + var definition = LedgerEntryDefinitionRegistry.lookup(LedgerEntryType.BOND_CONVERSION).orElseThrow(); + + var sourceLeg = assertLeg(definition, LedgerLegRole.SOURCE_BOND_LEG, LedgerPostingType.BOND, + LedgerLegCardinality.EXACTLY_ONE); + assertTrue(sourceLeg.getRequiredParameterTypes().contains(LedgerParameterType.CORPORATE_ACTION_LEG)); + assertTrue(sourceLeg.getRequiredParameterTypes().contains(LedgerParameterType.SOURCE_SECURITY)); + assertTrue(sourceLeg.getRequiredParameterTypes().contains(LedgerParameterType.NOMINAL_VALUE)); + assertTrue(sourceLeg.getRequiredParameterTypes().contains(LedgerParameterType.QUOTATION_STYLE)); + assertThat(sourceLeg.getProjectionRole().orElseThrow(), is(LedgerProjectionRole.OLD_SECURITY_LEG)); + assertTrue(sourceLeg.isPrimaryPostingExpected()); + + var targetLeg = assertLeg(definition, LedgerLegRole.TARGET_SECURITY_LEG, LedgerPostingType.SECURITY, + LedgerLegCardinality.EXACTLY_ONE); + assertTrue(targetLeg.getRequiredParameterTypes().contains(LedgerParameterType.CORPORATE_ACTION_LEG)); + assertTrue(targetLeg.getRequiredParameterTypes().contains(LedgerParameterType.TARGET_SECURITY)); + assertThat(targetLeg.getProjectionRole().orElseThrow(), is(LedgerProjectionRole.NEW_SECURITY_LEG)); + assertTrue(targetLeg.isPrimaryPostingExpected()); + + var cashLeg = assertLeg(definition, LedgerLegRole.CASH_LEG, LedgerPostingType.CASH, + LedgerLegCardinality.OPTIONAL); + assertTrue(cashLeg.getProjectionRole().isEmpty()); + + var accruedInterestLeg = assertLeg(definition, LedgerLegRole.ACCRUED_INTEREST_LEG, + LedgerPostingType.ACCRUED_INTEREST, LedgerLegCardinality.OPTIONAL); + assertTrue(accruedInterestLeg.getOptionalParameterTypes() + .contains(LedgerParameterType.ACCRUED_INTEREST_AMOUNT)); + assertTrue(accruedInterestLeg.getProjectionRole().isEmpty()); + + assertAlternativeGroup(definition, "BOND_CONVERSION_RATIO", LedgerRequirement.REQUIRED, + LedgerParameterType.CONVERSION_RATIO, LedgerParameterType.RATIO_NUMERATOR, + LedgerParameterType.RATIO_DENOMINATOR); + assertAlternativeGroup(definition, "BOND_CONVERSION_DATE", LedgerRequirement.REQUIRED, + LedgerParameterType.EFFECTIVE_DATE, LedgerParameterType.SETTLEMENT_DATE); + assertCashCompensationFeeAndTaxLegs(definition); + } + + /** + * Checks the ledger rule scenario: bond conversion definition describes fixed income 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 testBondConversionDefinitionDescribesFixedIncomeDataModel() + { + var definition = LedgerEntryDefinitionRegistry.lookup(LedgerEntryType.BOND_CONVERSION).orElseThrow(); + + assertThat(definition.getNativeShape(), is(LedgerNativeEntryShape.DUAL_INSTRUMENT_PLUS_ACCOUNT)); + assertTrue(definition.getPostingTypes().contains(LedgerPostingType.BOND)); + assertTrue(definition.getPostingTypes().contains(LedgerPostingType.SECURITY)); + assertTrue(definition.getPostingTypes().contains(LedgerPostingType.ACCRUED_INTEREST)); + assertTrue(definition.getPostingParameterTypes().contains(LedgerParameterType.CONVERSION_RATIO)); + assertTrue(definition.getPostingParameterTypes().contains(LedgerParameterType.NOMINAL_VALUE)); + assertTrue(definition.getPostingParameterTypes().contains(LedgerParameterType.QUOTATION_STYLE)); + assertTrue(definition.getPostingParameterTypes().contains(LedgerParameterType.ACCRUED_INTEREST_AMOUNT)); + assertRequiredPosting(definition, LedgerPostingType.BOND); + assertRequiredPosting(definition, LedgerPostingType.SECURITY); + assertOptionalPosting(definition, LedgerPostingType.ACCRUED_INTEREST); + assertRequiredEntryParameter(definition, LedgerParameterType.CORPORATE_ACTION_KIND); + assertOptionalEntryParameter(definition, LedgerParameterType.EFFECTIVE_DATE); + assertOptionalEntryParameter(definition, LedgerParameterType.SETTLEMENT_DATE); + assertRequiredPostingParameter(definition, LedgerParameterType.SOURCE_SECURITY); + assertRequiredPostingParameter(definition, LedgerParameterType.TARGET_SECURITY); + assertRequiredPostingParameter(definition, LedgerParameterType.NOMINAL_VALUE); + assertRequiredPostingParameter(definition, LedgerParameterType.QUOTATION_STYLE); + assertOptionalPostingParameter(definition, LedgerParameterType.ACCRUED_INTEREST_AMOUNT); + assertAlternativeGroup(definition, "BOND_CONVERSION_RATIO", LedgerRequirement.REQUIRED, + LedgerParameterType.CONVERSION_RATIO, LedgerParameterType.RATIO_NUMERATOR, + LedgerParameterType.RATIO_DENOMINATOR); + assertAlternativeGroup(definition, "BOND_CONVERSION_DATE", LedgerRequirement.REQUIRED, + LedgerParameterType.EFFECTIVE_DATE, LedgerParameterType.SETTLEMENT_DATE); + assertRequiredProjection(definition, LedgerProjectionRole.OLD_SECURITY_LEG, true, false); + assertRequiredProjection(definition, LedgerProjectionRole.NEW_SECURITY_LEG, true, false); + assertThat(definition.getReportingClass(), is(LedgerReportingClass.SECURITY_REORGANIZATION)); + assertThat(definition.getPerformanceTreatment(), is(LedgerPerformanceTreatment.INTERNAL_RECLASSIFICATION)); + } + + /** + * Checks the ledger rule scenario: stock dividend bonus issue and rights rules describe 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 testStockDividendBonusIssueAndRightsRulesDescribeNativeDataModel() + { + var stockDividend = LedgerEntryDefinitionRegistry.lookup(LedgerEntryType.STOCK_DIVIDEND).orElseThrow(); + assertRequiredPosting(stockDividend, LedgerPostingType.SECURITY); + assertOptionalPosting(stockDividend, LedgerPostingType.CASH_COMPENSATION); + assertRequiredPostingParameter(stockDividend, LedgerParameterType.TARGET_SECURITY); + assertRequiredPostingParameter(stockDividend, LedgerParameterType.RATIO_NUMERATOR); + assertRequiredProjection(stockDividend, LedgerProjectionRole.DELIVERY_INBOUND, true, false); + assertThat(stockDividend.getPerformanceTreatment(), is(LedgerPerformanceTreatment.SECURITY_DISTRIBUTION)); + + var bonusIssue = LedgerEntryDefinitionRegistry.lookup(LedgerEntryType.BONUS_ISSUE).orElseThrow(); + assertRequiredPosting(bonusIssue, LedgerPostingType.SECURITY); + assertOptionalPostingParameter(bonusIssue, LedgerParameterType.SAME_SECURITY_AS_SOURCE); + assertRequiredProjection(bonusIssue, LedgerProjectionRole.DELIVERY_INBOUND, true, false); + assertThat(bonusIssue.getPerformanceTreatment(), is(LedgerPerformanceTreatment.PERFORMANCE_NEUTRAL)); + + var rightsDistribution = LedgerEntryDefinitionRegistry.lookup(LedgerEntryType.RIGHTS_DISTRIBUTION) + .orElseThrow(); + assertOptionalPosting(rightsDistribution, LedgerPostingType.RIGHT); + assertOptionalPosting(rightsDistribution, LedgerPostingType.SECURITY); + assertRequiredPostingParameter(rightsDistribution, LedgerParameterType.RIGHT_SECURITY); + assertRequiredPostingParameter(rightsDistribution, LedgerParameterType.SOURCE_SECURITY); + assertOptionalPostingParameter(rightsDistribution, LedgerParameterType.SUBSCRIPTION_PRICE); + assertAlternativePostingGroup(rightsDistribution, "RIGHTS_DISTRIBUTED_INSTRUMENT", LedgerRequirement.REQUIRED, + LedgerPostingType.RIGHT, LedgerPostingType.SECURITY); + assertRequiredProjection(rightsDistribution, LedgerProjectionRole.NEW_SECURITY_LEG, true, false); + assertThat(rightsDistribution.getReportingClass(), is(LedgerReportingClass.RIGHTS_EVENT)); + } + + /** + * 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)); + assertTrue(allPostingParameters.contains(LedgerParameterType.NOMINAL_VALUE)); + assertTrue(allPostingParameters.contains(LedgerParameterType.QUOTATION_STYLE)); + assertTrue(allPostingParameters.contains(LedgerParameterType.ACCRUED_INTEREST_AMOUNT)); + assertTrue(allPostingTypes.contains(LedgerPostingType.ACCRUED_INTEREST)); + assertTrue(allPostingTypes.contains(LedgerPostingType.BOND)); + } + + /** + * 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 does not enforce 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 testDefinitionLayerDoesNotEnforceNativeCompleteness() + { + var ledger = new Ledger(); + var entry = new LedgerEntry("entry-1"); + var posting = new LedgerPosting("posting-1"); + var projection = new LedgerProjectionRef("projection-1"); + + entry.setType(LedgerEntryType.SPIN_OFF); + entry.setDateTime(LocalDateTime.of(2026, 1, 2, 0, 0)); + posting.setType(LedgerPostingType.CASH); + posting.setCurrency(CurrencyUnit.EUR); + projection.setRole(LedgerProjectionRole.DELIVERY_INBOUND); + projection.setPortfolio(new Portfolio()); + projection.setPrimaryPosting(posting); + entry.addPosting(posting); + entry.addProjectionRef(projection); + ledger.addEntry(entry); + + assertTrue(LedgerStructuralValidator.validate(ledger).isOK()); + } + + private void assertRequiredPosting(name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinition definition, + LedgerPostingType postingType) + { + assertTrue(postingType.name(), hasPostingRule(definition.getRequiredPostingRules(), postingType)); + } + + 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 assertRequiredProjection( + name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinition definition, + LedgerProjectionRole role, boolean primaryPostingExpected, boolean postingGroupExpected) + { + assertProjection(definition.getRequiredProjectionRules(), role, primaryPostingExpected, postingGroupExpected); + } + + 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 void assertAlternativePostingGroup( + name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinition definition, String name, + LedgerRequirement requirement, LedgerPostingType first, LedgerPostingType... rest) + { + var expected = EnumSet.of(first, rest); + + for (var group : definition.getAlternativeRequirementGroups()) + { + if (group.getName().equals(name)) + { + assertThat(group.getRequirement(), is(requirement)); + assertThat(group.getPostingTypes(), is(expected)); + return; + } + } + + assertTrue(name, false); + } + + private boolean hasRequiredPostingAlternative( + name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinition definition) + { + for (var group : definition.getAlternativeRequirementGroups()) + if (group.isRequired() && !group.getPostingTypes().isEmpty()) + return true; + + return 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 void assertCashCompensationFeeAndTaxLegs( + name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinition definition) + { + var cashLeg = assertLeg(definition, LedgerLegRole.CASH_COMPENSATION_LEG, + LedgerPostingType.CASH_COMPENSATION, LedgerLegCardinality.OPTIONAL); + 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")); + } + + 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/LedgerNativeEntryDefinitionValidatorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java new file mode 100644 index 0000000000..0fe080fa53 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java @@ -0,0 +1,502 @@ +package name.abuchen.portfolio.model.ledger; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; + +import org.junit.Test; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.ledger.configuration.CashCompensationKind; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionKind; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionSubtype; +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.LedgerNativeEntryDefinitionValidator; +import name.abuchen.portfolio.model.ledger.configuration.LedgerNativeEntryDefinitionValidator.IssueCode; +import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.model.ledger.configuration.RoundingModeCode; +import name.abuchen.portfolio.model.ledger.configuration.TaxReason; +import name.abuchen.portfolio.model.ledger.nativeentry.LedgerNativeEntryAssembler; +import name.abuchen.portfolio.model.ledger.nativeentry.LedgerNativeEntryAssemblyException; +import name.abuchen.portfolio.model.ledger.nativeentry.LedgerNativeEntryAssemblyIssue; +import name.abuchen.portfolio.model.ledger.nativeentry.NativeCashCompensation; +import name.abuchen.portfolio.model.ledger.nativeentry.NativeCorporateActionEvent; +import name.abuchen.portfolio.model.ledger.nativeentry.NativeEntryMetadata; +import name.abuchen.portfolio.model.ledger.nativeentry.NativeFee; +import name.abuchen.portfolio.model.ledger.nativeentry.NativeSecurityLeg; +import name.abuchen.portfolio.model.ledger.nativeentry.NativeTax; +import name.abuchen.portfolio.model.ledger.nativeentry.Ratio; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.Values; + +/** + * Tests native Ledger entry validation against Java-owned entry and leg definitions. + * These tests keep native create paths strict while leaving load, save, and raw + * model fixtures outside native completeness enforcement. + */ +@SuppressWarnings("nls") +public class LedgerNativeEntryDefinitionValidatorTest +{ + /** + * Checks that a complete spin-off assembled through the native builder + * satisfies the Java-owned native definition. + * The fee and tax postings do not need first-class group membership. + */ + @Test + public void testValidSpinOffIsNativeDefinitionValid() + { + var entry = validSpinOff(fixture()).buildDetached().getEntry(); + var result = LedgerNativeEntryDefinitionValidator.validate(entry); + + assertTrue(result.format(), result.isOK()); + assertThat(entry.getPostings().stream().filter(posting -> posting.getType() == LedgerPostingType.FEE).count(), + is(1L)); + assertThat(entry.getPostings().stream().filter(posting -> posting.getType() == LedgerPostingType.TAX).count(), + is(1L)); + } + + /** + * Checks that a native entry without its required corporate-action kind is + * rejected before it can be used by supported create paths. + */ + @Test + public void testMissingRequiredEntryParameterIsRejected() + { + var entry = copyValidSpinOff(); + removeEntryParameters(entry, LedgerParameterType.CORPORATE_ACTION_KIND); + + assertIssue(entry, IssueCode.REQUIRED_ENTRY_PARAMETER_MISSING); + } + + /** + * Checks that a native entry must satisfy the configured date alternative. + * A spin-off without EX_DATE and EFFECTIVE_DATE is structurally possible + * but incomplete against the native definition. + */ + @Test + public void testMissingDateAlternativeIsRejected() + { + var entry = copyValidSpinOff(); + removeEntryParameters(entry, LedgerParameterType.EX_DATE); + removeEntryParameters(entry, LedgerParameterType.EFFECTIVE_DATE); + + assertIssue(entry, IssueCode.REQUIRED_ALTERNATIVE_GROUP_MISSING); + } + + /** + * Checks that entry parameters stay within the entry definition vocabulary. + * Source-account facts must not be smuggled into the event-level parameter list. + */ + @Test + public void testEntryParameterNotAllowedByDefinitionIsRejected() + { + var fixture = fixture(); + var entry = validSpinOff(fixture).buildDetached().getEntry(); + + entry.addParameter(LedgerParameter.ofAccount(LedgerParameterType.SOURCE_ACCOUNT, fixture.account)); + + assertIssue(entry, IssueCode.ENTRY_PARAMETER_NOT_ALLOWED); + } + + /** + * Checks that the source security leg is required for spin-offs. + * Removing its posting and projection leaves the entry definition incomplete. + */ + @Test + public void testMissingSourceSecurityLegIsRejected() + { + var entry = copyValidSpinOff(); + var sourcePosting = postingFor(entry, LedgerProjectionRole.OLD_SECURITY_LEG); + + entry.removeProjectionRef(projection(entry, LedgerProjectionRole.OLD_SECURITY_LEG)); + entry.removePosting(sourcePosting); + + assertIssue(entry, IssueCode.LEG_CARDINALITY_VIOLATED); + } + + /** + * Checks that the target security leg is required for spin-offs. + * Removing its posting and projection prevents the new security side from + * being represented as a native leg. + */ + @Test + public void testMissingTargetSecurityLegIsRejected() + { + var entry = copyValidSpinOff(); + var targetPosting = postingFor(entry, LedgerProjectionRole.NEW_SECURITY_LEG); + + entry.removeProjectionRef(projection(entry, LedgerProjectionRole.NEW_SECURITY_LEG)); + entry.removePosting(targetPosting); + + assertIssue(entry, IssueCode.LEG_CARDINALITY_VIOLATED); + } + + /** + * Checks that an exactly-one source leg cannot map to multiple postings. + * The validator reports the ambiguity instead of guessing which posting is + * the real source side. + */ + @Test + public void testDuplicateSourceLegIsRejectedAsAmbiguous() + { + var entry = copyValidSpinOff(); + var sourcePosting = LedgerModelCopy.copyPosting(postingFor(entry, LedgerProjectionRole.OLD_SECURITY_LEG)); + sourcePosting.setUUID("duplicate-source-posting"); + entry.addPosting(sourcePosting); + + var projection = new LedgerProjectionRef("duplicate-source-projection"); + projection.setRole(LedgerProjectionRole.OLD_SECURITY_LEG); + projection.setPortfolio(sourcePosting.getPortfolio()); + projection.setPrimaryPosting(sourcePosting); + entry.addProjectionRef(projection); + + assertIssue(entry, IssueCode.AMBIGUOUS_LEG_MATCH); + } + + /** + * Checks that a projection cannot satisfy a security leg with a posting of + * the wrong type. + * The validator rejects the entry before a supported create path can attach it. + */ + @Test + public void testWrongPostingTypeForLegIsRejected() + { + var entry = copyValidSpinOff(); + + postingFor(entry, LedgerProjectionRole.OLD_SECURITY_LEG).setType(LedgerPostingType.FEE); + + assertIssue(entry, IssueCode.PROJECTION_PRIMARY_POSTING_MISMATCH); + } + + /** + * Checks that the source security projection is required for the source leg. + * A source posting without its expected projection cannot become a supported + * runtime view. + */ + @Test + public void testMissingOldSecurityProjectionIsRejected() + { + var entry = copyValidSpinOff(); + + entry.removeProjectionRef(projection(entry, LedgerProjectionRole.OLD_SECURITY_LEG)); + + assertIssue(entry, IssueCode.REQUIRED_PROJECTION_MISSING); + } + + /** + * Checks that the target security projection is required for the target leg. + * The native definition needs a projection that points at the new security side. + */ + @Test + public void testMissingNewSecurityProjectionIsRejected() + { + var entry = copyValidSpinOff(); + + entry.removeProjectionRef(projection(entry, LedgerProjectionRole.NEW_SECURITY_LEG)); + + assertIssue(entry, IssueCode.REQUIRED_PROJECTION_MISSING); + } + + /** + * Checks that the old security projection must point to the source posting. + * Pointing it at the target posting would make the source and target sides + * contradict the native leg definitions. + */ + @Test + public void testOldSecurityProjectionPointingToTargetPostingIsRejected() + { + var entry = copyValidSpinOff(); + var targetPosting = postingFor(entry, LedgerProjectionRole.NEW_SECURITY_LEG); + + projection(entry, LedgerProjectionRole.OLD_SECURITY_LEG).setPrimaryPosting(targetPosting); + + assertIssue(entry, IssueCode.PROJECTION_PRIMARY_POSTING_MISMATCH); + } + + /** + * Checks that the new security projection must point to the target posting. + * Pointing it at the source posting would mix the old and received security + * sides. + */ + @Test + public void testNewSecurityProjectionPointingToSourcePostingIsRejected() + { + var entry = copyValidSpinOff(); + var sourcePosting = postingFor(entry, LedgerProjectionRole.OLD_SECURITY_LEG); + + projection(entry, LedgerProjectionRole.NEW_SECURITY_LEG).setPrimaryPosting(sourcePosting); + + assertIssue(entry, IssueCode.PROJECTION_PRIMARY_POSTING_MISMATCH); + } + + /** + * Checks that required leg parameters must be stored on the matching posting. + * The validator does not derive missing ratio facts from another location. + */ + @Test + public void testMissingRequiredLegParameterIsRejected() + { + var entry = copyValidSpinOff(); + + for (var posting : entry.getPostings()) + removePostingParameters(posting, LedgerParameterType.RATIO_NUMERATOR); + + assertIssue(entry, IssueCode.REQUIRED_LEG_PARAMETER_MISSING); + } + + /** + * Checks that a leg parameter placed at entry level does not satisfy the leg. + * This prevents event facts and leg facts from becoming two competing truths. + */ + @Test + public void testLegParameterOnEntryLevelIsRejected() + { + var fixture = fixture(); + var entry = validSpinOff(fixture).buildDetached().getEntry(); + var sourcePosting = postingFor(entry, LedgerProjectionRole.OLD_SECURITY_LEG); + + removePostingParameters(sourcePosting, LedgerParameterType.SOURCE_SECURITY); + entry.addParameter(LedgerParameter.ofSecurity(LedgerParameterType.SOURCE_SECURITY, fixture.siemens)); + + assertIssue(entry, IssueCode.PARAMETER_PLACEMENT_INVALID); + } + + /** + * Checks that a required source-leg parameter on another posting does not + * satisfy the source leg. + * The validator keeps source and target facts tied to their matching postings. + */ + @Test + public void testRequiredLegParameterOnWrongPostingIsRejected() + { + var entry = copyValidSpinOff(); + var sourcePosting = postingFor(entry, LedgerProjectionRole.OLD_SECURITY_LEG); + + removePostingParameters(sourcePosting, LedgerParameterType.SOURCE_SECURITY); + + assertIssue(entry, IssueCode.PARAMETER_PLACEMENT_INVALID); + } + + /** + * Checks that the cash compensation projection carries its configured group + * anchor when the leg definition expects it. + * The validator only checks the anchor, not fee or tax group membership. + */ + @Test + public void testMissingPostingGroupUUIDForCashCompensationProjectionIsRejected() + { + var entry = copyValidSpinOff(); + + projection(entry, LedgerProjectionRole.CASH_COMPENSATION).setPostingGroupUUID(null); + + assertIssue(entry, IssueCode.PROJECTION_POSTING_GROUP_REQUIRED); + } + + /** + * Checks that buildDetached rejects native entries that are incomplete + * against the definition. + * Raw model construction remains possible for tests, but supported builder + * paths must not return invalid native entries. + */ + @Test + public void testAssemblerBuildDetachedRejectsDefinitionIncompleteEntry() + { + var fixture = fixture(); + var exception = assertThrows(LedgerNativeEntryAssemblyException.class, + () -> baseSpinOff(fixture).securityLeg(targetLeg(fixture).build()).buildDetached()); + + assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.NATIVE_DEFINITION_VALIDATION_FAILED)); + } + + /** + * Checks that buildAndAdd does not attach a definition-incomplete native + * entry to the live client. + * The failure happens before any Ledger entry or runtime projection is added. + */ + @Test + public void testAssemblerBuildAndAddRejectsDefinitionIncompleteEntryBeforeMutation() + { + var fixture = fixture(); + var exception = assertThrows(LedgerNativeEntryAssemblyException.class, + () -> baseSpinOff(fixture).securityLeg(targetLeg(fixture).build()).buildAndAdd()); + + assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.NATIVE_DEFINITION_VALIDATION_FAILED)); + assertThat(fixture.client.getLedger().getEntries().size(), is(0)); + assertThat(fixture.account.getTransactions().size(), is(0)); + assertThat(fixture.portfolio.getTransactions().size(), is(0)); + } + + /** + * Checks that legacy fixed-shape entries stay outside native definition + * validation. + * Legacy compatibility paths remain governed by their existing creators, + * converters, and structural validation rules. + */ + @Test + public void testLegacyFixedShapeEntryIsIgnoredByNativeDefinitionValidator() + { + var entry = new LedgerEntry("legacy-buy"); + entry.setType(name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType.BUY); + + assertTrue(LedgerNativeEntryDefinitionValidator.validate(entry).isOK()); + } + + private static void assertIssue(LedgerEntry entry, IssueCode code) + { + var result = LedgerNativeEntryDefinitionValidator.validate(entry); + + assertTrue(result.format(), result.hasIssue(code)); + assertTrue(result.format(), result.format().contains("[LEDGER-STRUCT-")); + } + + private static LedgerEntry copyValidSpinOff() + { + return LedgerModelCopy.copyEntry(validSpinOff(fixture()).buildDetached().getEntry()); + } + + private static LedgerNativeEntryAssembler.EntryBuilder validSpinOff(Fixture fixture) + { + return baseSpinOff(fixture) // + .securityLeg(sourceLeg(fixture).build()) // + .securityLeg(targetLeg(fixture).build()) // + .cashCompensation(NativeCashCompensation.builder() // + .account(fixture.account) // + .amount(money(5)) // + .kind(CashCompensationKind.CASH_IN_LIEU) // + .applied(true) // + .fractionQuantity(new BigDecimal("0.5")) // + .fractionTreatment(FractionTreatment.CASH_IN_LIEU) // + .roundingMode(RoundingModeCode.FLOOR) // + .build()) // + .fee(NativeFee.of(fixture.account, money(2), FeeReason.CORPORATE_ACTION_FEE)) // + .tax(NativeTax.builder() // + .account(fixture.account) // + .amount(money(1)) // + .reason(TaxReason.WITHHOLDING_TAX) // + .taxableDistribution(true) // + .withholdingTax(true) // + .transactionTax(false) // + .reclaimableTax(false) // + .build()); + } + + private static LedgerNativeEntryAssembler.EntryBuilder baseSpinOff(Fixture fixture) + { + return LedgerNativeEntryAssembler.forClient(fixture.client).spinOff() // + .metadata(metadata()) // + .event(event()); + } + + private static NativeEntryMetadata metadata() + { + return NativeEntryMetadata.of(LocalDateTime.of(2020, 9, 28, 0, 0)) // + .note("Native corporate action") // + .source("native-definition-validator-test"); + } + + private static NativeCorporateActionEvent event() + { + return NativeCorporateActionEvent.builder() // + .kind(CorporateActionKind.SPIN_OFF) // + .subtype(CorporateActionSubtype.STANDARD) // + .reference("SPIN_OFF-2020") // + .stage(EventStage.SETTLED) // + .effectiveDate(LocalDate.of(2020, 9, 28)) // + .build(); + } + + private static NativeSecurityLeg.Builder sourceLeg(Fixture fixture) + { + return NativeSecurityLeg.source() // + .portfolio(fixture.portfolio) // + .security(fixture.siemens) // + .shares(Values.Share.factorize(10)) // + .amount(money(100)) // + .sourceSecurity(fixture.siemens) // + .targetSecurity(fixture.siemensEnergy) // + .ratio(Ratio.of(BigDecimal.ONE, BigDecimal.valueOf(2))); + } + + private static NativeSecurityLeg.Builder targetLeg(Fixture fixture) + { + return NativeSecurityLeg.target() // + .portfolio(fixture.portfolio) // + .security(fixture.siemensEnergy) // + .shares(Values.Share.factorize(5)) // + .amount(money(50)) // + .sourceSecurity(fixture.siemens) // + .targetSecurity(fixture.siemensEnergy) // + .ratio(Ratio.of(BigDecimal.ONE, BigDecimal.valueOf(2))); + } + + private static Money money(long amount) + { + return Money.of(CurrencyUnit.EUR, Values.Amount.factorize(amount)); + } + + private static LedgerProjectionRef projection(LedgerEntry entry, LedgerProjectionRole role) + { + return entry.getProjectionRefs().stream().filter(ref -> ref.getRole() == role).findFirst().orElseThrow(); + } + + private static LedgerPosting postingFor(LedgerEntry entry, LedgerProjectionRole role) + { + var postingUUID = projection(entry, role).getPrimaryPostingUUID(); + + return entry.getPostings().stream().filter(posting -> posting.getUUID().equals(postingUUID)).findFirst() + .orElseThrow(); + } + + private static void removeEntryParameters(LedgerEntry entry, LedgerParameterType type) + { + for (var parameter : new ArrayList<>(entry.getParameters())) + if (parameter.getType() == type) + entry.removeParameter(parameter); + } + + private static void removePostingParameters(LedgerPosting posting, LedgerParameterType type) + { + for (var parameter : new ArrayList<>(posting.getParameters())) + if (parameter.getType() == type) + posting.removeParameter(parameter); + } + + private static Fixture fixture() + { + var client = new Client(); + + var account = new Account(); + account.setName("Cash"); + client.addAccount(account); + + var portfolio = new Portfolio(); + portfolio.setName("Portfolio"); + portfolio.setReferenceAccount(account); + client.addPortfolio(portfolio); + + var siemens = new Security("Siemens AG", CurrencyUnit.EUR); + client.addSecurity(siemens); + + var siemensEnergy = new Security("Siemens Energy AG", CurrencyUnit.EUR); + client.addSecurity(siemensEnergy); + + return new Fixture(client, account, portfolio, siemens, siemensEnergy); + } + + private record Fixture(Client client, Account account, Portfolio portfolio, Security siemens, Security siemensEnergy) + { + } +} 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..b4d045a5e4 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerParameterCodeDomainTest.java @@ -0,0 +1,91 @@ +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("SPIN_OFF", "STOCK_DIVIDEND", "BONUS_ISSUE", "RIGHTS_DISTRIBUTION", + "BOND_CONVERSION", "OTHER")), + 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/LedgerPostingTypeDefinitionTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerPostingTypeDefinitionTest.java new file mode 100644 index 0000000000..f9ae87c073 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerPostingTypeDefinitionTest.java @@ -0,0 +1,201 @@ +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.Portfolio; +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"); + var projection = new LedgerProjectionRef("projection-1"); + + entry.setType(LedgerEntryType.SPIN_OFF); + entry.setDateTime(LocalDateTime.of(2026, 1, 2, 0, 0)); + posting.setType(LedgerPostingType.CASH); + posting.setCurrency(CurrencyUnit.EUR); + posting.addParameter(LedgerParameter.ofString(LedgerParameterType.FEE_REASON, + FeeReason.BROKER_FEE.getCode())); + projection.setRole(LedgerProjectionRole.DELIVERY_INBOUND); + projection.setPortfolio(new Portfolio()); + projection.setPrimaryPosting(posting); + entry.addPosting(posting); + entry.addProjectionRef(projection); + 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/LedgerSpinOffScenarioTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffScenarioTest.java new file mode 100644 index 0000000000..69cbc14146 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffScenarioTest.java @@ -0,0 +1,943 @@ +package name.abuchen.portfolio.model.ledger; + +import static org.hamcrest.CoreMatchers.instanceOf; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.lang.reflect.InvocationTargetException; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; +import java.util.regex.Pattern; + +import org.junit.Test; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.ClientFactory; +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.model.Transaction.Unit; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountCashLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerBuySellEdit; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerBuySellEditor; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerCreationUnits; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioSecurityLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerSecurityQuantity; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerShareAdjustmentHelper; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerTransactionCreator; +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.EventStage; +import name.abuchen.portfolio.model.ledger.configuration.FeeReason; +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.model.ledger.nativeentry.LedgerNativeEntryAssembler; +import name.abuchen.portfolio.model.ledger.nativeentry.NativeCashCompensation; +import name.abuchen.portfolio.model.ledger.nativeentry.NativeCorporateActionEvent; +import name.abuchen.portfolio.model.ledger.nativeentry.NativeEntryMetadata; +import name.abuchen.portfolio.model.ledger.nativeentry.NativeFee; +import name.abuchen.portfolio.model.ledger.nativeentry.NativeSecurityLeg; +import name.abuchen.portfolio.model.ledger.nativeentry.NativeTax; +import name.abuchen.portfolio.model.ledger.nativeentry.Ratio; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedPortfolioTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionService; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionSupport; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.Values; + +/** + * Tests ledger-native entry assembly for advanced transaction shapes. + * These tests make sure structural facts can be represented without enabling unsupported UI workflows. + */ +@SuppressWarnings("nls") +public class LedgerSpinOffScenarioTest +{ + private static final Path XML_EXAMPLE = Path + .of("docs/ledger-v6/examples/ledger-v6-spin-off-siemens-energy-example.xml"); + + private static final LocalDateTime SPIN_OFF_DATE = LocalDateTime.of(2020, 9, 28, 0, 0); + private static final LocalDateTime BUY_DATE = LocalDateTime.of(2020, 1, 2, 0, 0); + private static final Instant UPDATED_AT = Instant.parse("2026-06-15T08:00:00Z"); + + /** + * Checks the Ledger-V6 scenario: spin off uses ledger native targeted policy. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testSpinOffUsesLedgerNativeTargetedPolicy() + { + assertFalse(LedgerEntryType.SPIN_OFF.isLegacyFixedShape()); + assertTrue(LedgerEntryType.SPIN_OFF.isLedgerNativeTargeted()); + assertTrue(LedgerEntryType.SPIN_OFF.requiresTargetedProjectionRefs()); + assertTrue(LedgerEntryType.SPIN_OFF.usesSignedTargetedProjectionFacts()); + } + + /** + * Checks the Ledger-V6 scenario: targeted projection ref can use posting objects. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testTargetedProjectionRefCanUsePostingObjects() + { + var primaryPosting = new LedgerPosting(); + var groupPosting = new LedgerPosting(); + var projection = new LedgerProjectionRef(); + + projection.setPrimaryPosting(primaryPosting); + projection.setPostingGroup(groupPosting); + + assertThat(projection.getPrimaryPostingUUID(), is(primaryPosting.getUUID())); + assertThat(projection.getPostingGroupUUID(), is(groupPosting.getUUID())); + + assertThrows(NullPointerException.class, () -> projection.setPrimaryPosting(null)); + assertThrows(NullPointerException.class, () -> projection.setPostingGroup(null)); + } + + /** + * Checks the Ledger-V6 scenario: creates targeted spin off shape. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testCreatesTargetedSpinOffShape() + { + var fixture = fixture(); + var client = fixture.client(); + var entry = spinOffEntry(client); + + assertThat(entry.getPostings().size(), is(6)); + assertThat(entry.getProjectionRefs().size(), is(4)); + var oldSiemensOut = securityPosting(entry, fixture.siemens(), + CorporateActionLeg.SOURCE_SECURITY.getCode(), fixture.siemensEnergy()); + var siemensBackIn = securityPosting(entry, fixture.siemens(), + CorporateActionLeg.TARGET_SECURITY.getCode(), fixture.siemens()); + var siemensEnergyIn = securityPosting(entry, fixture.siemensEnergy(), + CorporateActionLeg.TARGET_SECURITY.getCode(), fixture.siemensEnergy()); + var compensation = posting(entry, LedgerPostingType.CASH_COMPENSATION, fixture.account(), + Values.Amount.factorize(5)); + posting(entry, LedgerPostingType.FEE, fixture.account(), Values.Amount.factorize(2)); + posting(entry, LedgerPostingType.TAX, fixture.account(), Values.Amount.factorize(1)); + + assertProjectionTargets(entry, LedgerProjectionRole.OLD_SECURITY_LEG, oldSiemensOut, null); + assertProjectionTargets(entry, LedgerProjectionRole.DELIVERY_INBOUND, siemensBackIn, null); + assertProjectionTargets(entry, LedgerProjectionRole.NEW_SECURITY_LEG, siemensEnergyIn, null); + assertProjectionTargets(entry, LedgerProjectionRole.CASH_COMPENSATION, compensation, compensation); + assertTrue(LedgerStructuralValidator.validate(client.getLedger()).isOK()); + } + + /** + * Checks the Ledger-V6 scenario: materializes spin off compatibility projections. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testMaterializesSpinOffCompatibilityProjections() + { + var fixture = fixture(); + LedgerProjectionService.materialize(fixture.client()); + LedgerProjectionService.materialize(fixture.client()); + + assertThat(fixture.portfolio().getTransactions().size(), is(4)); + assertThat(fixture.account().getTransactions().size(), is(3)); + + var oldLeg = portfolioProjection(fixture.portfolio(), LedgerProjectionRole.OLD_SECURITY_LEG); + var retainedLeg = portfolioProjection(fixture.portfolio(), LedgerProjectionRole.DELIVERY_INBOUND, + fixture.siemens()); + var newLeg = portfolioProjection(fixture.portfolio(), LedgerProjectionRole.NEW_SECURITY_LEG); + var compensation = accountProjection(fixture.account(), LedgerProjectionRole.CASH_COMPENSATION); + + assertThat(oldLeg, instanceOf(LedgerBackedTransaction.class)); + assertThat(oldLeg.getType(), is(PortfolioTransaction.Type.DELIVERY_OUTBOUND)); + assertSame(fixture.siemens(), oldLeg.getSecurity()); + assertThat(oldLeg.getShares(), is(Values.Share.factorize(10))); + assertThat(oldLeg.getUnits().count(), is(0L)); + + assertThat(retainedLeg, instanceOf(LedgerBackedTransaction.class)); + assertThat(retainedLeg.getType(), is(PortfolioTransaction.Type.DELIVERY_INBOUND)); + assertSame(fixture.siemens(), retainedLeg.getSecurity()); + assertThat(retainedLeg.getShares(), is(Values.Share.factorize(10))); + assertThat(retainedLeg.getAmount(), is(oldLeg.getAmount())); + assertThat(retainedLeg.getUnits().count(), is(0L)); + + assertThat(newLeg, instanceOf(LedgerBackedTransaction.class)); + assertThat(newLeg.getType(), is(PortfolioTransaction.Type.DELIVERY_INBOUND)); + assertSame(fixture.siemensEnergy(), newLeg.getSecurity()); + assertThat(newLeg.getShares(), is(Values.Share.factorize(5))); + assertThat(newLeg.getUnits().count(), is(0L)); + + assertThat(compensation, instanceOf(LedgerBackedTransaction.class)); + assertThat(compensation.getType(), is(AccountTransaction.Type.DEPOSIT)); + assertThat(compensation.getAmount(), is(Values.Amount.factorize(5))); + assertThat(compensation.getUnit(Unit.Type.FEE).orElseThrow().getAmount().getAmount(), + is(Values.Amount.factorize(2))); + assertThat(compensation.getUnit(Unit.Type.TAX).orElseThrow().getAmount().getAmount(), + is(Values.Amount.factorize(1))); + assertThat(fixture.client().getAllTransactions().size(), is(6)); + assertSpinOffSiemensPositionUnchanged(fixture.portfolio(), fixture.siemens()); + } + + /** + * Checks the Ledger-V6 scenario: share adjustment helper scales selected targeted spin off postings. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testShareAdjustmentHelperScalesSelectedTargetedSpinOffPostings() throws Exception + { + var fixture = fixture(); + var client = fixture.client(); + LedgerProjectionService.materialize(client); + + var entry = spinOffEntry(client); + var entryUUID = entry.getUUID(); + var postingUUIDs = entry.getPostings().stream().map(LedgerPosting::getUUID).toList(); + var projectionUUIDs = entry.getProjectionRefs().stream().map(LedgerProjectionRef::getUUID).toList(); + var selected = fixture.siemens().getTransactions(client).stream() + .map(pair -> (Transaction) pair.getTransaction()) + .filter(transaction -> transaction.getDateTime().isBefore(SPIN_OFF_DATE.plusDays(1))).toList(); + + LedgerShareAdjustmentHelper.plan(client, fixture.siemens(), selected, shares -> shares * 2).apply(); + LedgerProjectionService.materialize(client); + + var editedEntry = spinOffEntry(client); + assertThat(editedEntry.getUUID(), is(entryUUID)); + assertThat(editedEntry.getPostings().stream().map(LedgerPosting::getUUID).toList(), is(postingUUIDs)); + assertThat(editedEntry.getProjectionRefs().stream().map(LedgerProjectionRef::getUUID).toList(), + is(projectionUUIDs)); + assertThat(buyProjection(client, fixture.siemens()).getShares(), is(Values.Share.factorize(20))); + + var oldLeg = portfolioProjection(fixture.portfolio(), LedgerProjectionRole.OLD_SECURITY_LEG); + var retainedLeg = portfolioProjection(fixture.portfolio(), LedgerProjectionRole.DELIVERY_INBOUND, + fixture.siemens()); + var newLeg = portfolioProjection(fixture.portfolio(), LedgerProjectionRole.NEW_SECURITY_LEG); + + assertThat(oldLeg.getShares(), is(Values.Share.factorize(20))); + assertThat(retainedLeg.getShares(), is(Values.Share.factorize(20))); + assertThat(retainedLeg.getShares() - oldLeg.getShares(), is(0L)); + assertThat(newLeg.getShares(), is(Values.Share.factorize(5))); + assertTrue(LedgerStructuralValidator.validate(client.getLedger()).isOK()); + + var loaded = loadXml(saveXml(client)); + assertThat(buyProjection(loaded, siemens(loaded)).getShares(), is(Values.Share.factorize(20))); + assertThat(portfolioProjection(loaded.getPortfolios().get(0), LedgerProjectionRole.OLD_SECURITY_LEG) + .getShares(), is(Values.Share.factorize(20))); + assertThat(portfolioProjection(loaded.getPortfolios().get(0), LedgerProjectionRole.DELIVERY_INBOUND, + siemens(loaded)).getShares(), is(Values.Share.factorize(20))); + assertThat(portfolioProjection(loaded.getPortfolios().get(0), LedgerProjectionRole.NEW_SECURITY_LEG) + .getShares(), is(Values.Share.factorize(5))); + } + + /** + * Checks the Ledger-V6 scenario: share adjustment helper rejects targeted projection without primary posting. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testShareAdjustmentHelperRejectsTargetedProjectionWithoutPrimaryPosting() + { + var client = new Client(); + var portfolio = portfolio("Targeted portfolio"); + var security = security("Targeted Security", "DE000TARGET0", "TGT.DE"); + var entry = new LedgerEntry(); + + client.addPortfolio(portfolio); + client.addSecurity(security); + entry.setType(LedgerEntryType.SPIN_OFF); + entry.setDateTime(SPIN_OFF_DATE); + + var posting = invalidTargetSecurityPosting(portfolio, security, Values.Share.factorize(10), + Values.Amount.factorize(100), CorporateActionLeg.TARGET_SECURITY.getCode(), security, + security); + var projection = new LedgerProjectionRef(); + projection.setRole(LedgerProjectionRole.NEW_SECURITY_LEG); + projection.setPortfolio(portfolio); + entry.addPosting(posting); + entry.addProjectionRef(projection); + client.getLedger().addEntry(entry); + + var transaction = LedgerProjectionService.createProjection(entry, projection); + + assertThrows(IllegalArgumentException.class, + () -> LedgerShareAdjustmentHelper.plan(client, security, List.of(transaction), + shares -> shares * 2)); + assertThat(posting.getShares(), is(Values.Share.factorize(10))); + assertThat(client.getLedger().getEntries().size(), is(1)); + assertThat(portfolio.getTransactions().size(), is(0)); + } + + /** + * Checks the Ledger-V6 scenario: xml roundtrip preserves targeted spin off shape. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testXmlRoundtripPreservesTargetedSpinOffShape() throws Exception + { + var client = fixture().client(); + LedgerProjectionService.materialize(client); + + var xml = saveXml(client); + + assertTrue(xml.contains("")); + assertFalse(xml.contains(" primaryPosting(edited, LedgerProjectionRole.CASH_COMPENSATION) + .setAmount(Values.Amount.factorize(100))); + + var edited = spinOffEntry(client); + var editedCompensation = primaryPosting(edited, LedgerProjectionRole.CASH_COMPENSATION); + + assertThat(edited.getUUID(), is(entryUUID)); + assertThat(edited.getPostings().stream().map(LedgerPosting::getUUID).toList(), is(postingUUIDs)); + assertThat(edited.getProjectionRefs().stream().map(LedgerProjectionRef::getUUID).toList(), + is(projectionUUIDs)); + assertSecurityPostingUnchanged(edited, oldSiemensOut); + assertSecurityPostingUnchanged(edited, siemensBackIn); + assertSecurityPostingUnchanged(edited, siemensEnergyIn); + assertThat(editedCompensation.getAmount(), is(Values.Amount.factorize(100))); + assertThat(accountProjection(client.getAccounts().get(0), LedgerProjectionRole.CASH_COMPENSATION).getAmount(), + is(Values.Amount.factorize(100))); + assertThat(accountProjection(client.getAccounts().get(0), LedgerProjectionRole.CASH_COMPENSATION) + .getUnit(Unit.Type.FEE).orElseThrow().getAmount().getAmount(), is(Values.Amount.factorize(2))); + assertThat(accountProjection(client.getAccounts().get(0), LedgerProjectionRole.CASH_COMPENSATION) + .getUnit(Unit.Type.TAX).orElseThrow().getAmount().getAmount(), is(Values.Amount.factorize(1))); + assertXmlRoundtripHasEditedCompensation(client); + } + + /** + * Checks the Ledger-V6 scenario: spin off documentation does not expose uuid construction. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testSpinOffDocumentationDoesNotExposeUuidConstruction() throws Exception + { + var markdown = Files.readString(xmlExample().resolveSibling("ledger-v6-spin-off-siemens-energy-example.md"), + StandardCharsets.UTF_8); + + assertFalse(markdown.contains("setPrimaryPostingUUID")); + assertFalse(markdown.contains("setPostingGroupUUID")); + assertFalse(markdown.contains("new LedgerPosting(\"")); + assertFalse(markdown.contains("new LedgerEntry(\"")); + assertFalse(markdown.contains("new LedgerProjectionRef(\"")); + assertFalse(Pattern.compile("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}") + .matcher(markdown).find()); + } + + private SpinOffFixture fixture() + { + var client = new Client(); + var account = account("Spin-off cash account"); + var portfolio = portfolio("Corporate action portfolio"); + var siemens = security("Siemens AG", "DE0007236101", "SIE.DE"); + var siemensEnergy = security("Siemens Energy AG", "DE000ENER6Y0", "ENR.DE"); + + client.addAccount(account); + client.addPortfolio(portfolio); + client.addSecurity(siemens); + client.addSecurity(siemensEnergy); + portfolio.setReferenceAccount(account); + createStandardDeposit(client, account); + createStandardBuy(client, account, portfolio, siemens); + createSpinOffEntry(client, account, portfolio, siemens, siemensEnergy); + + return new SpinOffFixture(client, account, portfolio, siemens, siemensEnergy); + } + + private LedgerEntry spinOffEntry(Client client) + { + return client.getLedger().getEntries().stream().filter(entry -> entry.getType() == LedgerEntryType.SPIN_OFF) + .filter(entry -> SPIN_OFF_DATE.equals(entry.getDateTime())).findFirst().orElseThrow(); + } + + private LedgerEntry createSpinOffEntry(Client client, Account account, Portfolio portfolio, Security siemens, + Security siemensEnergy) + { + var entry = LedgerNativeEntryAssembler.forClient(client).spinOff() + .metadata(NativeEntryMetadata.of(SPIN_OFF_DATE) + .note("Siemens Energy spin-off") + .source("Ledger")) + .event(NativeCorporateActionEvent.builder() + .kind(CorporateActionKind.SPIN_OFF) + .subtype(CorporateActionSubtype.STANDARD) + .reference("SIEMENS-ENERGY-2020") + .stage(EventStage.SETTLED) + .effectiveDate(SPIN_OFF_DATE.toLocalDate()) + .build()) + .securityLeg(NativeSecurityLeg.source() + .portfolio(portfolio) + .security(siemens) + .shares(Values.Share.factorize(10)) + .amount(Money.of(CurrencyUnit.EUR, 109960L)) + .sourceSecurity(siemens) + .targetSecurity(siemensEnergy) + .ratio(Ratio.of(BigDecimal.ONE, BigDecimal.valueOf(2))) + .build()) + .securityLeg(NativeSecurityLeg.target() + .portfolio(portfolio) + .security(siemens) + .shares(Values.Share.factorize(10)) + .amount(Money.of(CurrencyUnit.EUR, 109960L)) + .sourceSecurity(siemens) + .targetSecurity(siemens) + .ratio(Ratio.of(BigDecimal.ONE, BigDecimal.valueOf(2))) + .projectAs(LedgerProjectionRole.DELIVERY_INBOUND) + .build()) + .securityLeg(NativeSecurityLeg.target() + .portfolio(portfolio) + .security(siemensEnergy) + .shares(Values.Share.factorize(5)) + .amount(Money.of(CurrencyUnit.EUR, 10605L)) + .sourceSecurity(siemens) + .targetSecurity(siemensEnergy) + .ratio(Ratio.of(BigDecimal.ONE, BigDecimal.valueOf(2))) + .build()) + .cashCompensation(NativeCashCompensation.builder() + .account(account) + .amount(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(5))) + .kind(CashCompensationKind.CASH_IN_LIEU) + .build()) + .fee(NativeFee.of(account, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(2)), + FeeReason.CORPORATE_ACTION_FEE)) + .tax(NativeTax.withholding(account, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(1)))) + .buildAndAdd() + .getEntry(); + + entry.setUpdatedAt(UPDATED_AT); + + return entry; + } + + private void createStandardDeposit(Client client, Account account) + { + var entry = new LedgerTransactionCreator(client) + .createDeposit(LedgerTransactionMetadata.of(LocalDateTime.of(2019, 12, 30, 0, 0)), + LedgerAccountCashLeg.of(account, + Money.of(CurrencyUnit.EUR, + Values.Amount.factorize(10000)))) + .getEntry(); + + entry.setUpdatedAt(Instant.parse("2026-06-15T10:41:50.210577100Z")); + } + + private void createStandardBuy(Client client, Account account, Portfolio portfolio, Security siemens) + { + var entry = new LedgerTransactionCreator(client) + .createBuy(LedgerTransactionMetadata.of(BUY_DATE), + LedgerAccountCashLeg.of(account, Money.of(CurrencyUnit.EUR, 118640L)), + LedgerPortfolioSecurityLeg.of(portfolio, + LedgerSecurityQuantity.of(siemens, + Values.Share.factorize(10)), + Money.of(CurrencyUnit.EUR, 118640L)), + LedgerCreationUnits.none()) + .getEntry(); + + entry.setUpdatedAt(Instant.parse("2026-06-15T10:41:34.896212600Z")); + } + + private LedgerPosting invalidTargetSecurityPosting(Portfolio portfolio, Security security, long shares, long amount, + String leg, Security sourceSecurity, Security targetSecurity) + { + var posting = new LedgerPosting(); + + posting.setType(LedgerPostingType.SECURITY); + posting.setPortfolio(portfolio); + posting.setSecurity(security); + posting.setShares(shares); + posting.setAmount(amount); + posting.setCurrency(CurrencyUnit.EUR); + if (leg != null) + posting.addParameter(LedgerParameter.ofString( + LedgerParameterType.CORPORATE_ACTION_LEG, leg)); + if (sourceSecurity != null) + posting.addParameter(LedgerParameter.ofSecurity(LedgerParameterType.SOURCE_SECURITY, + sourceSecurity)); + if (targetSecurity != null) + posting.addParameter(LedgerParameter.ofSecurity(LedgerParameterType.TARGET_SECURITY, + targetSecurity)); + if (sourceSecurity != null && targetSecurity != null) + { + posting.addParameter(LedgerParameter.ofDecimal(LedgerParameterType.RATIO_NUMERATOR, + BigDecimal.ONE)); + posting.addParameter(LedgerParameter.ofDecimal(LedgerParameterType.RATIO_DENOMINATOR, + BigDecimal.valueOf(2))); + } + + return posting; + } + + private void assertSpinOffScenarioClient(Client client) + { + LedgerProjectionService.materialize(client); + + var entry = spinOffEntry(client); + + assertThat(client.getLedger().getEntries().size(), is(3)); + assertThat(client.getSecurities().stream().filter(security -> "DE0007236101".equals(security.getIsin())) + .count(), is(1L)); + assertThat(client.getSecurities().stream().filter(security -> "Siemens Energy AG".equals(security.getName())) + .count(), is(1L)); + assertThat(client.getPortfolios().get(0).getReferenceAccount(), is(client.getAccounts().get(0))); + assertThat(entry.getType(), is(LedgerEntryType.SPIN_OFF)); + assertThat(entry.getUpdatedAt(), is(UPDATED_AT)); + assertThat(entry.getPostings().size(), is(6)); + assertThat(entry.getProjectionRefs().size(), is(4)); + assertProjectionTargets(entry, LedgerProjectionRole.OLD_SECURITY_LEG, + securityPosting(entry, siemens(client), CorporateActionLeg.SOURCE_SECURITY.getCode(), + siemensEnergy(client)), + null); + assertProjectionTargets(entry, LedgerProjectionRole.DELIVERY_INBOUND, + securityPosting(entry, siemens(client), CorporateActionLeg.TARGET_SECURITY.getCode(), + siemens(client)), + null); + assertProjectionTargets(entry, LedgerProjectionRole.NEW_SECURITY_LEG, + securityPosting(entry, siemensEnergy(client), CorporateActionLeg.TARGET_SECURITY.getCode(), + siemensEnergy(client)), + null); + assertProjectionTargets(entry, LedgerProjectionRole.CASH_COMPENSATION, + primaryPosting(entry, LedgerProjectionRole.CASH_COMPENSATION), + primaryPosting(entry, LedgerProjectionRole.CASH_COMPENSATION)); + assertThat(client.getPortfolios().get(0).getTransactions().size(), is(4)); + assertThat(client.getAccounts().get(0).getTransactions().size(), is(3)); + assertThat(buyProjection(client, siemens(client)).getType(), is(PortfolioTransaction.Type.BUY)); + assertThat(portfolioProjection(client.getPortfolios().get(0), LedgerProjectionRole.OLD_SECURITY_LEG).getType(), + is(PortfolioTransaction.Type.DELIVERY_OUTBOUND)); + assertSame(siemens(client), + portfolioProjection(client.getPortfolios().get(0), LedgerProjectionRole.OLD_SECURITY_LEG) + .getSecurity()); + assertThat(portfolioProjection(client.getPortfolios().get(0), LedgerProjectionRole.OLD_SECURITY_LEG) + .getShares(), + is(Values.Share.factorize(10))); + assertThat(portfolioProjection(client.getPortfolios().get(0), LedgerProjectionRole.DELIVERY_INBOUND, + siemens(client)).getType(), is(PortfolioTransaction.Type.DELIVERY_INBOUND)); + assertSame(siemens(client), + portfolioProjection(client.getPortfolios().get(0), LedgerProjectionRole.DELIVERY_INBOUND, + siemens(client)).getSecurity()); + assertThat(portfolioProjection(client.getPortfolios().get(0), LedgerProjectionRole.DELIVERY_INBOUND, + siemens(client)).getShares(), + is(Values.Share.factorize(10))); + assertThat(portfolioProjection(client.getPortfolios().get(0), LedgerProjectionRole.DELIVERY_INBOUND, + siemens(client)).getAmount(), is(portfolioProjection(client.getPortfolios().get(0), + LedgerProjectionRole.OLD_SECURITY_LEG).getAmount())); + assertThat(portfolioProjection(client.getPortfolios().get(0), LedgerProjectionRole.NEW_SECURITY_LEG).getType(), + is(PortfolioTransaction.Type.DELIVERY_INBOUND)); + assertSame(siemensEnergy(client), + portfolioProjection(client.getPortfolios().get(0), LedgerProjectionRole.NEW_SECURITY_LEG) + .getSecurity()); + assertThat(portfolioProjection(client.getPortfolios().get(0), LedgerProjectionRole.NEW_SECURITY_LEG) + .getShares(), + is(Values.Share.factorize(5))); + assertThat(accountProjection(client.getAccounts().get(0), LedgerProjectionRole.CASH_COMPENSATION).getUnits() + .count(), is(2L)); + assertSpinOffSiemensPositionUnchanged(client.getPortfolios().get(0), siemens(client)); + assertTrue(LedgerStructuralValidator.validate(client.getLedger()).isOK()); + } + + private void assertSpinOffSiemensPositionUnchanged(Portfolio portfolio, Security siemens) + { + var oldLeg = portfolioProjection(portfolio, LedgerProjectionRole.OLD_SECURITY_LEG); + var retainedLeg = portfolioProjection(portfolio, LedgerProjectionRole.DELIVERY_INBOUND, siemens); + + assertThat(oldLeg.getShares(), is(Values.Share.factorize(10))); + assertThat(retainedLeg.getShares(), is(Values.Share.factorize(10))); + assertThat(retainedLeg.getShares() - oldLeg.getShares(), is(0L)); + } + + private LedgerPosting posting(LedgerEntry entry, LedgerPostingType type, Account account, long amount) + { + return entry.getPostings().stream().filter(posting -> posting.getType() == type) + .filter(posting -> posting.getAccount() == account).filter(posting -> posting.getAmount() == amount) + .findFirst().orElseThrow(); + } + + private LedgerPosting securityPosting(LedgerEntry entry, Security security, + String leg, Security targetSecurity) + { + return entry.getPostings().stream().filter(posting -> posting.getType() == LedgerPostingType.SECURITY) + .filter(posting -> posting.getSecurity() == security) + .filter(posting -> hasCorporateActionLeg(posting, leg)) + .filter(posting -> targetSecurity == null || hasTargetSecurity(posting, targetSecurity)) + .findFirst().orElseThrow(); + } + + private void assertProjectionTargets(LedgerEntry entry, LedgerProjectionRole role, LedgerPosting primaryPosting, + LedgerPosting postingGroup) + { + var projection = projection(entry, role); + assertThat(projection.getRole(), is(role)); + assertThat(primaryPostingUUID(projection), is(primaryPosting.getUUID())); + assertThat(postingGroupUUID(projection), is(postingGroup != null ? postingGroup.getUUID() : null)); + } + + private String primaryPostingUUID(LedgerProjectionRef projection) + { + return projection.getPrimaryMembership().map(ProjectionMembership::getPostingUUID) + .orElse(projection.getPrimaryPostingUUID()); + } + + private String postingGroupUUID(LedgerProjectionRef projection) + { + return projection.getMembershipsByRole(ProjectionMembershipRole.GROUP_ANCHOR).stream().findFirst() + .map(ProjectionMembership::getPostingUUID).orElse(projection.getPostingGroupUUID()); + } + + private PortfolioTransaction portfolioProjection(Portfolio portfolio, LedgerProjectionRole role) + { + return portfolio.getTransactions().stream().filter(LedgerBackedTransaction.class::isInstance) + .filter(transaction -> ((LedgerBackedTransaction) transaction).getLedgerProjectionRef() + .getRole() == role) + .findFirst().orElseThrow(); + } + + private PortfolioTransaction portfolioProjection(Portfolio portfolio, LedgerProjectionRole role, Security security) + { + return portfolio.getTransactions().stream().filter(LedgerBackedTransaction.class::isInstance) + .filter(transaction -> ((LedgerBackedTransaction) transaction).getLedgerProjectionRef() + .getRole() == role) + .filter(transaction -> transaction.getSecurity() == security).findFirst().orElseThrow(); + } + + private AccountTransaction accountProjection(Account account, LedgerProjectionRole role) + { + return account.getTransactions().stream().filter(LedgerBackedTransaction.class::isInstance) + .filter(transaction -> ((LedgerBackedTransaction) transaction).getLedgerProjectionRef() + .getRole() == role) + .findFirst().orElseThrow(); + } + + private AccountTransaction accountProjection(LedgerEntry entry) + { + var accountProjection = projection(entry, LedgerProjectionRole.ACCOUNT); + + return accountProjection.getAccount().getTransactions().stream().filter(LedgerBackedTransaction.class::isInstance) + .filter(transaction -> ((LedgerBackedTransaction) transaction).getLedgerEntry() == entry) + .filter(transaction -> ((LedgerBackedTransaction) transaction).getLedgerProjectionRef() + .getRole() == LedgerProjectionRole.ACCOUNT) + .findFirst().orElseThrow(); + } + + private PortfolioTransaction buyProjection(Client client, Security siemens) + { + LedgerProjectionService.materialize(client); + + return client.getPortfolios().stream().flatMap(portfolio -> portfolio.getTransactions().stream()) + .filter(LedgerBackedTransaction.class::isInstance) + .filter(transaction -> transaction.getType() == PortfolioTransaction.Type.BUY) + .filter(transaction -> BUY_DATE.equals(transaction.getDateTime())) + .filter(transaction -> transaction.getSecurity() == siemens).findFirst().orElseThrow(); + } + + private LedgerProjectionRef projection(LedgerEntry entry, LedgerProjectionRole role) + { + return entry.getProjectionRefs().stream().filter(ref -> ref.getRole() == role).findFirst().orElseThrow(); + } + + private LedgerPosting primaryPosting(LedgerEntry entry, LedgerProjectionRole role) + { + return LedgerProjectionSupport.primaryPosting(entry, projection(entry, role)); + } + + private boolean hasCorporateActionLeg(LedgerPosting posting, String leg) + { + return posting.getParameters().stream() + .filter(parameter -> parameter.getType() == LedgerParameterType.CORPORATE_ACTION_LEG) + .anyMatch(parameter -> parameter.getValue().equals(leg)); + } + + private boolean hasTargetSecurity(LedgerPosting posting, Security security) + { + return posting.getParameters().stream() + .filter(parameter -> parameter.getType() == LedgerParameterType.TARGET_SECURITY) + .anyMatch(parameter -> parameter.getValue() == security); + } + + private void assertSecurityPostingUnchanged(LedgerEntry edited, LedgerPosting before) + { + var after = edited.getPostings().stream().filter(posting -> posting.getUUID().equals(before.getUUID())) + .findFirst().orElseThrow(); + + assertThat(after.getType(), is(before.getType())); + assertThat(after.getSecurity(), is(before.getSecurity())); + assertThat(after.getShares(), is(before.getShares())); + assertThat(after.getAmount(), is(before.getAmount())); + assertThat(after.getCurrency(), is(before.getCurrency())); + } + + private void assertXmlRoundtripHasEditedBuy(Client client) throws Exception + { + var loaded = loadXml(saveXml(client)); + var buy = buyProjection(loaded, siemens(loaded)); + + assertThat(buy.getShares(), is(Values.Share.factorize(100))); + assertFalse(saveXml(loaded).contains(" "DE0007236101".equals(security.getIsin())) + .findFirst().orElseThrow(); + } + + private Security siemensEnergy(Client client) + { + return client.getSecurities().stream().filter(security -> "DE000ENER6Y0".equals(security.getIsin())) + .findFirst().orElseThrow(); + } + + private String saveXml(Client client) throws Exception + { + var file = Files.createTempFile("ledger-spin-off", ".xml"); + + try + { + ClientFactory.save(client, file.toFile()); + return Files.readString(file, StandardCharsets.UTF_8); + } + finally + { + Files.deleteIfExists(file); + } + } + + private Client loadXml(String xml) throws Exception + { + return ClientFactory.load(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); + } + + private byte[] saveProtobuf(Client client) throws Exception + { + var stream = new ByteArrayOutputStream(); + var writer = protobufWriter(); + var save = writer.getClass().getDeclaredMethod("save", Client.class, java.io.OutputStream.class); + + save.setAccessible(true); + invoke(save, writer, client, stream); + + return stream.toByteArray(); + } + + private Client loadProtobuf(byte[] bytes) throws Exception + { + var writer = protobufWriter(); + var load = writer.getClass().getDeclaredMethod("load", java.io.InputStream.class); + + load.setAccessible(true); + return (Client) invoke(load, writer, new ByteArrayInputStream(bytes)); + } + + private Object protobufWriter() throws Exception + { + var type = Class.forName("name.abuchen.portfolio.model.ProtobufWriter"); + var constructor = type.getDeclaredConstructor(); + + constructor.setAccessible(true); + return constructor.newInstance(); + } + + private Object invoke(java.lang.reflect.Method method, Object target, Object... args) throws Exception + { + try + { + return method.invoke(target, args); + } + catch (InvocationTargetException e) + { + var cause = e.getCause(); + + if (cause instanceof Exception exception) + throw exception; + if (cause instanceof Error error) + throw error; + + throw new AssertionError(cause); + } + } + + private Account account(String name) + { + var account = new Account(); + + account.setName(name); + account.setCurrencyCode(CurrencyUnit.EUR); + account.setUpdatedAt(UPDATED_AT); + + return account; + } + + private Portfolio portfolio(String name) + { + var portfolio = new Portfolio(); + + portfolio.setName(name); + portfolio.setUpdatedAt(UPDATED_AT); + + return portfolio; + } + + private Security security(String name, String isin, String ticker) + { + var security = new Security(name, CurrencyUnit.EUR); + + security.setIsin(isin); + security.setTickerSymbol(ticker); + security.setUpdatedAt(UPDATED_AT); + + return security; + } + + private Path xmlExample() + { + var current = Path.of("").toAbsolutePath(); + + while (current != null) + { + var candidate = current.resolve(XML_EXAMPLE); + + if (Files.exists(candidate)) + return candidate; + + current = current.getParent(); + } + + return Path.of("").toAbsolutePath().resolve(XML_EXAMPLE); + } + + private record SpinOffFixture(Client client, Account account, Portfolio portfolio, Security siemens, + Security siemensEnergy) + { + } +} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffXmlDefinitionCheckerTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffXmlDefinitionCheckerTest.java new file mode 100644 index 0000000000..fbd4634222 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffXmlDefinitionCheckerTest.java @@ -0,0 +1,434 @@ +package name.abuchen.portfolio.model.ledger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import org.junit.Test; + +import name.abuchen.portfolio.model.ClientFactory; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinition; +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.model.ledger.configuration.rule.LedgerParameterRule; +import name.abuchen.portfolio.model.ledger.configuration.rule.LedgerPostingRule; +import name.abuchen.portfolio.model.ledger.configuration.rule.LedgerRequirementGroup; + +@SuppressWarnings("nls") +public final class LedgerSpinOffXmlDefinitionCheckerTest +{ + private static final Path XML_EXAMPLE = Path + .of("docs/ledger-v6/examples/ledger-v6-spin-off-siemens-energy-example.xml"); + + /** + * Checks the persistence scenario: siemens energy xml example matches static ledger definitions. + * The loaded model must rebuild visible rows from ledger truth. + * This protects compatibility data from becoming a second transaction source. + */ + @Test + public void testSiemensEnergyXmlExampleMatchesStaticLedgerDefinitions() throws IOException + { + var report = check(XML_EXAMPLE); + + System.out.println(report.markdown()); + + assertTrue(report.structuralValidationOK()); + assertEquals(0, report.summary().unknownParameterCount()); + assertEquals(0, report.summary().valueKindMismatchCount()); + assertEquals(0, report.summary().notAllowedCount()); + assertEquals(0, report.summary().definitionGapCount()); + assertEquals(1, report.spinOffEntryCount()); + } + + public static Report check(Path xmlFile) throws IOException + { + xmlFile = resolve(xmlFile); + + var client = ClientFactory.load(Files.newInputStream(xmlFile)); + var structuralResult = LedgerStructuralValidator.validate(client.getLedger()); + var summary = new Summary(); + var sections = new ArrayList(); + var codeDomainRows = new ArrayList(); + var spinOffEntryCount = 0; + + for (var entry : client.getLedger().getEntries()) + { + if (entry.getType() == LedgerEntryType.SPIN_OFF) + spinOffEntryCount++; + + var definition = entry.getType() == null ? null + : LedgerEntryDefinitionRegistry.lookup(entry.getType()).orElse(null); + + sections.add(entrySection(entry, definition, codeDomainRows, summary)); + + for (var posting : entry.getPostings()) + sections.add(postingSection(entry, posting, definition, codeDomainRows, summary)); + } + + return new Report(xmlFile, structuralResult, sections, codeDomainRows, summary, spinOffEntryCount); + } + + private static Path resolve(Path file) + { + var current = Path.of("").toAbsolutePath(); + + while (current != null) + { + var candidate = current.resolve(file); + + if (Files.exists(candidate)) + return candidate; + + current = current.getParent(); + } + + return Path.of("").toAbsolutePath().resolve(file); + } + + private static String entrySection(LedgerEntry entry, LedgerEntryDefinition definition, + List codeDomainRows, Summary summary) + { + var builder = new StringBuilder(); + var allowed = definition == null ? Set.of() : definition.getEntryParameterTypes(); + + builder.append("## LedgerEntry `").append(entry.getUUID()).append("`\n\n"); + builder.append("* Entry type: `").append(entry.getType()).append("`\n"); + builder.append("* Definition: `") + .append(definition == null ? "missing" : definition.getEntryType().name()) + .append("`\n\n"); + builder.append("### Entry Parameters\n\n"); + builder.append("| Parameter | ValueKind | Expected | Rule | Alternative group | Code domain | Result |\n"); + builder.append("| --- | --- | --- | --- | --- | --- | --- |\n"); + + for (var parameter : entry.getParameters()) + { + var type = parameter.getType(); + var definitionAllowed = type != null && allowed.contains(type); + var rule = definition == null || type == null ? "-" + : parameterRule(definition.getEntryParameterRules(), type); + var alternative = definition == null || type == null ? "-" + : alternativeGroups(definition.getAlternativeRequirementGroups(), type); + var result = parameterResult(parameter, definitionAllowed, false, summary); + + appendParameterRow(builder, parameter, rule, alternative, result); + appendCodeDomainRow(codeDomainRows, "entry " + entry.getUUID(), parameter, summary); + } + + if (entry.getParameters().isEmpty()) + builder.append("| _none_ | | | | | | |\n"); + + builder.append("\n"); + builder.append("Allowed entry parameters from `LedgerEntryDefinition`: ") + .append(formatTypes(allowed)).append("\n\n"); + + return builder.toString(); + } + + private static String postingSection(LedgerEntry entry, LedgerPosting posting, LedgerEntryDefinition entryDefinition, + List codeDomainRows, Summary summary) + { + var builder = new StringBuilder(); + var entryPostingTypes = entryDefinition == null ? Set.of() + : entryDefinition.getPostingTypes(); + var entryPostingParameters = entryDefinition == null ? Set.of() + : entryDefinition.getPostingParameterTypes(); + var postingDefinition = posting.getType() == null ? null + : LedgerPostingTypeDefinitionRegistry.lookup(posting.getType()).orElse(null); + var postingParameters = postingDefinition == null ? Set.of() + : postingDefinition.getComponentParameterTypes(); + var postingRule = entryDefinition == null || posting.getType() == null ? null + : postingRule(entryDefinition.getPostingRules(), posting.getType()); + var postingTypeAllowed = entryDefinition == null + || posting.getType() != null && entryPostingTypes.contains(posting.getType()); + + if (entryDefinition != null && !postingTypeAllowed) + summary.definitionGapCount++; + + builder.append("## LedgerPosting `").append(posting.getUUID()).append("`\n\n"); + builder.append("* Posting type: `").append(posting.getType()).append("`\n"); + builder.append("* Posting type allowed by entry definition: `") + .append(entryDefinition == null ? "not applicable" : postingTypeAllowed).append("`\n"); + builder.append("* Posting rule: `").append(postingRule == null ? "-" : postingRule.getRequirement()) + .append("`\n\n"); + builder.append("### Posting Parameters\n\n"); + builder.append("| Parameter | ValueKind | Expected | EntryDefinition | PostingTypeDefinition | Rule | Code domain | Result |\n"); + builder.append("| --- | --- | --- | --- | --- | --- | --- | --- |\n"); + + for (var parameter : posting.getParameters()) + { + var type = parameter.getType(); + var entryAllowed = type != null && entryPostingParameters.contains(type); + var postingAllowed = type != null && postingParameters.contains(type); + var result = parameterResult(parameter, entryAllowed, postingAllowed, summary); + var rule = postingRuleDescription(postingRule, type); + + builder.append("| `").append(type).append("` | `").append(parameter.getValueKind()) + .append("` | `").append(type == null ? "" : type.getExpectedValueKind()) + .append("` | ").append(definitionStatus(entryAllowed)).append(" | ") + .append(definitionStatus(postingAllowed)).append(" | ") + .append(escape(rule)).append(" | ") + .append(codeDomain(parameter)).append(" | ").append(result).append(" |\n"); + + appendCodeDomainRow(codeDomainRows, "posting " + posting.getUUID(), parameter, summary); + } + + if (posting.getParameters().isEmpty()) + builder.append("| _none_ | | | | | | | |\n"); + + builder.append("\n"); + builder.append("Entry-definition posting parameter vocabulary: ") + .append(formatTypes(entryPostingParameters)).append("\n\n"); + builder.append("Posting-type definition parameter vocabulary: ") + .append(formatTypes(postingParameters)).append("\n\n"); + + return builder.toString(); + } + + private static String parameterResult(LedgerParameter parameter, boolean definitionAllowed, + boolean postingDefinitionAllowed, Summary summary) + { + var type = parameter.getType(); + + if (type == null) + { + summary.unknownParameterCount++; + return "`UNKNOWN_PARAMETER`"; + } + + if (parameter.getValueKind() == null || parameter.getValue() == null + || !type.supportsValueKind(parameter.getValueKind()) + || !parameter.getValueKind().supportsValue(parameter.getValue())) + { + summary.valueKindMismatchCount++; + return "`VALUE_KIND_MISMATCH`"; + } + + if (!definitionAllowed && !postingDefinitionAllowed) + { + summary.definitionGapCount++; + return "`DEFINITION_GAP`"; + } + + summary.okCount++; + + if (definitionAllowed && postingDefinitionAllowed) + return "`OK_BOTH_DEFINITIONS`"; + + if (definitionAllowed) + return "`OK_ENTRY_DEFINITION_ONLY`"; + + return "`OK_POSTING_TYPE_DEFINITION_ONLY`"; + } + + private static void appendParameterRow(StringBuilder builder, LedgerParameter parameter, String rule, + String alternative, String result) + { + var type = parameter.getType(); + + builder.append("| `").append(type).append("` | `").append(parameter.getValueKind()).append("` | `") + .append(type == null ? "" : type.getExpectedValueKind()).append("` | ") + .append(escape(rule)).append(" | ").append(escape(alternative)).append(" | ") + .append(codeDomain(parameter)).append(" | ").append(result).append(" |\n"); + } + + private static void appendCodeDomainRow(List rows, String owner, LedgerParameter parameter, Summary summary) + { + var type = parameter.getType(); + + if (type == null || !type.hasCodeDomain()) + return; + + var value = String.valueOf(parameter.getValue()); + var domain = type.getCodeDomain(); + var allowed = domain.allows(value); + + if (!allowed) + summary.notAllowedCount++; + + rows.add(new Row(owner, type.name(), value, domain.name(), + String.join(", ", domain.getAllowedCodes()), allowed ? "OK" : "NOT_ALLOWED")); + } + + private static String codeDomain(LedgerParameter parameter) + { + var type = parameter.getType(); + + if (type == null || !type.hasCodeDomain()) + return "`-`"; + + return "`" + type.getCodeDomain() + "`"; + } + + private static String parameterRule(Set rules, LedgerParameterType type) + { + for (var rule : rules) + if (rule.getParameterType() == type) + return rule.getRequirement() + (rule.isRepeatable() ? ", repeatable" : ""); + + return "-"; + } + + private static String postingRuleDescription(LedgerPostingRule postingRule, LedgerParameterType type) + { + if (postingRule == null || type == null) + return "-"; + + if (postingRule.getRequiredParameterTypes().contains(type)) + return "REQUIRED"; + + if (postingRule.getOptionalParameterTypes().contains(type)) + return "OPTIONAL"; + + return "-"; + } + + private static LedgerPostingRule postingRule(Set rules, LedgerPostingType type) + { + for (var rule : rules) + if (rule.getPostingType() == type) + return rule; + + return null; + } + + private static String alternativeGroups(Set groups, LedgerParameterType type) + { + var names = groups.stream().filter(group -> group.getParameterTypes().contains(type)) + .map(group -> group.getName() + " " + group.getRequirement()) + .collect(Collectors.joining(", ")); + + return names.isBlank() ? "-" : names; + } + + private static String definitionStatus(boolean allowed) + { + return allowed ? "`allowed`" : "`not listed`"; + } + + private static String formatTypes(Set types) + { + if (types.isEmpty()) + return "`-`"; + + return types.stream().map(type -> "`" + type.name() + "`").collect(Collectors.joining(", ")); + } + + private static String escape(String value) + { + return value == null || value.isBlank() ? "`-`" : "`" + value.replace("|", "\\|") + "`"; + } + + public record Report(Path xmlFile, LedgerStructuralValidator.ValidationResult structuralResult, + List sections, List codeDomainRows, Summary summary, int spinOffEntryCount) + { + public boolean structuralValidationOK() + { + return structuralResult.isOK(); + } + + public String markdown() + { + var builder = new StringBuilder(); + + builder.append("# Ledger Spin-Off XML Definition Check\n\n"); + builder.append("* XML file: `").append(xmlFile).append("`\n"); + builder.append("* XML structural load / LedgerStructuralValidator: `") + .append(structuralResult.isOK() ? "OK" : "NOT_OK").append("`\n"); + builder.append("* Spin-off entries: `").append(spinOffEntryCount).append("`\n\n"); + builder.append("This diagnostic reads the XML through `ClientFactory`, inspects `Client#getLedger()`, ") + .append("and compares the stored `LedgerParameter` values against the static ") + .append("Ledger definition registries. It reports required, optional, and alternative ") + .append("definition metadata, but it does not implement productive corporate-action ") + .append("completeness validation.\n\n"); + builder.append("## Summary\n\n"); + builder.append("| Counter | Value |\n"); + builder.append("| --- | ---: |\n"); + builder.append("| OK | ").append(summary.okCount()).append(" |\n"); + builder.append("| NOT_ALLOWED | ").append(summary.notAllowedCount()).append(" |\n"); + builder.append("| UNKNOWN_PARAMETER | ").append(summary.unknownParameterCount()).append(" |\n"); + builder.append("| VALUE_KIND_MISMATCH | ").append(summary.valueKindMismatchCount()).append(" |\n"); + builder.append("| DEFINITION_GAP | ").append(summary.definitionGapCount()).append(" |\n\n"); + builder.append("## Static Event Parameter Vocabulary\n\n"); + builder.append(formatTypes(EnumSet.copyOf(LedgerEventParameterDefinition.getParameterTypes()))) + .append("\n\n"); + builder.append(String.join("\n", sections)); + builder.append("## Code Domain Results\n\n"); + builder.append("| Owner | ParameterType | XML value | CodeDomain | Allowed codes | Result |\n"); + builder.append("| --- | --- | --- | --- | --- | --- |\n"); + + for (var row : codeDomainRows) + builder.append("| ").append(escapeCell(row.owner())).append(" | `").append(row.parameterType()) + .append("` | `").append(escapeCell(row.value())).append("` | `") + .append(row.codeDomain()).append("` | ") + .append(escapeCell(row.allowedCodes())).append(" | `").append(row.result()) + .append("` |\n"); + + if (codeDomainRows.isEmpty()) + builder.append("| _none_ | | | | | |\n"); + + builder.append("\n## Boundary Statement\n\n"); + builder.append("The diagnostic separates XML structural load, generic `LedgerStructuralValidator` ") + .append("result, definition vocabulary checks, and code-domain checks. It does not ") + .append("change production validation and does not state that Portfolio Performance ") + .append("productively enforces corporate-action completeness.\n"); + + return builder.toString(); + } + + private static String escapeCell(String value) + { + return value.replace("|", "\\|"); + } + } + + public static final class Summary + { + private int okCount; + private int notAllowedCount; + private int unknownParameterCount; + private int valueKindMismatchCount; + private int definitionGapCount; + + public int okCount() + { + return okCount; + } + + public int notAllowedCount() + { + return notAllowedCount; + } + + public int unknownParameterCount() + { + return unknownParameterCount; + } + + public int valueKindMismatchCount() + { + return valueKindMismatchCount; + } + + public int definitionGapCount() + { + return definitionGapCount; + } + } + + public record Row(String owner, String parameterType, String value, String codeDomain, String allowedCodes, + String result) + { + } +} 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..ec2ddad1ed --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerStructuralValidatorTest.java @@ -0,0 +1,1191 @@ +package name.abuchen.portfolio.model.ledger; + +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.lang.reflect.Field; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +import org.junit.Test; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.Client; +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.LedgerStructuralValidator.IssueCode; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountCashLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerTransactionCreator; +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.FeeReason; +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.model.ledger.configuration.TaxReason; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Money; + +/** + * Tests structural validation for ledger entries. + * These tests make sure malformed ledger facts are rejected before they can become persisted transaction truth. + */ +@SuppressWarnings("nls") +public class LedgerStructuralValidatorTest +{ + /** + * Checks the ledger rule scenario: empty ledger is valid. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testEmptyLedgerIsValid() + { + assertOK(LedgerStructuralValidator.validate(new Ledger())); + } + + /** + * Checks the ledger rule scenario: simple valid ledger entry passes validation. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testSimpleValidLedgerEntryPassesValidation() + { + assertOK(LedgerStructuralValidator.validate(createStandardLedger())); + } + + /** + * Checks the ledger rule scenario: validation issue formats code and message. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testValidationIssueFormatsCodeAndMessage() + { + var ledger = createStandardLedger(); + var posting = ledger.getEntries().get(0).getPostings().get(0); + + posting.setCurrency(null); + + var issue = LedgerStructuralValidator.validate(ledger).getIssues().get(0); + + assertTrue(issue.format(), issue.format().startsWith("[POSTING_CURRENCY_REQUIRED] ")); + assertTrue(issue.format(), issue.format().contains("[LEDGER-STRUCT-014] ")); + assertTrue(issue.format(), issue.format().contains("\n Entry:\n")); + assertTrue(issue.format(), issue.format().contains("\n Currency: ")); + assertTrue(issue.format(), !issue.format().contains("{")); + assertTrue(issue.toString(), issue.toString().contains("POSTING_CURRENCY_REQUIRED")); + } + + /** + * Checks the ledger rule scenario: validation result formats issues deterministically. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testValidationResultFormatsIssuesDeterministically() + { + var ledger = createStandardLedger(); + var entry = ledger.getEntries().get(0); + var posting = entry.getPostings().get(0); + + entry.setDateTime(null); + posting.setCurrency(null); + + var result = LedgerStructuralValidator.validate(ledger); + var format = result.format(); + + assertTrue(format, format.startsWith("[ENTRY_DATE_TIME_REQUIRED] ")); + assertTrue(format, format.contains("[LEDGER-STRUCT-008] ")); + assertTrue(format, format.contains("\n\n[POSTING_CURRENCY_REQUIRED] ")); + assertTrue(format, format.contains("[LEDGER-STRUCT-014] ")); + assertTrue(format, format.contains("\n Entry:\n")); + assertTrue(format, format.contains("\n Posting:\n")); + assertTrue(result.toString(), result.toString().contains("POSTING_CURRENCY_REQUIRED")); + } + + /** + * Checks the ledger rule scenario: creator exception uses formatted diagnostics. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testCreatorExceptionUsesFormattedDiagnostics() + { + var client = new Client(); + var account = account(); + var ledger = createStandardLedger(); + + ledger.getEntries().get(0).getPostings().get(0).setCurrency(null); + client.addAccount(account); + client.getLedger().addEntry(ledger.getEntries().get(0)); + + var exception = assertThrows(IllegalArgumentException.class, + () -> new LedgerTransactionCreator(client).createDeposit( + LedgerTransactionMetadata.of(LocalDateTime.of(2026, 1, 3, 0, 0)), + LedgerAccountCashLeg.of(account, Money.of(CurrencyUnit.EUR, 1L)))); + + assertTrue(exception.getMessage(), exception.getMessage().contains("[POSTING_CURRENCY_REQUIRED] ")); + assertTrue(exception.getMessage(), exception.getMessage().contains("[LEDGER-STRUCT-014] ")); + assertTrue(exception.getMessage(), exception.getMessage().contains("\n Posting:\n")); + } + + /** + * Checks the ledger rule scenario: duplicate entry uuidis reported. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testDuplicateEntryUUIDIsReported() + { + var ledger = new Ledger(); + + ledger.addEntry(validEntry("entry-1", LedgerEntryType.DEPOSIT)); + ledger.addEntry(validEntry("entry-1", LedgerEntryType.REMOVAL)); + + assertIssue(ledger, IssueCode.DUPLICATE_ENTRY_UUID); + } + + /** + * Checks the ledger rule scenario: duplicate posting uuidis reported. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testDuplicatePostingUUIDIsReported() + { + var ledger = new Ledger(); + var entry1 = validEntry("entry-1", LedgerEntryType.DEPOSIT); + var entry2 = validEntry("entry-2", LedgerEntryType.REMOVAL); + + entry1.addPosting(validPosting("posting-1")); + entry2.addPosting(validPosting("posting-1")); + ledger.addEntry(entry1); + ledger.addEntry(entry2); + + assertIssue(ledger, IssueCode.DUPLICATE_POSTING_UUID); + + var format = findIssue(ledger, IssueCode.DUPLICATE_POSTING_UUID).format(); + + assertTrue(format, format.contains("\n Duplicate:\n")); + assertTrue(format, format.contains("DuplicateUUID: posting-1")); + assertTrue(format, format.contains("ObjectType: LedgerPosting")); + assertTrue(format, format.contains("OccurrenceCount: 2")); + assertTrue(format, format.contains("UUID: entry-2")); + } + + /** + * Checks the ledger rule scenario: duplicate projection ref uuidis reported. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testDuplicateProjectionRefUUIDIsReported() + { + var ledger = new Ledger(); + var entry1 = validEntry("entry-1", LedgerEntryType.DEPOSIT); + var entry2 = validEntry("entry-2", LedgerEntryType.REMOVAL); + + entry1.addProjectionRef(accountProjection("projection-1")); + entry2.addProjectionRef(accountProjection("projection-1")); + ledger.addEntry(entry1); + ledger.addEntry(entry2); + + assertIssue(ledger, IssueCode.DUPLICATE_PROJECTION_REF_UUID); + } + + /** + * Checks the ledger rule scenario: missing required entry fields are reported. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testMissingRequiredEntryFieldsAreReported() throws ReflectiveOperationException + { + var ledger = new Ledger(); + var entry = new LedgerEntry("entry-1"); + + setField(entry, "uuid", null); + ledger.addEntry(entry); + + var result = LedgerStructuralValidator.validate(ledger); + + assertTrue(result.hasIssue(IssueCode.ENTRY_UUID_REQUIRED)); + assertTrue(result.hasIssue(IssueCode.ENTRY_TYPE_REQUIRED)); + assertTrue(result.hasIssue(IssueCode.ENTRY_DATE_TIME_REQUIRED)); + } + + /** + * Checks the ledger rule scenario: missing required posting fields are reported. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testMissingRequiredPostingFieldsAreReported() throws ReflectiveOperationException + { + var ledger = new Ledger(); + var entry = validEntry("entry-1", LedgerEntryType.DEPOSIT); + var posting = new LedgerPosting("posting-1"); + + setField(posting, "uuid", null); + entry.addPosting(posting); + ledger.addEntry(entry); + + var result = LedgerStructuralValidator.validate(ledger); + + assertTrue(result.hasIssue(IssueCode.POSTING_UUID_REQUIRED)); + assertTrue(result.hasIssue(IssueCode.POSTING_TYPE_REQUIRED)); + } + + /** + * Checks the ledger rule scenario: missing required projection fields are reported. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testMissingRequiredProjectionFieldsAreReported() throws ReflectiveOperationException + { + var ledger = new Ledger(); + var entry = validEntry("entry-1", LedgerEntryType.DEPOSIT); + var projection = new LedgerProjectionRef("projection-1"); + + setField(projection, "uuid", null); + entry.addProjectionRef(projection); + ledger.addEntry(entry); + + var result = LedgerStructuralValidator.validate(ledger); + + assertTrue(result.hasIssue(IssueCode.PROJECTION_REF_UUID_REQUIRED)); + assertTrue(result.hasIssue(IssueCode.PROJECTION_REF_ROLE_REQUIRED)); + } + + /** + * Checks the ledger rule scenario: invalid owner role combination is reported. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testInvalidOwnerRoleCombinationIsReported() + { + var ledger = new Ledger(); + var entry = validEntry("entry-1", LedgerEntryType.DEPOSIT); + var projection = new LedgerProjectionRef("projection-1"); + + projection.setRole(LedgerProjectionRole.ACCOUNT); + projection.setPortfolio(new Portfolio()); + entry.addProjectionRef(projection); + ledger.addEntry(entry); + + var result = LedgerStructuralValidator.validate(ledger); + + assertTrue(result.hasIssue(IssueCode.PROJECTION_REF_ACCOUNT_REQUIRED)); + assertTrue(result.hasIssue(IssueCode.PROJECTION_REF_PORTFOLIO_NOT_ALLOWED)); + + var format = findIssue(ledger, IssueCode.PROJECTION_REF_ACCOUNT_REQUIRED).format(); + + assertTrue(format, format.contains("\n Projection:\n")); + assertTrue(format, format.contains("UUID: projection-1")); + assertTrue(format, format.contains("Role: ACCOUNT")); + assertTrue(format, format.contains("Portfolio: ")); + } + + /** + * Checks the ledger rule scenario: portfolio role requires portfolio ownership. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testPortfolioRoleRequiresPortfolioOwnership() + { + var ledger = new Ledger(); + var entry = validEntry("entry-1", LedgerEntryType.DEPOSIT); + var projection = new LedgerProjectionRef("projection-1"); + + projection.setRole(LedgerProjectionRole.DELIVERY_INBOUND); + projection.setAccount(new Account()); + entry.addProjectionRef(projection); + ledger.addEntry(entry); + + var result = LedgerStructuralValidator.validate(ledger); + + assertTrue(result.hasIssue(IssueCode.PROJECTION_REF_PORTFOLIO_REQUIRED)); + assertTrue(result.hasIssue(IssueCode.PROJECTION_REF_ACCOUNT_NOT_ALLOWED)); + } + + /** + * Checks the ledger rule scenario: spin off without targeted projection reference is reported. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testSpinOffWithoutTargetedProjectionReferenceIsReported() + { + var ledger = new Ledger(); + var entry = validEntry("entry-1", LedgerEntryType.SPIN_OFF); + + entry.addPosting(validPosting("posting-1")); + entry.addProjectionRef(portfolioProjection("projection-1")); + ledger.addEntry(entry); + + assertIssue(ledger, IssueCode.TARGETING_REF_REQUIRED); + } + + /** + * Checks the ledger rule scenario: invalid primary posting uuidis reported. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testInvalidPrimaryPostingUUIDIsReported() + { + var ledger = new Ledger(); + var entry = validEntry("entry-1", LedgerEntryType.SPIN_OFF); + var projection = portfolioProjection("projection-1"); + + entry.addPosting(validPosting("posting-1")); + projection.setPrimaryPostingUUID("missing-posting"); + entry.addProjectionRef(projection); + ledger.addEntry(entry); + + assertIssue(ledger, IssueCode.PRIMARY_POSTING_REF_NOT_FOUND); + } + + /** + * Checks the ledger rule scenario: fixed-shape primary posting uuid must target the same entry. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testFixedShapeInvalidPrimaryPostingUUIDIsReported() + { + var ledger = new Ledger(); + var entry = validEntry("entry-1", LedgerEntryType.DEPOSIT); + var projection = accountProjection("projection-1"); + + entry.addPosting(validPosting("posting-1")); + projection.setPrimaryPostingUUID("missing-posting"); + entry.addProjectionRef(projection); + ledger.addEntry(entry); + + assertIssue(ledger, IssueCode.PRIMARY_POSTING_REF_NOT_FOUND); + } + + /** + * Checks the ledger rule scenario: primary posting uuidmust target posting inside same entry. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testPrimaryPostingUUIDMustTargetPostingInsideSameEntry() + { + var ledger = new Ledger(); + var entry = validEntry("entry-1", LedgerEntryType.SPIN_OFF); + var otherEntry = validEntry("entry-2", LedgerEntryType.SPIN_OFF); + var projection = portfolioProjection("projection-1"); + + entry.addPosting(validPosting("posting-1")); + projection.setPrimaryPostingUUID("posting-2"); + entry.addProjectionRef(projection); + otherEntry.addPosting(validPosting("posting-2")); + ledger.addEntry(entry); + ledger.addEntry(otherEntry); + + assertIssue(ledger, IssueCode.PRIMARY_POSTING_REF_NOT_FOUND); + } + + /** + * Checks the ledger rule scenario: valid primary posting uuidpasses validation. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testValidPrimaryPostingUUIDPassesValidation() + { + var ledger = new Ledger(); + var entry = validEntry("entry-1", LedgerEntryType.SPIN_OFF); + var projection = portfolioProjection("projection-1"); + + entry.addPosting(validPosting("posting-1")); + projection.setPrimaryPostingUUID("posting-1"); + entry.addProjectionRef(projection); + ledger.addEntry(entry); + + assertOK(LedgerStructuralValidator.validate(ledger)); + } + + /** + * Checks the ledger rule scenario: primary projection membership can satisfy targeting. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testPrimaryMembershipPassesValidation() + { + var ledger = new Ledger(); + var entry = validEntry("entry-1", LedgerEntryType.SPIN_OFF); + var projection = portfolioProjection("projection-1"); + + entry.addPosting(validPosting("posting-1")); + projection.addMembership("posting-1", ProjectionMembershipRole.PRIMARY); + entry.addProjectionRef(projection); + ledger.addEntry(entry); + + assertOK(LedgerStructuralValidator.validate(ledger)); + } + + /** + * Checks the ledger rule scenario: projection membership target must resolve inside same entry. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testInvalidPrimaryMembershipPostingUUIDIsReported() + { + var ledger = new Ledger(); + var entry = validEntry("entry-1", LedgerEntryType.SPIN_OFF); + var projection = portfolioProjection("projection-1"); + + entry.addPosting(validPosting("posting-1")); + projection.addMembership("missing-posting", ProjectionMembershipRole.PRIMARY); + entry.addProjectionRef(projection); + ledger.addEntry(entry); + + assertIssue(ledger, IssueCode.PROJECTION_MEMBERSHIP_REF_NOT_FOUND); + } + + /** + * Checks the ledger rule scenario: primary membership and scalar fallback must not conflict. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testPrimaryMembershipAndScalarPrimaryConflictIsReported() + { + var ledger = new Ledger(); + var entry = validEntry("entry-1", LedgerEntryType.SPIN_OFF); + var projection = portfolioProjection("projection-1"); + + entry.addPosting(validPosting("posting-1")); + entry.addPosting(validPosting("posting-2")); + projection.setPrimaryPostingUUID("posting-1"); + projection.addMembership("posting-2", ProjectionMembershipRole.PRIMARY); + entry.addProjectionRef(projection); + ledger.addEntry(entry); + + assertIssue(ledger, IssueCode.PROJECTION_PRIMARY_TARGET_CONFLICT); + } + + /** + * Checks the ledger rule scenario: group membership and scalar fallback must not conflict. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testGroupMembershipAndScalarPostingGroupConflictIsReported() + { + var ledger = new Ledger(); + var entry = validEntry("entry-1", LedgerEntryType.SPIN_OFF); + var projection = portfolioProjection("projection-1"); + + entry.addPosting(validPosting("posting-1")); + entry.addPosting(validPosting("posting-2")); + projection.setPrimaryPostingUUID("posting-1"); + projection.setPostingGroupUUID("posting-1"); + projection.addMembership("posting-2", ProjectionMembershipRole.GROUP_ANCHOR); + entry.addProjectionRef(projection); + ledger.addEntry(entry); + + assertIssue(ledger, IssueCode.PROJECTION_GROUP_TARGET_CONFLICT); + } + + /** + * Checks the ledger rule scenario: ex-date with local date time passes validation. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testExDateWithLocalDateTimePassesValidation() + { + var ledger = createStandardLedger(); + var posting = ledger.getEntries().get(0).getPostings().get(0); + + posting.setSecurity(new Security("Security", CurrencyUnit.EUR)); + posting.addParameter(LedgerParameter.ofLocalDateTime(LedgerParameterType.EX_DATE, + LocalDateTime.of(2020, 9, 28, 0, 0))); + + assertOK(LedgerStructuralValidator.validate(ledger)); + } + + /** + * Checks the ledger rule scenario: ex-date with wrong value kind is reported. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testExDateWithWrongValueKindIsReported() + { + var ledger = createStandardLedger(); + var posting = ledger.getEntries().get(0).getPostings().get(0); + + posting.addParameter(LedgerParameter.unchecked(LedgerParameterType.EX_DATE, ValueKind.STRING, + "2020-09-28")); + + assertIssue(ledger, IssueCode.EX_DATE_VALUE_KIND_REQUIRED); + } + + /** + * Checks the ledger rule scenario: ex-date without posting security is reported. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testExDateWithoutPostingSecurityIsReported() + { + var ledger = createStandardLedger(); + var posting = ledger.getEntries().get(0).getPostings().get(0); + + posting.addParameter(LedgerParameter.ofLocalDateTime(LedgerParameterType.EX_DATE, + LocalDateTime.of(2020, 9, 28, 0, 0))); + + assertIssue(ledger, IssueCode.EX_DATE_SECURITY_REQUIRED); + } + + /** + * Checks the ledger rule scenario: parameter value kind mismatch is reported. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testParameterValueKindMismatchIsReported() + { + var ledger = createStandardLedger(); + var posting = ledger.getEntries().get(0).getPostings().get(0); + + posting.addParameter(LedgerParameter.unchecked(LedgerParameterType.FEE_REASON, + ValueKind.STRING, BigDecimal.ONE)); + + assertIssue(ledger, IssueCode.PARAMETER_VALUE_KIND_MISMATCH); + } + + /** + * Checks the ledger rule scenario: parameter type value kind mismatch is reported for every configured type. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testParameterTypeValueKindMismatchIsReportedForEveryConfiguredType() + { + for (var type : LedgerParameterType.values()) + { + var ledger = createStandardLedger(); + var posting = ledger.getEntries().get(0).getPostings().get(0); + var wrongValueKind = wrongValueKind(type); + + posting.setSecurity(new Security("Security", CurrencyUnit.EUR)); + posting.addParameter(LedgerParameter.unchecked(type, wrongValueKind, valueFor(wrongValueKind))); + + assertIssue(ledger, type == LedgerParameterType.EX_DATE ? IssueCode.EX_DATE_VALUE_KIND_REQUIRED + : IssueCode.PARAMETER_VALUE_KIND_MISMATCH); + } + } + + /** + * Checks the ledger rule scenario: supported parameter types pass validation. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testSupportedParameterTypesPassValidation() + { + var ledger = createStandardLedger(); + var posting = ledger.getEntries().get(0).getPostings().get(0); + var account = new Account(); + var portfolio = new Portfolio(); + var security = new Security("Security", CurrencyUnit.EUR); + + posting.setSecurity(security); + posting.addParameter(LedgerParameter.ofString(LedgerParameterType.FEE_REASON, + FeeReason.BROKER_FEE.getCode())); + posting.addParameter(LedgerParameter.ofString(LedgerParameterType.TAX_REASON, + TaxReason.WITHHOLDING_TAX.getCode())); + posting.addParameter(LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_KIND, + CorporateActionKind.SPIN_OFF.getCode())); + posting.addParameter(LedgerParameter.ofDecimal(LedgerParameterType.RATIO_NUMERATOR, + BigDecimal.ONE)); + posting.addParameter(LedgerParameter.ofDecimal(LedgerParameterType.RATIO_DENOMINATOR, + BigDecimal.valueOf(2))); + posting.addParameter(LedgerParameter.ofDecimal(LedgerParameterType.CONVERSION_RATIO, + BigDecimal.valueOf(3))); + posting.addParameter(LedgerParameter.ofMoney(LedgerParameterType.NOMINAL_VALUE, + Money.of(CurrencyUnit.EUR, 100L))); + posting.addParameter(LedgerParameter.ofSecurity(LedgerParameterType.SOURCE_SECURITY, security)); + posting.addParameter(LedgerParameter.ofSecurity(LedgerParameterType.TARGET_SECURITY, security)); + posting.addParameter(LedgerParameter.ofSecurity(LedgerParameterType.RIGHT_SECURITY, security)); + posting.addParameter(LedgerParameter.ofAccount(LedgerParameterType.SOURCE_ACCOUNT, account)); + posting.addParameter(LedgerParameter.ofPortfolio(LedgerParameterType.SOURCE_PORTFOLIO, + portfolio)); + posting.addParameter(LedgerParameter.ofLocalDate(LedgerParameterType.RECORD_DATE, + LocalDate.of(2026, 1, 2))); + posting.addParameter(LedgerParameter.ofBoolean(LedgerParameterType.CASH_IN_LIEU_APPLIED, + Boolean.TRUE)); + posting.addParameter(LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_LEG, + CorporateActionLeg.SOURCE_SECURITY.getCode())); + posting.addParameter(LedgerParameter.ofString(LedgerParameterType.CASH_COMPENSATION_KIND, + CashCompensationKind.CASH_IN_LIEU.getCode())); + posting.addParameter(LedgerParameter.ofLocalDateTime(LedgerParameterType.EX_DATE, + LocalDateTime.of(2020, 9, 28, 0, 0))); + + assertOK(LedgerStructuralValidator.validate(ledger)); + } + + /** + * Checks the ledger rule scenario: controlled ledger parameter codes pass validation. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testControlledLedgerParameterCodesPassValidation() + { + for (var type : LedgerParameterType.values()) + { + if (!type.hasCodeDomain()) + continue; + + var ledger = createStandardLedger(); + var posting = ledger.getEntries().get(0).getPostings().get(0); + var code = type.getCodeDomain().getAllowedCodes().get(0); + + posting.addParameter(LedgerParameter.ofString(type, code)); + + assertOK(LedgerStructuralValidator.validate(ledger)); + } + } + + /** + * Checks the ledger rule scenario: unknown controlled ledger parameter code is reported. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testUnknownControlledLedgerParameterCodeIsReported() + { + for (var type : LedgerParameterType.values()) + { + if (!type.hasCodeDomain()) + continue; + + var ledger = createStandardLedger(); + var posting = ledger.getEntries().get(0).getPostings().get(0); + + posting.addParameter(LedgerParameter.ofString(type, "UNKNOWN_CODE")); + + assertIssue(ledger, IssueCode.PARAMETER_CODE_NOT_ALLOWED); + } + } + + /** + * Checks the ledger rule scenario: free string ledger parameter still accepts arbitrary value. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testFreeStringLedgerParameterStillAcceptsArbitraryValue() + { + var ledger = createStandardLedger(); + var posting = ledger.getEntries().get(0).getPostings().get(0); + + posting.addParameter(LedgerParameter.ofString(LedgerParameterType.EVENT_REFERENCE, + "external reference #42")); + + assertOK(LedgerStructuralValidator.validate(ledger)); + } + + /** + * Checks the ledger rule scenario: entry level parameter passes generic validation. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testEntryLevelParameterPassesGenericValidation() + { + var ledger = createStandardLedger(); + var entry = ledger.getEntries().get(0); + + entry.addParameter(LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_KIND, + CorporateActionKind.SPIN_OFF.getCode())); + entry.addParameter(LedgerParameter.ofBoolean(LedgerParameterType.CASH_IN_LIEU_APPLIED, + Boolean.TRUE)); + + assertOK(LedgerStructuralValidator.validate(ledger)); + } + + /** + * Checks the ledger rule scenario: entry level parameter value kind mismatch is reported. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testEntryLevelParameterValueKindMismatchIsReported() + { + var ledger = createStandardLedger(); + var entry = ledger.getEntries().get(0); + + entry.addParameter(LedgerParameter.unchecked(LedgerParameterType.RECORD_DATE, ValueKind.STRING, + "2026-01-02")); + + assertIssue(ledger, IssueCode.PARAMETER_VALUE_KIND_MISMATCH); + } + + /** + * Checks the ledger rule scenario: entry level parameter java value mismatch is reported. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testEntryLevelParameterJavaValueMismatchIsReported() + { + var ledger = createStandardLedger(); + var entry = ledger.getEntries().get(0); + + entry.addParameter(LedgerParameter.unchecked(LedgerParameterType.CASH_IN_LIEU_APPLIED, + ValueKind.BOOLEAN, "true")); + + assertIssue(ledger, IssueCode.PARAMETER_VALUE_KIND_MISMATCH); + } + + /** + * Checks the ledger rule scenario: entry level controlled parameter unknown code is reported. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testEntryLevelControlledParameterUnknownCodeIsReported() + { + var ledger = createStandardLedger(); + var entry = ledger.getEntries().get(0); + + entry.addParameter(LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_KIND, + "UNKNOWN_CODE")); + + assertIssue(ledger, IssueCode.PARAMETER_CODE_NOT_ALLOWED); + } + + /** + * Checks the ledger rule scenario: entry level ex-date does not use posting security rule. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testEntryLevelExDateDoesNotUsePostingSecurityRule() + { + var ledger = createStandardLedger(); + var entry = ledger.getEntries().get(0); + + entry.addParameter(LedgerParameter.ofLocalDateTime(LedgerParameterType.EX_DATE, + LocalDateTime.of(2020, 9, 28, 0, 0))); + + assertOK(LedgerStructuralValidator.validate(ledger)); + } + + /** + * Checks the ledger rule scenario: fixed income money posting types pass generic currency validation. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testFixedIncomeMoneyPostingTypesPassGenericCurrencyValidation() + { + for (var type : new LedgerPostingType[] { LedgerPostingType.ACCRUED_INTEREST, + LedgerPostingType.PRINCIPAL_REDEMPTION }) + { + var ledger = createStandardLedger(); + var posting = ledger.getEntries().get(0).getPostings().get(0); + + posting.setType(type); + + assertOK(LedgerStructuralValidator.validate(ledger)); + } + } + + /** + * Checks the ledger rule scenario: fixed income money posting types require currency. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testFixedIncomeMoneyPostingTypesRequireCurrency() + { + for (var type : new LedgerPostingType[] { LedgerPostingType.ACCRUED_INTEREST, + LedgerPostingType.PRINCIPAL_REDEMPTION }) + { + var ledger = createStandardLedger(); + var posting = ledger.getEntries().get(0).getPostings().get(0); + + posting.setType(type); + posting.setCurrency(null); + + assertIssue(ledger, IssueCode.POSTING_CURRENCY_REQUIRED); + } + } + + /** + * Checks the ledger rule scenario: posting type currency policy drives generic validation. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testPostingTypeCurrencyPolicyDrivesGenericValidation() + { + for (var type : LedgerPostingType.values()) + { + var ledger = createStandardLedger(); + var posting = ledger.getEntries().get(0).getPostings().get(0); + + posting.setType(type); + posting.setCurrency(null); + posting.setSecurity(new Security("Security", CurrencyUnit.EUR)); + + var result = LedgerStructuralValidator.validate(ledger); + + if (type.requiresCurrency()) + assertTrue(type.name(), result.hasIssue(IssueCode.POSTING_CURRENCY_REQUIRED)); + else + assertTrue(type.name(), !result.hasIssue(IssueCode.POSTING_CURRENCY_REQUIRED)); + } + } + + /** + * Checks the ledger rule scenario: negative amount facts are rejected for standard types. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testNegativeAmountFactsAreRejectedForStandardTypes() + { + var ledger = createStandardLedger(); + var posting = ledger.getEntries().get(0).getPostings().get(0); + + posting.setAmount(-1L); + + assertIssue(ledger, IssueCode.SIGNED_FACT_NOT_ALLOWED); + } + + /** + * Checks the ledger rule scenario: negative share facts are rejected for standard types. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testNegativeShareFactsAreRejectedForStandardTypes() + { + var ledger = createStandardLedger(); + var posting = ledger.getEntries().get(0).getPostings().get(0); + + posting.setShares(-1L); + + assertIssue(ledger, IssueCode.SIGNED_FACT_NOT_ALLOWED); + } + + /** + * Checks the ledger rule scenario: missing currency on money bearing posting is reported. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testMissingCurrencyOnMoneyBearingPostingIsReported() + { + var ledger = createStandardLedger(); + var posting = ledger.getEntries().get(0).getPostings().get(0); + + posting.setCurrency(null); + + assertIssue(ledger, IssueCode.POSTING_CURRENCY_REQUIRED); + + var format = findIssue(ledger, IssueCode.POSTING_CURRENCY_REQUIRED).format(); + + assertTrue(format, format.contains("\n Entry:\n")); + assertTrue(format, format.contains("UUID: entry-1")); + assertTrue(format, format.contains("Type: DEPOSIT")); + assertTrue(format, format.contains("\n Posting:\n")); + assertTrue(format, format.contains("UUID: posting-1")); + assertTrue(format, format.contains("Type: CASH")); + assertTrue(format, format.contains("Currency: ")); + } + + /** + * Checks the ledger rule scenario: security posting without security is reported. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testSecurityPostingWithoutSecurityIsReported() + { + var ledger = createStandardLedger(); + var posting = ledger.getEntries().get(0).getPostings().get(0); + + posting.setType(LedgerPostingType.SECURITY); + + assertIssue(ledger, IssueCode.POSTING_SECURITY_REQUIRED); + + var format = findIssue(ledger, IssueCode.POSTING_SECURITY_REQUIRED).format(); + + assertTrue(format, format.contains("UUID: entry-1")); + assertTrue(format, format.contains("UUID: posting-1")); + assertTrue(format, format.contains("Type: SECURITY")); + assertTrue(format, format.contains("Security: ")); + } + + /** + * Checks the ledger rule scenario: dividend entry without security is reported. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testDividendEntryWithoutSecurityIsReported() + { + var ledger = createStandardLedger(); + var entry = ledger.getEntries().get(0); + + entry.setType(LedgerEntryType.DIVIDENDS); + + assertIssue(ledger, IssueCode.DIVIDEND_SECURITY_REQUIRED); + } + + /** + * Checks the ledger rule scenario: negative exchange rate is reported. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testNegativeExchangeRateIsReported() + { + var ledger = createStandardLedger(); + var posting = ledger.getEntries().get(0).getPostings().get(0); + + posting.setExchangeRate(BigDecimal.valueOf(-1)); + + assertIssue(ledger, IssueCode.POSTING_EXCHANGE_RATE_POSITIVE); + assertTrue(findIssue(ledger, IssueCode.POSTING_EXCHANGE_RATE_POSITIVE).getMessage() + .contains(LedgerDiagnosticCode.LEDGER_FOREX_002.prefix())); + } + + /** + * Checks the ledger rule scenario: fixed shape missing required projection role is reported. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testFixedShapeMissingRequiredProjectionRoleIsReported() + { + var ledger = new Ledger(); + var entry = validEntry("entry-1", LedgerEntryType.BUY); + + entry.addPosting(validPosting("posting-1")); + entry.addProjectionRef(accountProjection("projection-1")); + ledger.addEntry(entry); + + assertIssue(ledger, IssueCode.FIXED_SHAPE_PROJECTION_ROLE_REQUIRED); + } + + /** + * Checks the ledger rule scenario: fixed shape unexpected projection role is reported. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testFixedShapeUnexpectedProjectionRoleIsReported() + { + var ledger = new Ledger(); + var entry = validEntry("entry-1", LedgerEntryType.DEPOSIT); + + entry.addPosting(validPosting("posting-1")); + entry.addProjectionRef(portfolioProjection("projection-1")); + ledger.addEntry(entry); + + assertIssue(ledger, IssueCode.FIXED_SHAPE_PROJECTION_ROLE_NOT_ALLOWED); + } + + /** + * Checks the ledger rule scenario: signed posting facts are accepted for spin off. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testSignedPostingFactsAreAcceptedForSpinOff() + { + var ledger = new Ledger(); + var entry = validEntry("entry-1", LedgerEntryType.SPIN_OFF); + var posting = validPosting("posting-1"); + var projection = portfolioProjection("projection-1"); + + posting.setShares(-1L); + entry.addPosting(posting); + projection.setPrimaryPostingUUID(posting.getUUID()); + entry.addProjectionRef(projection); + ledger.addEntry(entry); + + assertOK(LedgerStructuralValidator.validate(ledger)); + } + + /** + * Checks the ledger rule scenario: signed posting facts are accepted for other ledger native targeted types. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testSignedPostingFactsAreAcceptedForOtherLedgerNativeTargetedTypes() + { + var ledger = new Ledger(); + var entry = validEntry("entry-1", LedgerEntryType.STOCK_DIVIDEND); + var posting = validPosting("posting-1"); + var projection = portfolioProjection("projection-1"); + + posting.setShares(-1L); + entry.addPosting(posting); + projection.setPrimaryPostingUUID(posting.getUUID()); + entry.addProjectionRef(projection); + ledger.addEntry(entry); + + assertOK(LedgerStructuralValidator.validate(ledger)); + } + + /** + * Checks the ledger rule scenario: invalid posting group uuidis reported. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testInvalidPostingGroupUUIDIsReported() + { + var ledger = new Ledger(); + var entry = validEntry("entry-1", LedgerEntryType.SPIN_OFF); + var projection = portfolioProjection("projection-1"); + + entry.addPosting(validPosting("posting-1")); + projection.setPrimaryPostingUUID("posting-1"); + projection.setPostingGroupUUID("group-1"); + entry.addProjectionRef(projection); + ledger.addEntry(entry); + + assertIssue(ledger, IssueCode.POSTING_GROUP_REF_NOT_FOUND); + } + + private Ledger createStandardLedger() + { + var ledger = new Ledger(); + var entry = validEntry("entry-1", LedgerEntryType.DEPOSIT); + + entry.addPosting(validPosting("posting-1")); + entry.addProjectionRef(accountProjection("projection-1")); + ledger.addEntry(entry); + + return ledger; + } + + private LedgerEntry validEntry(String uuid, LedgerEntryType type) + { + var entry = new LedgerEntry(uuid); + + entry.setType(type); + entry.setDateTime(LocalDateTime.of(2026, 1, 2, 0, 0)); + + return entry; + } + + private LedgerPosting validPosting(String uuid) + { + var posting = new LedgerPosting(uuid); + + posting.setType(LedgerPostingType.CASH); + posting.setAmount(100L); + posting.setCurrency(CurrencyUnit.EUR); + + return posting; + } + + private LedgerProjectionRef accountProjection(String uuid) + { + var projection = new LedgerProjectionRef(uuid); + + projection.setRole(LedgerProjectionRole.ACCOUNT); + projection.setAccount(new Account()); + + return projection; + } + + private Account account() + { + var account = new Account(); + + account.setCurrencyCode(CurrencyUnit.EUR); + + return account; + } + + private LedgerProjectionRef portfolioProjection(String uuid) + { + var projection = new LedgerProjectionRef(uuid); + + projection.setRole(LedgerProjectionRole.DELIVERY_INBOUND); + projection.setPortfolio(new Portfolio()); + + return projection; + } + + private void assertIssue(Ledger ledger, IssueCode code) + { + assertTrue(LedgerStructuralValidator.validate(ledger).hasIssue(code)); + } + + private LedgerStructuralValidator.ValidationIssue findIssue(Ledger ledger, IssueCode code) + { + return LedgerStructuralValidator.validate(ledger).getIssues().stream() // + .filter(issue -> issue.getCode() == code) // + .findFirst() // + .orElseThrow(); + } + + private void assertOK(LedgerStructuralValidator.ValidationResult result) + { + assertTrue(result.getIssues().toString(), result.isOK()); + } + + private void setField(Object target, String fieldName, Object value) throws ReflectiveOperationException + { + Field field = target.getClass().getDeclaredField(fieldName); + + field.setAccessible(true); + field.set(target, value); + } + + private ValueKind wrongValueKind(LedgerParameterType type) + { + for (var valueKind : ValueKind.values()) + if (!type.supportsValueKind(valueKind)) + return valueKind; + + throw new AssertionError(type); + } + + 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/nativeentry/LedgerNativeEntryAssemblerTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblerTest.java new file mode 100644 index 0000000000..98626d18da --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblerTest.java @@ -0,0 +1,744 @@ +package name.abuchen.portfolio.model.ledger.nativeentry; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.junit.Test; + +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.ledger.Ledger; +import name.abuchen.portfolio.model.ledger.LedgerParameter; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; +import name.abuchen.portfolio.model.ledger.ProjectionMembershipRole; +import name.abuchen.portfolio.model.ledger.configuration.CashCompensationKind; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionKind; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionSubtype; +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.LedgerEntryDefinitionRegistry; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +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.LedgerReportingClass; +import name.abuchen.portfolio.model.ledger.configuration.RoundingModeCode; +import name.abuchen.portfolio.model.ledger.configuration.TaxReason; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.Values; + +/** + * Tests ledger-native entry assembly for advanced transaction shapes. + * These tests make sure structural facts can be represented without enabling unsupported UI workflows. + */ +@SuppressWarnings("nls") +public class LedgerNativeEntryAssemblerTest +{ + /** + * Checks the Ledger-V6 scenario: rejects legacy fixed shape entry type. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testRejectsLegacyFixedShapeEntryType() + { + var exception = assertThrows(LedgerNativeEntryAssemblyException.class, + () -> LedgerNativeEntryAssembler.forClient(new Client()).forType(LedgerEntryType.BUY)); + + assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.ENTRY_TYPE_NOT_NATIVE)); + assertThat(exception.getMessage(), containsString("Use LedgerTransactionCreator for standard transaction families")); + } + + /** + * Checks the Ledger-V6 scenario: for type accepts every defined ledger native entry type. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testForTypeAcceptsEveryDefinedLedgerNativeEntryType() + { + var assembler = LedgerNativeEntryAssembler.forClient(new Client()); + + for (var definition : LedgerEntryDefinitionRegistry.getDefinitions()) + { + assertTrue(definition.getEntryType().isLedgerNativeTargeted()); + assertThat(assembler.forType(definition.getEntryType()), is(notNullValue())); + } + } + + /** + * Checks the Ledger-V6 scenario: for type rejects every legacy fixed shape entry type. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testForTypeRejectsEveryLegacyFixedShapeEntryType() + { + var assembler = LedgerNativeEntryAssembler.forClient(new Client()); + + for (var entryType : LedgerEntryType.values()) + { + if (entryType.isLegacyFixedShape()) + { + var exception = assertThrows(LedgerNativeEntryAssemblyException.class, + () -> assembler.forType(entryType)); + + assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.ENTRY_TYPE_NOT_NATIVE)); + } + } + } + + /** + * Checks the Ledger-V6 scenario: spin off is convenience for for type. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testSpinOffIsConvenienceForForType() + { + var fixture = fixture(); + + assertThat(LedgerNativeEntryAssembler.forClient(fixture.client).spinOff(), is(notNullValue())); + assertThat(LedgerNativeEntryAssembler.forClient(fixture.client).forType(LedgerEntryType.SPIN_OFF), + is(notNullValue())); + } + + /** + * Checks the Ledger-V6 scenario: definition registry schemas are readable by assembler consumers. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testDefinitionRegistrySchemasAreReadableByAssemblerConsumers() + { + for (var definition : LedgerEntryDefinitionRegistry.getDefinitions()) + { + assertThat(definition.getEntryType(), is(notNullValue())); + assertThat(definition.getPostingRules().isEmpty(), is(false)); + assertThat(definition.getEntryParameterRules().isEmpty(), is(false)); + assertThat(definition.getPostingParameterRules().isEmpty(), is(false)); + assertThat(definition.getProjectionRules().isEmpty(), is(false)); + assertThat(definition.getPostingGroupRules().isEmpty(), is(false)); + assertThat(definition.getAlternativeRequirementGroups().isEmpty(), is(false)); + assertThat(definition.getReportingClass() != LedgerReportingClass.UNDEFINED, is(true)); + assertThat(definition.getPerformanceTreatment() != LedgerPerformanceTreatment.UNDEFINED, is(true)); + assertThat(definition.getDownstreamResultsNotPersisted().isEmpty(), is(false)); + } + } + + /** + * Checks the Ledger-V6 scenario: rejects missing entry definition. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testRejectsMissingEntryDefinition() + { + var exception = assertThrows(LedgerNativeEntryAssemblyException.class, + () -> new LedgerNativeEntryAssembler(new Client(), type -> Optional.empty()) + .forType(LedgerEntryType.SPIN_OFF)); + + assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.ENTRY_DEFINITION_MISSING)); + } + + /** + * Checks the Ledger-V6 scenario: rejects posting type not in entry definition. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testRejectsPostingTypeNotInEntryDefinition() + { + var fixture = fixture(); + var invalidLeg = NativeSecurityLeg.ofType(LedgerPostingType.BOND) // + .portfolio(fixture.portfolio) // + .security(fixture.siemens) // + .shares(Values.Share.factorize(1)) // + .amount(money(1)) // + .build(); + + var exception = assertThrows(LedgerNativeEntryAssemblyException.class, + () -> baseSpinOff(fixture).securityLeg(invalidLeg).buildDetached()); + + assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.POSTING_TYPE_NOT_IN_ENTRY_DEFINITION)); + } + + /** + * Checks the Ledger-V6 scenario: rejects posting type outside each entry definition. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testRejectsPostingTypeOutsideEachEntryDefinition() + { + var fixture = fixture(); + + for (var definition : LedgerEntryDefinitionRegistry.getDefinitions()) + { + var invalidPostingType = java.util.Arrays.stream(LedgerPostingType.values()) // + .filter(postingType -> !definition.getPostingTypes().contains(postingType)) // + .findFirst().orElseThrow(); + var invalidLeg = NativeSecurityLeg.ofType(invalidPostingType) // + .portfolio(fixture.portfolio) // + .security(fixture.siemens) // + .shares(Values.Share.factorize(1)) // + .amount(money(1)) // + .build(); + + var exception = assertThrows(LedgerNativeEntryAssemblyException.class, + () -> LedgerNativeEntryAssembler.forClient(fixture.client) + .forType(definition.getEntryType()) // + .metadata(metadata()) // + .event(event(definition.getEntryType())) // + .securityLeg(invalidLeg) // + .buildDetached()); + + assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.POSTING_TYPE_NOT_IN_ENTRY_DEFINITION)); + } + } + + /** + * Checks the Ledger-V6 scenario: rejects entry parameter not allowed by entry definition. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testRejectsEntryParameterNotAllowedByEntryDefinition() + { + var fixture = fixture(); + var event = NativeCorporateActionEvent.builder() // + .kind(CorporateActionKind.SPIN_OFF) // + .parameter(LedgerParameterType.SOURCE_ACCOUNT, fixture.account) // + .build(); + + var exception = assertThrows(LedgerNativeEntryAssemblyException.class, + () -> baseSpinOff(fixture).event(event).buildDetached()); + + assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.PARAMETER_NOT_IN_ENTRY_DEFINITION)); + } + + /** + * Checks the Ledger-V6 scenario: rejects posting parameter not meaningful for posting type definition. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testRejectsPostingParameterNotMeaningfulForPostingTypeDefinition() + { + var fixture = fixture(); + var sourceLeg = sourceLeg(fixture).parameter(LedgerParameterType.CASH_ACCOUNT, fixture.account).build(); + + var exception = assertThrows(LedgerNativeEntryAssemblyException.class, + () -> baseSpinOff(fixture).securityLeg(sourceLeg).buildDetached()); + + assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.PARAMETER_NOT_IN_POSTING_TYPE_DEFINITION)); + } + + /** + * Checks the Ledger-V6 scenario: rejects projection role not allowed by entry definition. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testRejectsProjectionRoleNotAllowedByEntryDefinition() + { + var fixture = fixture(); + var stockDividendLeg = NativeSecurityLeg.target() // + .portfolio(fixture.portfolio) // + .security(fixture.siemensEnergy) // + .shares(Values.Share.factorize(5)) // + .amount(money(50)) // + .targetSecurity(fixture.siemensEnergy) // + .ratio(Ratio.of(BigDecimal.ONE, BigDecimal.valueOf(2))) // + .projectAs(LedgerProjectionRole.OLD_SECURITY_LEG) // + .build(); + + var exception = assertThrows(LedgerNativeEntryAssemblyException.class, + () -> LedgerNativeEntryAssembler.forClient(fixture.client) // + .forType(LedgerEntryType.STOCK_DIVIDEND) // + .metadata(metadata()) // + .event(event(LedgerEntryType.STOCK_DIVIDEND)) // + .securityLeg(stockDividendLeg) // + .buildDetached()); + + assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.PROJECTION_ROLE_NOT_IN_ENTRY_DEFINITION)); + } + + /** + * Checks the Ledger-V6 scenario: rejects wrong value kind carrier. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testRejectsWrongValueKindCarrier() + { + var fixture = fixture(); + var event = NativeCorporateActionEvent.builder() // + .kind(CorporateActionKind.SPIN_OFF) // + .parameter(LedgerParameterType.EFFECTIVE_DATE, "2020-09-28") // + .build(); + + var exception = assertThrows(LedgerNativeEntryAssemblyException.class, + () -> baseSpinOff(fixture).event(event).buildDetached()); + + assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.VALUE_KIND_MISMATCH)); + } + + /** + * Checks the Ledger-V6 scenario: rejects invalid code domain value. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testRejectsInvalidCodeDomainValue() + { + var fixture = fixture(); + var event = NativeCorporateActionEvent.builder().kind("SOURCE").build(); + + var exception = assertThrows(LedgerNativeEntryAssemblyException.class, + () -> baseSpinOff(fixture).event(event).buildDetached()); + + assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.PARAMETER_CODE_NOT_ALLOWED)); + } + + /** + * Checks the Ledger-V6 scenario: builds detached minimal spin off. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testBuildsDetachedMinimalSpinOff() + { + var fixture = fixture(); + var result = validSpinOff(fixture).buildDetached(); + var entry = result.getEntry(); + + assertThat(entry.getType(), is(LedgerEntryType.SPIN_OFF)); + assertThat(entry.getPostings().size(), is(5)); + assertThat(entry.getProjectionRefs().size(), is(3)); + assertThat(fixture.client.getLedger().getEntries().size(), is(0)); + assertTrue(result.getValidationResult().isOK()); + + assertThat(parameter(entry.getParameters(), LedgerParameterType.CORPORATE_ACTION_KIND).getValue(), + is(CorporateActionKind.SPIN_OFF.getCode())); + assertThat(entry.getProjectionRefs().stream().filter(ref -> ref.getRole() == LedgerProjectionRole.OLD_SECURITY_LEG) + .count(), is(1L)); + assertThat(entry.getProjectionRefs().stream().filter(ref -> ref.getRole() == LedgerProjectionRole.NEW_SECURITY_LEG) + .count(), is(1L)); + assertThat(entry.getProjectionRefs().stream() + .filter(ref -> ref.getRole() == LedgerProjectionRole.CASH_COMPENSATION).count(), is(1L)); + } + + /** + * Checks the Ledger-V6 scenario: build detached does not mutate client. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testBuildDetachedDoesNotMutateClient() + { + var fixture = fixture(); + + validSpinOff(fixture).buildDetached(); + + assertThat(fixture.client.getLedger().getEntries().size(), is(0)); + assertThat(fixture.account.getTransactions().size(), is(0)); + assertThat(fixture.portfolio.getTransactions().size(), is(0)); + } + + /** + * Checks the Ledger-V6 scenario: builds detached minimal stock dividend through generic for type. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testBuildsDetachedMinimalStockDividendThroughGenericForType() + { + var fixture = fixture(); + var stockDividendLeg = NativeSecurityLeg.target() // + .portfolio(fixture.portfolio) // + .security(fixture.siemensEnergy) // + .shares(Values.Share.factorize(5)) // + .amount(money(50)) // + .targetSecurity(fixture.siemensEnergy) // + .ratio(Ratio.of(BigDecimal.ONE, BigDecimal.valueOf(2))) // + .projectAs(LedgerProjectionRole.DELIVERY_INBOUND) // + .build(); + + var result = LedgerNativeEntryAssembler.forClient(fixture.client) // + .forType(LedgerEntryType.STOCK_DIVIDEND) // + .metadata(metadata()) // + .event(event(LedgerEntryType.STOCK_DIVIDEND)) // + .securityLeg(stockDividendLeg) // + .buildDetached(); + + assertThat(result.getEntry().getType(), is(LedgerEntryType.STOCK_DIVIDEND)); + assertThat(result.getEntry().getProjectionRefs().get(0).getRole(), is(LedgerProjectionRole.DELIVERY_INBOUND)); + assertTrue(result.getValidationResult().isOK()); + assertThat(fixture.client.getLedger().getEntries().size(), is(0)); + } + + /** + * Checks the Ledger-V6 scenario: build and add creates spin off and materializes runtime projections. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testBuildAndAddCreatesSpinOffAndMaterializesRuntimeProjections() + { + var fixture = fixture(); + var result = validSpinOff(fixture).buildAndAdd(); + var entry = result.getEntry(); + + assertThat(fixture.client.getLedger().getEntries().size(), is(1)); + assertThat(fixture.client.getLedger().getEntries().get(0), is(entry)); + assertTrue(result.getValidationResult().isOK()); + assertTrue(LedgerStructuralValidator.validate(fixture.client.getLedger()).isOK()); + + assertThat(fixture.portfolio.getTransactions().size(), is(2)); + assertThat(fixture.account.getTransactions().size(), is(1)); + assertThat(portfolioProjection(fixture.portfolio, LedgerProjectionRole.OLD_SECURITY_LEG).getLedgerEntry(), + is(entry)); + assertThat(portfolioProjection(fixture.portfolio, LedgerProjectionRole.NEW_SECURITY_LEG).getLedgerEntry(), + is(entry)); + assertThat(accountProjection(fixture.account, LedgerProjectionRole.CASH_COMPENSATION).getLedgerEntry(), + is(entry)); + } + + /** + * Checks the Ledger-V6 scenario: build and add projection ref uuids are runtime projection uuids. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testBuildAndAddProjectionRefUUIDsAreRuntimeProjectionUUIDs() + { + var fixture = fixture(); + var entry = validSpinOff(fixture).buildAndAdd().getEntry(); + var projectionUUIDs = entry.getProjectionRefs().stream().map(ref -> ref.getUUID()).collect(Collectors.toSet()); + var runtimeUUIDs = java.util.stream.Stream.concat( + fixture.portfolio.getTransactions().stream() + .filter(LedgerBackedTransaction.class::isInstance) + .map(PortfolioTransaction::getUUID), + fixture.account.getTransactions().stream() + .filter(LedgerBackedTransaction.class::isInstance) + .map(AccountTransaction::getUUID)) + .collect(Collectors.toSet()); + + assertThat(runtimeUUIDs, is(projectionUUIDs)); + assertThat(runtimeUUIDs.size(), is(3)); + } + + /** + * Checks the Ledger-V6 scenario: build and add does not duplicate runtime projections. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testBuildAndAddDoesNotDuplicateRuntimeProjections() + { + var fixture = fixture(); + + validSpinOff(fixture).buildAndAdd(); + validSpinOff(fixture).buildAndAdd(); + + assertThat(fixture.client.getLedger().getEntries().size(), is(2)); + assertThat(fixture.portfolio.getTransactions().size(), is(4)); + assertThat(fixture.account.getTransactions().size(), is(2)); + assertThat(runtimeProjectionUUIDs(fixture).size(), is(6)); + } + + /** + * Checks the Ledger-V6 scenario: build and add invalid code domain leaves client unchanged. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testBuildAndAddInvalidCodeDomainLeavesClientUnchanged() + { + var fixture = fixture(); + var event = NativeCorporateActionEvent.builder().kind("SOURCE").build(); + + var exception = assertThrows(LedgerNativeEntryAssemblyException.class, + () -> baseSpinOff(fixture).event(event).buildAndAdd()); + + assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.PARAMETER_CODE_NOT_ALLOWED)); + assertClientUnchanged(fixture); + } + + /** + * Checks the Ledger-V6 scenario: build and add invalid posting type leaves client unchanged. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testBuildAndAddInvalidPostingTypeLeavesClientUnchanged() + { + var fixture = fixture(); + var invalidLeg = NativeSecurityLeg.ofType(LedgerPostingType.BOND) // + .portfolio(fixture.portfolio) // + .security(fixture.siemens) // + .shares(Values.Share.factorize(1)) // + .amount(money(1)) // + .build(); + + var exception = assertThrows(LedgerNativeEntryAssemblyException.class, + () -> baseSpinOff(fixture).securityLeg(invalidLeg).buildAndAdd()); + + assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.POSTING_TYPE_NOT_IN_ENTRY_DEFINITION)); + assertClientUnchanged(fixture); + } + + /** + * Checks the Ledger-V6 scenario: build and add structural validation failure leaves client unchanged. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testBuildAndAddStructuralValidationFailureLeavesClientUnchanged() + { + var fixture = fixture(); + var invalidSourceLeg = sourceLeg(fixture).portfolio(null).build(); + + var exception = assertThrows(LedgerNativeEntryAssemblyException.class, + () -> baseSpinOff(fixture).securityLeg(invalidSourceLeg).buildAndAdd()); + + assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.STRUCTURAL_VALIDATION_FAILED)); + assertClientUnchanged(fixture); + } + + /** + * Checks the Ledger-V6 scenario: generated detached entry passes structural validator when added to scratch ledger. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testGeneratedDetachedEntryPassesStructuralValidatorWhenAddedToScratchLedger() + { + var fixture = fixture(); + var result = validSpinOff(fixture).buildDetached(); + var ledger = new Ledger(); + + ledger.addEntry(result.getEntry()); + + assertTrue(LedgerStructuralValidator.validate(ledger).isOK()); + } + + /** + * Checks the Ledger-V6 scenario: generated projection refs target assembler owned postings. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testGeneratedProjectionRefsTargetAssemblerOwnedPostings() + { + var fixture = fixture(); + var entry = validSpinOff(fixture).buildDetached().getEntry(); + var postingUUIDs = entry.getPostings().stream().map(LedgerPosting::getUUID).collect(Collectors.toSet()); + + for (var ref : entry.getProjectionRefs()) + { + assertTrue(postingUUIDs.contains(ref.getPrimaryPostingUUID())); + assertThat(ref.getPrimaryMembership().orElseThrow().getPostingUUID(), is(ref.getPrimaryPostingUUID())); + + if (ref.getPostingGroupUUID() != null) + { + assertTrue(postingUUIDs.contains(ref.getPostingGroupUUID())); + assertThat(ref.getMembershipsByRole(ProjectionMembershipRole.GROUP_ANCHOR).get(0).getPostingUUID(), + is(ref.getPostingGroupUUID())); + } + } + } + + private static LedgerNativeEntryAssembler.EntryBuilder validSpinOff(Fixture fixture) + { + return baseSpinOff(fixture) // + .securityLeg(sourceLeg(fixture).build()) // + .securityLeg(targetLeg(fixture).build()) // + .cashCompensation(NativeCashCompensation.builder() // + .account(fixture.account) // + .amount(money(5)) // + .kind(CashCompensationKind.CASH_IN_LIEU) // + .applied(true) // + .fractionQuantity(new BigDecimal("0.5")) // + .fractionTreatment(FractionTreatment.CASH_IN_LIEU) // + .roundingMode(RoundingModeCode.FLOOR) // + .build()) // + .fee(NativeFee.of(fixture.account, money(2), + FeeReason.CORPORATE_ACTION_FEE)) // + .tax(NativeTax.builder() // + .account(fixture.account) // + .amount(money(1)) // + .reason(TaxReason.WITHHOLDING_TAX) // + .taxableDistribution(true) // + .withholdingTax(true) // + .transactionTax(false) // + .reclaimableTax(false) // + .build()); + } + + private static LedgerNativeEntryAssembler.EntryBuilder baseSpinOff(Fixture fixture) + { + return LedgerNativeEntryAssembler.forClient(fixture.client).spinOff() // + .metadata(metadata()) // + .event(event(LedgerEntryType.SPIN_OFF)); + } + + private static NativeEntryMetadata metadata() + { + return NativeEntryMetadata.of(LocalDateTime.of(2020, 9, 28, 0, 0)) // + .note("Native corporate action") // + .source("native-entry-assembler-test"); + } + + private static NativeCorporateActionEvent event(LedgerEntryType entryType) + { + return NativeCorporateActionEvent.builder() // + .kind(corporateActionKind(entryType)) // + .subtype(CorporateActionSubtype.STANDARD) // + .reference(entryType.name() + "-2020") // + .stage(EventStage.SETTLED) // + .effectiveDate(LocalDate.of(2020, 9, 28)) // + .build(); + } + + private static CorporateActionKind corporateActionKind(LedgerEntryType entryType) + { + for (var kind : CorporateActionKind.values()) + if (kind.getRelatedEntryType().filter(entryType::equals).isPresent()) + return kind; + + throw new IllegalArgumentException("No corporate action kind for " + entryType); + } + + private static NativeSecurityLeg.Builder sourceLeg(Fixture fixture) + { + return NativeSecurityLeg.source() // + .portfolio(fixture.portfolio) // + .security(fixture.siemens) // + .shares(Values.Share.factorize(10)) // + .amount(money(100)) // + .sourceSecurity(fixture.siemens) // + .targetSecurity(fixture.siemensEnergy) // + .ratio(Ratio.of(BigDecimal.ONE, BigDecimal.valueOf(2))); + } + + private static NativeSecurityLeg.Builder targetLeg(Fixture fixture) + { + return NativeSecurityLeg.target() // + .portfolio(fixture.portfolio) // + .security(fixture.siemensEnergy) // + .shares(Values.Share.factorize(5)) // + .amount(money(50)) // + .sourceSecurity(fixture.siemens) // + .targetSecurity(fixture.siemensEnergy) // + .ratio(Ratio.of(BigDecimal.ONE, BigDecimal.valueOf(2))); + } + + private static LedgerParameter parameter(Collection> parameters, LedgerParameterType type) + { + return parameters.stream().filter(parameter -> parameter.getType() == type).findFirst().orElseThrow(); + } + + private static LedgerBackedTransaction portfolioProjection(Portfolio portfolio, LedgerProjectionRole role) + { + return portfolio.getTransactions().stream() // + .filter(LedgerBackedTransaction.class::isInstance) // + .map(LedgerBackedTransaction.class::cast) // + .filter(transaction -> transaction.getLedgerProjectionRef().getRole() == role) // + .findFirst().orElseThrow(); + } + + private static LedgerBackedTransaction accountProjection(Account account, LedgerProjectionRole role) + { + return account.getTransactions().stream() // + .filter(LedgerBackedTransaction.class::isInstance) // + .map(LedgerBackedTransaction.class::cast) // + .filter(transaction -> transaction.getLedgerProjectionRef().getRole() == role) // + .findFirst().orElseThrow(); + } + + private static java.util.Set runtimeProjectionUUIDs(Fixture fixture) + { + return java.util.stream.Stream.concat( + fixture.portfolio.getTransactions().stream() + .filter(LedgerBackedTransaction.class::isInstance) + .map(PortfolioTransaction::getUUID), + fixture.account.getTransactions().stream() + .filter(LedgerBackedTransaction.class::isInstance) + .map(AccountTransaction::getUUID)) + .collect(Collectors.toSet()); + } + + private static void assertClientUnchanged(Fixture fixture) + { + assertThat(fixture.client.getLedger().getEntries().size(), is(0)); + assertThat(fixture.account.getTransactions().size(), is(0)); + assertThat(fixture.portfolio.getTransactions().size(), is(0)); + } + + private static Money money(long amount) + { + return Money.of(CurrencyUnit.EUR, Values.Amount.factorize(amount)); + } + + private static Fixture fixture() + { + var client = new Client(); + var account = new Account(); + var portfolio = new Portfolio(); + var siemens = new Security("Siemens AG", CurrencyUnit.EUR); + var siemensEnergy = new Security("Siemens Energy AG", CurrencyUnit.EUR); + + account.setName("Cash"); + account.setCurrencyCode(CurrencyUnit.EUR); + portfolio.setName("Portfolio"); + siemens.setIsin("DE0007236101"); + siemensEnergy.setIsin("DE000ENER6Y0"); + + client.addAccount(account); + client.addPortfolio(portfolio); + client.addSecurity(siemens); + client.addSecurity(siemensEnergy); + + return new Fixture(client, account, portfolio, siemens, siemensEnergy); + } + + private static final class Fixture + { + private final Client client; + private final Account account; + private final Portfolio portfolio; + private final Security siemens; + private final Security siemensEnergy; + + private Fixture(Client client, Account account, Portfolio portfolio, Security siemens, Security siemensEnergy) + { + this.client = client; + this.account = account; + this.portfolio = portfolio; + this.siemens = siemens; + this.siemensEnergy = siemensEnergy; + } + } +} 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..f56f386173 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerStructuralValidator.java @@ -0,0 +1,891 @@ +package name.abuchen.portfolio.model.ledger; + +import java.text.MessageFormat; +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumMap; +import java.util.EnumSet; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +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.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; + +/** + * Validates the structural consistency of Ledger entries. + * This class checks Ledger facts and projection references. It does not apply business + * repairs or guess missing transaction data. + */ +public final class LedgerStructuralValidator +{ + public enum IssueCode + { + LEDGER_REQUIRED, + DUPLICATE_ENTRY_UUID, + DUPLICATE_POSTING_UUID, + DUPLICATE_PROJECTION_REF_UUID, + ENTRY_UUID_REQUIRED, + ENTRY_TYPE_REQUIRED, + ENTRY_DATE_TIME_REQUIRED, + POSTING_UUID_REQUIRED, + POSTING_TYPE_REQUIRED, + POSTING_CURRENCY_REQUIRED, + POSTING_SECURITY_REQUIRED, + POSTING_EXCHANGE_RATE_POSITIVE, + DIVIDEND_SECURITY_REQUIRED, + PROJECTION_REF_UUID_REQUIRED, + PROJECTION_REF_ROLE_REQUIRED, + FIXED_SHAPE_PROJECTION_ROLE_REQUIRED, + FIXED_SHAPE_PROJECTION_ROLE_NOT_ALLOWED, + PROJECTION_REF_ACCOUNT_REQUIRED, + PROJECTION_REF_PORTFOLIO_REQUIRED, + PROJECTION_REF_ACCOUNT_NOT_ALLOWED, + PROJECTION_REF_PORTFOLIO_NOT_ALLOWED, + TARGETING_REF_REQUIRED, + PRIMARY_POSTING_REF_NOT_FOUND, + POSTING_GROUP_REF_NOT_FOUND, + PROJECTION_MEMBERSHIP_REF_NOT_FOUND, + PROJECTION_PRIMARY_TARGET_CONFLICT, + PROJECTION_GROUP_TARGET_CONFLICT, + 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 + } + + 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) + { + var entryUUIDCounts = new LinkedHashMap(); + var postingUUIDCounts = new LinkedHashMap(); + var projectionRefUUIDCounts = new LinkedHashMap(); + + for (var entry : ledger.getEntries()) + { + if (isBlank(entry.getUUID())) + issues.add(entryIssue(IssueCode.ENTRY_UUID_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_005 + .message(Messages.LedgerStructuralValidatorEntryUuidRequired), + entry)); + else + { + var occurrenceCount = entryUUIDCounts.merge(entry.getUUID(), 1, Integer::sum); + if (occurrenceCount > 1) + issues.add(entryIssue(IssueCode.DUPLICATE_ENTRY_UUID, + LedgerDiagnosticCode.LEDGER_STRUCT_006 + .message(MessageFormat.format( + Messages.LedgerStructuralValidatorDuplicateEntryUuid, + entry.getUUID())), + entry) + .withDetail("objectType", "LedgerEntry") //$NON-NLS-1$ //$NON-NLS-2$ + .withDetail("duplicateUUID", entry.getUUID()) //$NON-NLS-1$ + .withDetail("occurrenceCount", occurrenceCount)); //$NON-NLS-1$ + } + + 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); + + var entryPostingUUIDs = validatePostings(entry, postingUUIDCounts, issues); + validateProjectionRefs(entry, entryPostingUUIDs, projectionRefUUIDCounts, issues); + } + } + + private static Set validatePostings(LedgerEntry entry, Map ledgerPostingUUIDCounts, + List issues) + { + var entryPostingUUIDs = new HashSet(); + + for (var posting : entry.getPostings()) + { + if (isBlank(posting.getUUID())) + issues.add(postingIssue(IssueCode.POSTING_UUID_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_009.message(MessageFormat.format( + Messages.LedgerStructuralValidatorPostingUuidRequired, + entry.getUUID())), + entry, posting)); + else + { + entryPostingUUIDs.add(posting.getUUID()); + + var occurrenceCount = ledgerPostingUUIDCounts.merge(posting.getUUID(), 1, Integer::sum); + if (occurrenceCount > 1) + issues.add(postingIssue(IssueCode.DUPLICATE_POSTING_UUID, + LedgerDiagnosticCode.LEDGER_STRUCT_010 + .message(MessageFormat.format( + Messages.LedgerStructuralValidatorDuplicatePostingUuid, + posting.getUUID())), + entry, posting) + .withDetail("objectType", "LedgerPosting") //$NON-NLS-1$ //$NON-NLS-2$ + .withDetail("duplicateUUID", posting.getUUID()) //$NON-NLS-1$ + .withDetail("occurrenceCount", occurrenceCount)); //$NON-NLS-1$ + } + + 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); + + return entryPostingUUIDs; + } + + 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 validateProjectionRefs(LedgerEntry entry, Set entryPostingUUIDs, + Map ledgerProjectionRefUUIDCounts, List issues) + { + for (var projectionRef : entry.getProjectionRefs()) + { + if (isBlank(projectionRef.getUUID())) + issues.add(projectionIssue(IssueCode.PROJECTION_REF_UUID_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_016.message(MessageFormat.format( + Messages.LedgerStructuralValidatorProjectionUuidRequired, + entry.getUUID())), + entry, + projectionRef)); + else + { + var occurrenceCount = ledgerProjectionRefUUIDCounts.merge(projectionRef.getUUID(), 1, Integer::sum); + if (occurrenceCount > 1) + issues.add(projectionIssue(IssueCode.DUPLICATE_PROJECTION_REF_UUID, + LedgerDiagnosticCode.LEDGER_STRUCT_017.message(MessageFormat.format( + Messages.LedgerStructuralValidatorDuplicateProjectionUuid, + projectionRef.getUUID())), + entry, + projectionRef).withDetail("objectType", "LedgerProjectionRef") //$NON-NLS-1$ //$NON-NLS-2$ + .withDetail("duplicateUUID", projectionRef.getUUID()) //$NON-NLS-1$ + .withDetail("occurrenceCount", occurrenceCount)); //$NON-NLS-1$ + } + + if (projectionRef.getRole() == null) + { + issues.add(projectionIssue(IssueCode.PROJECTION_REF_ROLE_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_018.message(MessageFormat.format( + Messages.LedgerStructuralValidatorProjectionRoleRequired, + projectionRef.getUUID())), + entry, + projectionRef)); + continue; + } + + validateProjectionOwner(entry, projectionRef, issues); + validateProjectionMemberships(entry, projectionRef, entryPostingUUIDs, issues); + validatePrimaryPostingRef(entry, projectionRef, entryPostingUUIDs, issues); + validateTargeting(entry, projectionRef, entryPostingUUIDs, issues); + } + + validateFixedShapeProjectionRoles(entry, issues); + } + + private static void validateFixedShapeProjectionRoles(LedgerEntry entry, List issues) + { + if (entry.getType() == null || !entry.getType().isLegacyFixedShape()) + return; + + var expectedRoles = expectedProjectionRoles(entry.getType()); + var roleCounts = new EnumMap(LedgerProjectionRole.class); + + for (var projectionRef : entry.getProjectionRefs()) + { + var role = projectionRef.getRole(); + + if (role == null) + continue; + + roleCounts.merge(role, 1, Integer::sum); + if (!expectedRoles.contains(role)) + issues.add(projectionIssue(IssueCode.FIXED_SHAPE_PROJECTION_ROLE_NOT_ALLOWED, + LedgerDiagnosticCode.LEDGER_STRUCT_019.message(MessageFormat.format( + Messages.LedgerStructuralValidatorProjectionRoleNotAllowed, + role, entry.getType())), + entry, + projectionRef)); + } + + for (var expectedRole : expectedRoles) + { + if (roleCounts.getOrDefault(expectedRole, 0) != 1) + issues.add(entryIssue(IssueCode.FIXED_SHAPE_PROJECTION_ROLE_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_020.message(MessageFormat.format( + Messages.LedgerStructuralValidatorProjectionRoleRequiredForType, + entry.getType(), expectedRole)), + entry).withDetail("expectedProjectionRole", expectedRole)); //$NON-NLS-1$ + } + } + + private static Set expectedProjectionRoles(LedgerEntryType entryType) + { + return switch (entryType) + { + case DEPOSIT, REMOVAL, INTEREST, INTEREST_CHARGE, FEES, FEES_REFUND, TAXES, TAX_REFUND, DIVIDENDS -> + EnumSet.of(LedgerProjectionRole.ACCOUNT); + case BUY, SELL -> EnumSet.of(LedgerProjectionRole.ACCOUNT, LedgerProjectionRole.PORTFOLIO); + case CASH_TRANSFER -> EnumSet.of(LedgerProjectionRole.SOURCE_ACCOUNT, LedgerProjectionRole.TARGET_ACCOUNT); + case SECURITY_TRANSFER -> + EnumSet.of(LedgerProjectionRole.SOURCE_PORTFOLIO, LedgerProjectionRole.TARGET_PORTFOLIO); + case DELIVERY_INBOUND -> EnumSet.of(LedgerProjectionRole.DELIVERY_INBOUND); + case DELIVERY_OUTBOUND -> EnumSet.of(LedgerProjectionRole.DELIVERY_OUTBOUND); + default -> EnumSet.noneOf(LedgerProjectionRole.class); + }; + } + + private static void validateProjectionOwner(LedgerEntry entry, LedgerProjectionRef projectionRef, + List issues) + { + if (requiresAccount(projectionRef.getRole())) + { + if (projectionRef.getAccount() == null) + issues.add(projectionIssue(IssueCode.PROJECTION_REF_ACCOUNT_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_021 + .message(MessageFormat.format( + Messages.LedgerStructuralValidatorProjectionAccountRequired, + projectionRef.getRole())), + entry, + projectionRef)); + + if (projectionRef.getPortfolio() != null) + issues.add(projectionIssue(IssueCode.PROJECTION_REF_PORTFOLIO_NOT_ALLOWED, + LedgerDiagnosticCode.LEDGER_STRUCT_022.message(MessageFormat.format( + Messages.LedgerStructuralValidatorAccountProjectionPortfolioNotAllowed, + projectionRef.getRole())), + entry, projectionRef)); + } + + if (requiresPortfolio(projectionRef.getRole())) + { + if (projectionRef.getPortfolio() == null) + issues.add(projectionIssue(IssueCode.PROJECTION_REF_PORTFOLIO_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_023 + .message(MessageFormat.format( + Messages.LedgerStructuralValidatorProjectionPortfolioRequired, + projectionRef.getRole())), + entry, + projectionRef)); + + if (projectionRef.getAccount() != null) + issues.add(projectionIssue(IssueCode.PROJECTION_REF_ACCOUNT_NOT_ALLOWED, + LedgerDiagnosticCode.LEDGER_STRUCT_024.message(MessageFormat.format( + Messages.LedgerStructuralValidatorPortfolioProjectionAccountNotAllowed, + projectionRef.getRole())), + entry, projectionRef)); + } + } + + private static void validatePrimaryPostingRef(LedgerEntry entry, LedgerProjectionRef projectionRef, + Set entryPostingUUIDs, List issues) + { + if (!isBlank(projectionRef.getPrimaryPostingUUID()) + && !entryPostingUUIDs.contains(projectionRef.getPrimaryPostingUUID())) + issues.add(projectionIssue(IssueCode.PRIMARY_POSTING_REF_NOT_FOUND, + LedgerDiagnosticCode.LEDGER_STRUCT_002 + .message(MessageFormat.format( + Messages.LedgerStructuralValidatorPrimaryPostingRefNotFound, + projectionRef.getPrimaryPostingUUID())), + entry, projectionRef)); + } + + private static void validateProjectionMemberships(LedgerEntry entry, LedgerProjectionRef projectionRef, + Set entryPostingUUIDs, List issues) + { + for (var membership : projectionRef.getMemberships()) + { + var postingUUID = membership.getPostingUUID(); + + if (!entryPostingUUIDs.contains(postingUUID)) + issues.add(projectionIssue(IssueCode.PROJECTION_MEMBERSHIP_REF_NOT_FOUND, + LedgerDiagnosticCode.LEDGER_STRUCT_003 + .message(MessageFormat.format( + Messages.LedgerStructuralValidatorProjectionMembershipRefNotFound, + postingUUID)), + entry, projectionRef).withDetail("membershipRole", membership.getRole()) //$NON-NLS-1$ + .withDetail("membershipPostingUUID", postingUUID)); //$NON-NLS-1$ + } + + projectionRef.getPrimaryMembership().ifPresent(membership -> { + var primaryPostingUUID = projectionRef.getPrimaryPostingUUID(); + + if (!isBlank(primaryPostingUUID) && !primaryPostingUUID.equals(membership.getPostingUUID())) + issues.add(projectionIssue(IssueCode.PROJECTION_PRIMARY_TARGET_CONFLICT, + LedgerDiagnosticCode.LEDGER_STRUCT_025.message( + Messages.LedgerStructuralValidatorProjectionPrimaryTargetConflict), + entry, projectionRef).withDetail("membershipRole", membership.getRole()) //$NON-NLS-1$ + .withDetail("membershipPostingUUID", membership.getPostingUUID())); //$NON-NLS-1$ + }); + + projectionRef.getMembershipsByRole(ProjectionMembershipRole.GROUP_ANCHOR).stream().findFirst() + .ifPresent(membership -> { + var postingGroupUUID = projectionRef.getPostingGroupUUID(); + + if (!isBlank(postingGroupUUID) && !postingGroupUUID.equals(membership.getPostingUUID())) + issues.add(projectionIssue(IssueCode.PROJECTION_GROUP_TARGET_CONFLICT, + LedgerDiagnosticCode.LEDGER_STRUCT_026.message( + Messages.LedgerStructuralValidatorProjectionGroupTargetConflict), + entry, projectionRef).withDetail("membershipRole", membership.getRole()) //$NON-NLS-1$ + .withDetail("membershipPostingUUID", //$NON-NLS-1$ + membership.getPostingUUID())); + }); + } + + private static boolean requiresAccount(LedgerProjectionRole role) + { + return switch (role) + { + case ACCOUNT, SOURCE_ACCOUNT, TARGET_ACCOUNT, CASH_COMPENSATION -> true; + default -> false; + }; + } + + private static boolean requiresPortfolio(LedgerProjectionRole role) + { + return switch (role) + { + case PORTFOLIO, SOURCE_PORTFOLIO, TARGET_PORTFOLIO, DELIVERY, DELIVERY_INBOUND, DELIVERY_OUTBOUND, + OLD_SECURITY_LEG, NEW_SECURITY_LEG -> true; + default -> false; + }; + } + + private static void validateTargeting(LedgerEntry entry, LedgerProjectionRef projectionRef, + Set entryPostingUUIDs, List issues) + { + if (entry.getType() == null || !entry.getType().requiresTargetedProjectionRefs()) + return; + + if (projectionRef.getPrimaryMembership().isEmpty() && isBlank(projectionRef.getPrimaryPostingUUID())) + { + issues.add(projectionIssue(IssueCode.TARGETING_REF_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_027 + .message(Messages.LedgerStructuralValidatorTargetingRefRequired), + entry, projectionRef)); + return; + } + + if (!isBlank(projectionRef.getPostingGroupUUID()) + && !entryPostingUUIDs.contains(projectionRef.getPostingGroupUUID())) + issues.add(projectionIssue(IssueCode.POSTING_GROUP_REF_NOT_FOUND, + LedgerDiagnosticCode.LEDGER_STRUCT_004 + .message(MessageFormat.format( + Messages.LedgerStructuralValidatorPostingGroupRefNotFound, + projectionRef.getPostingGroupUUID())), + entry, projectionRef)); + } + + 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 projectionIssue(IssueCode code, String message, LedgerEntry entry, + LedgerProjectionRef projectionRef) + { + return entryIssue(code, message, entry).withProjection(projectionRef); + } + + 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, "Projection", //$NON-NLS-1$ + detail("UUID", "projectionUUID"), //$NON-NLS-1$ //$NON-NLS-2$ + detail("Role", "projectionRole"), //$NON-NLS-1$ //$NON-NLS-2$ + detail("ExpectedRole", "expectedProjectionRole"), //$NON-NLS-1$ //$NON-NLS-2$ + detail("Account", "projectionAccount"), //$NON-NLS-1$ //$NON-NLS-2$ + detail("Portfolio", "projectionPortfolio"), //$NON-NLS-1$ //$NON-NLS-2$ + detail("PrimaryPostingUUID", "primaryPostingUUID"), //$NON-NLS-1$ //$NON-NLS-2$ + detail("PostingGroupUUID", "postingGroupUUID"), //$NON-NLS-1$ //$NON-NLS-2$ + detail("MembershipRole", "membershipRole"), //$NON-NLS-1$ //$NON-NLS-2$ + detail("MembershipPostingUUID", "membershipPostingUUID")); //$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 withProjection(LedgerProjectionRef projectionRef) + { + if (projectionRef == null) + return this; + + return withDetail("projectionUUID", projectionRef.getUUID()) //$NON-NLS-1$ + .withDetail("projectionRole", projectionRef.getRole()) //$NON-NLS-1$ + .withDetail("projectionAccount", ownerSummary(projectionRef.getAccount())) //$NON-NLS-1$ + .withDetail("projectionPortfolio", ownerSummary(projectionRef.getPortfolio())) //$NON-NLS-1$ + .withDetail("primaryPostingUUID", projectionRef.getPrimaryPostingUUID()) //$NON-NLS-1$ + .withDetail("postingGroupUUID", projectionRef.getPostingGroupUUID()); //$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) + { + } +} 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..abc5bca0ff --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/CorporateActionKind.java @@ -0,0 +1,55 @@ +package name.abuchen.portfolio.model.ledger.configuration; + +import java.util.Optional; + +/** + * 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 +{ + SPIN_OFF("SPIN_OFF", LedgerEntryType.SPIN_OFF), + STOCK_DIVIDEND("STOCK_DIVIDEND", LedgerEntryType.STOCK_DIVIDEND), + BONUS_ISSUE("BONUS_ISSUE", LedgerEntryType.BONUS_ISSUE), + RIGHTS_DISTRIBUTION("RIGHTS_DISTRIBUTION", LedgerEntryType.RIGHTS_DISTRIBUTION), + BOND_CONVERSION("BOND_CONVERSION", LedgerEntryType.BOND_CONVERSION), + OTHER("OTHER"); + + private final String code; + private final LedgerEntryType relatedEntryType; + + private CorporateActionKind(String code) + { + this(code, null); + } + + private CorporateActionKind(String code, LedgerEntryType relatedEntryType) + { + this.code = code; + this.relatedEntryType = relatedEntryType; + } + + @Override + public LedgerParameterCodeDomain getDomain() + { + return LedgerParameterCodeDomain.CORPORATE_ACTION_KIND; + } + + @Override + public String getCode() + { + return code; + } + + public Optional getRelatedEntryType() + { + return Optional.ofNullable(relatedEntryType); + } +} 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..426ae69a3d --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerEntryDefinitionRegistry.java @@ -0,0 +1,772 @@ +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.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.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 projection refs. + *

+ */ +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 LedgerEntryDefinitionRegistry() + { + } + + public static Optional lookup(LedgerEntryType entryType) + { + return Optional.ofNullable(DEFINITIONS.get(entryType)); + } + + public static Collection getDefinitions() + { + return DEFINITIONS.values(); + } + + public static boolean hasDefinition(LedgerEntryType entryType) + { + return DEFINITIONS.containsKey(entryType); + } + + private static Map definitions() + { + var definitions = new EnumMap(LedgerEntryType.class); + + register(definitions, spinOff()); + register(definitions, stockDividend()); + register(definitions, bonusIssue()); + register(definitions, rightsDistribution()); + register(definitions, bondConversion()); + + 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 LedgerEntryDefinition spinOff() + { + return LedgerEntryDefinition.of(LedgerEntryType.SPIN_OFF, LedgerNativeEntryShape.DUAL_INSTRUMENT_PLUS_ACCOUNT, + SETS.postingRules( + requiredPosting(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( + requiredProjection(LedgerProjectionRole.OLD_SECURITY_LEG, true, false), + requiredProjection(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 LedgerEntryDefinition.of(LedgerEntryType.STOCK_DIVIDEND, LedgerNativeEntryShape.SINGLE_INSTRUMENT, + SETS.postingRules( + requiredPosting(LedgerPostingType.SECURITY, + SETS.parameterTypes(LedgerParameterType.CORPORATE_ACTION_LEG, + LedgerParameterType.TARGET_SECURITY, + LedgerParameterType.RATIO_NUMERATOR, + LedgerParameterType.RATIO_DENOMINATOR), + stockDividendSecurityOptionalParameters()), + optionalPosting(LedgerPostingType.CASH_COMPENSATION, SETS.parameterTypes(), + cashCompensationOptionalParameters()), + optionalPosting(LedgerPostingType.FEE, SETS.parameterTypes(), + feeOptionalParameters()), + optionalPosting(LedgerPostingType.TAX, SETS.parameterTypes(), + taxOptionalParameters())), + 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.SOURCE_SECURITY), + 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.TARGET_SECURITY), + repeatableRequiredPostingParameter(LedgerParameterType.RATIO_NUMERATOR), + repeatableRequiredPostingParameter(LedgerParameterType.RATIO_DENOMINATOR), + repeatableOptionalPostingParameter(LedgerParameterType.FRACTION_QUANTITY), + repeatableOptionalPostingParameter(LedgerParameterType.FRACTION_TREATMENT), + repeatableOptionalPostingParameter(LedgerParameterType.CASH_IN_LIEU_AMOUNT), + repeatableOptionalPostingParameter(LedgerParameterType.CASH_IN_LIEU_APPLIED), + repeatableOptionalPostingParameter(LedgerParameterType.COST_ALLOCATION_METHOD), + repeatableOptionalPostingParameter(LedgerParameterType.REFERENCE_PRICE), + repeatableOptionalPostingParameter(LedgerParameterType.FAIR_MARKET_VALUE), + repeatableOptionalPostingParameter(LedgerParameterType.VALUATION_PRICE), + repeatableOptionalPostingParameter(LedgerParameterType.TAXABLE_DISTRIBUTION), + repeatableOptionalPostingParameter(LedgerParameterType.FEE_REASON), + repeatableOptionalPostingParameter(LedgerParameterType.TAX_REASON)), + SETS.projectionRules( + requiredProjection(LedgerProjectionRole.DELIVERY_INBOUND, true, false), + optionalProjection(LedgerProjectionRole.CASH_COMPENSATION, true, true)), + cashCompensationPostingGroupRules(), + SETS.alternativeGroups(dateAlternative("STOCK_DIVIDEND_DATE")), //$NON-NLS-1$ + stockDividendLegDefinitions(), + LedgerReportingClass.SECURITIES_DISTRIBUTION, + LedgerPerformanceTreatment.SECURITY_DISTRIBUTION, downstreamResults()); + } + + private static LedgerEntryDefinition bonusIssue() + { + return LedgerEntryDefinition.of(LedgerEntryType.BONUS_ISSUE, LedgerNativeEntryShape.SINGLE_INSTRUMENT, + SETS.postingRules( + requiredPosting(LedgerPostingType.SECURITY, + SETS.parameterTypes(LedgerParameterType.CORPORATE_ACTION_LEG, + LedgerParameterType.TARGET_SECURITY, + LedgerParameterType.RATIO_NUMERATOR, + LedgerParameterType.RATIO_DENOMINATOR), + bonusIssueSecurityOptionalParameters()), + optionalPosting(LedgerPostingType.CASH_COMPENSATION, SETS.parameterTypes(), + cashCompensationOptionalParameters()), + optionalPosting(LedgerPostingType.FEE, SETS.parameterTypes(), + feeOptionalParameters()), + optionalPosting(LedgerPostingType.TAX, SETS.parameterTypes(), + taxOptionalParameters())), + 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.SOURCE_SECURITY), + 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.TARGET_SECURITY), + repeatableRequiredPostingParameter(LedgerParameterType.RATIO_NUMERATOR), + repeatableRequiredPostingParameter(LedgerParameterType.RATIO_DENOMINATOR), + repeatableOptionalPostingParameter(LedgerParameterType.SAME_SECURITY_AS_SOURCE), + repeatableOptionalPostingParameter(LedgerParameterType.FRACTION_TREATMENT), + repeatableOptionalPostingParameter(LedgerParameterType.ROUNDING_MODE), + repeatableOptionalPostingParameter(LedgerParameterType.COST_ALLOCATION_METHOD), + repeatableOptionalPostingParameter(LedgerParameterType.REFERENCE_PRICE), + repeatableOptionalPostingParameter(LedgerParameterType.FAIR_MARKET_VALUE), + repeatableOptionalPostingParameter(LedgerParameterType.VALUATION_PRICE), + repeatableOptionalPostingParameter(LedgerParameterType.FEE_REASON), + repeatableOptionalPostingParameter(LedgerParameterType.TAX_REASON)), + SETS.projectionRules( + requiredProjection(LedgerProjectionRole.DELIVERY_INBOUND, true, false), + optionalProjection(LedgerProjectionRole.CASH_COMPENSATION, true, true)), + cashCompensationPostingGroupRules(), + SETS.alternativeGroups(dateAlternative("BONUS_ISSUE_DATE")), //$NON-NLS-1$ + bonusIssueLegDefinitions(), + LedgerReportingClass.SECURITIES_DISTRIBUTION, + LedgerPerformanceTreatment.PERFORMANCE_NEUTRAL, downstreamResults()); + } + + private static LedgerEntryDefinition rightsDistribution() + { + return LedgerEntryDefinition.of(LedgerEntryType.RIGHTS_DISTRIBUTION, + LedgerNativeEntryShape.DUAL_INSTRUMENT_PLUS_ACCOUNT, + SETS.postingRules( + optionalPosting(LedgerPostingType.RIGHT, + SETS.parameterTypes(LedgerParameterType.CORPORATE_ACTION_LEG, + LedgerParameterType.SOURCE_SECURITY, + LedgerParameterType.RIGHT_SECURITY, + LedgerParameterType.RATIO_NUMERATOR, + LedgerParameterType.RATIO_DENOMINATOR), + rightsOptionalParameters()), + optionalPosting(LedgerPostingType.SECURITY, + SETS.parameterTypes(LedgerParameterType.CORPORATE_ACTION_LEG), + rightsOptionalParameters()), + optionalPosting(LedgerPostingType.CASH_COMPENSATION, SETS.parameterTypes(), + cashCompensationOptionalParameters()), + optionalPosting(LedgerPostingType.FEE, SETS.parameterTypes(), + feeOptionalParameters()), + optionalPosting(LedgerPostingType.TAX, SETS.parameterTypes(), + taxOptionalParameters())), + 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), + optionalEntryParameter(LedgerParameterType.ELECTION_DEADLINE)), + SETS.parameterRules(repeatableRequiredPostingParameter(LedgerParameterType.CORPORATE_ACTION_LEG), + repeatableRequiredPostingParameter(LedgerParameterType.SOURCE_SECURITY), + repeatableRequiredPostingParameter(LedgerParameterType.RIGHT_SECURITY), + repeatableRequiredPostingParameter(LedgerParameterType.RATIO_NUMERATOR), + repeatableRequiredPostingParameter(LedgerParameterType.RATIO_DENOMINATOR), + repeatableOptionalPostingParameter(LedgerParameterType.SUBSCRIPTION_PRICE), + repeatableOptionalPostingParameter(LedgerParameterType.REFERENCE_PRICE), + repeatableOptionalPostingParameter(LedgerParameterType.FRACTION_QUANTITY), + repeatableOptionalPostingParameter(LedgerParameterType.FRACTION_TREATMENT), + repeatableOptionalPostingParameter(LedgerParameterType.ROUNDING_MODE), + repeatableOptionalPostingParameter(LedgerParameterType.FEE_REASON), + repeatableOptionalPostingParameter(LedgerParameterType.TAX_REASON)), + SETS.projectionRules( + requiredProjection(LedgerProjectionRole.NEW_SECURITY_LEG, true, false), + optionalProjection(LedgerProjectionRole.OLD_SECURITY_LEG, true, false), + optionalProjection(LedgerProjectionRole.CASH_COMPENSATION, true, true)), + cashCompensationPostingGroupRules(), + SETS.alternativeGroups(dateAlternative("RIGHTS_DISTRIBUTION_DATE"), //$NON-NLS-1$ + LedgerRequirementGroup.postingTypes("RIGHTS_DISTRIBUTED_INSTRUMENT", //$NON-NLS-1$ + LedgerRequirement.REQUIRED, + SETS.postingTypes(LedgerPostingType.RIGHT, + LedgerPostingType.SECURITY))), + rightsDistributionLegDefinitions(), + LedgerReportingClass.RIGHTS_EVENT, LedgerPerformanceTreatment.PERFORMANCE_NEUTRAL, + downstreamResults()); + } + + private static LedgerEntryDefinition bondConversion() + { + return LedgerEntryDefinition.of(LedgerEntryType.BOND_CONVERSION, + LedgerNativeEntryShape.DUAL_INSTRUMENT_PLUS_ACCOUNT, + SETS.postingRules( + requiredPosting(LedgerPostingType.BOND, + SETS.parameterTypes(LedgerParameterType.CORPORATE_ACTION_LEG, + LedgerParameterType.SOURCE_SECURITY, + LedgerParameterType.NOMINAL_VALUE, + LedgerParameterType.QUOTATION_STYLE), + bondOptionalParameters()), + requiredPosting(LedgerPostingType.SECURITY, + SETS.parameterTypes(LedgerParameterType.CORPORATE_ACTION_LEG, + LedgerParameterType.TARGET_SECURITY), + bondOptionalParameters()), + optionalPosting(LedgerPostingType.CASH, SETS.parameterTypes(), + cashOptionalParameters()), + optionalPosting(LedgerPostingType.CASH_COMPENSATION, SETS.parameterTypes(), + cashCompensationOptionalParameters()), + optionalPosting(LedgerPostingType.ACCRUED_INTEREST, SETS.parameterTypes(), + accruedInterestOptionalParameters()), + optionalPosting(LedgerPostingType.FEE, SETS.parameterTypes(), + feeOptionalParameters()), + optionalPosting(LedgerPostingType.TAX, SETS.parameterTypes(), + taxOptionalParameters())), + SETS.parameterRules(requiredEntryParameter(LedgerParameterType.CORPORATE_ACTION_KIND), + optionalEntryParameter(LedgerParameterType.EFFECTIVE_DATE), + optionalEntryParameter(LedgerParameterType.CORPORATE_ACTION_SUBTYPE), + optionalEntryParameter(LedgerParameterType.EVENT_REFERENCE), + optionalEntryParameter(LedgerParameterType.EVENT_STAGE), + optionalEntryParameter(LedgerParameterType.SETTLEMENT_DATE)), + SETS.parameterRules(repeatableRequiredPostingParameter(LedgerParameterType.CORPORATE_ACTION_LEG), + repeatableRequiredPostingParameter(LedgerParameterType.SOURCE_SECURITY), + repeatableRequiredPostingParameter(LedgerParameterType.TARGET_SECURITY), + repeatableRequiredPostingParameter(LedgerParameterType.NOMINAL_VALUE), + repeatableRequiredPostingParameter(LedgerParameterType.QUOTATION_STYLE), + repeatableOptionalPostingParameter(LedgerParameterType.CONVERSION_RATIO), + repeatableOptionalPostingParameter(LedgerParameterType.RATIO_NUMERATOR), + repeatableOptionalPostingParameter(LedgerParameterType.RATIO_DENOMINATOR), + repeatableOptionalPostingParameter(LedgerParameterType.REFERENCE_PRICE), + repeatableOptionalPostingParameter(LedgerParameterType.FAIR_MARKET_VALUE), + repeatableOptionalPostingParameter(LedgerParameterType.VALUATION_PRICE), + repeatableOptionalPostingParameter(LedgerParameterType.ACCRUED_INTEREST_AMOUNT), + repeatableOptionalPostingParameter(LedgerParameterType.INTEREST_PERIOD_START), + repeatableOptionalPostingParameter(LedgerParameterType.INTEREST_PERIOD_END), + repeatableOptionalPostingParameter(LedgerParameterType.CASH_IN_LIEU_AMOUNT), + repeatableOptionalPostingParameter(LedgerParameterType.COST_ALLOCATION_METHOD), + repeatableOptionalPostingParameter(LedgerParameterType.SOURCE_COST_PERCENT), + repeatableOptionalPostingParameter(LedgerParameterType.TARGET_COST_PERCENT), + repeatableOptionalPostingParameter(LedgerParameterType.FEE_REASON), + repeatableOptionalPostingParameter(LedgerParameterType.TAX_REASON)), + SETS.projectionRules( + requiredProjection(LedgerProjectionRole.OLD_SECURITY_LEG, true, false), + requiredProjection(LedgerProjectionRole.NEW_SECURITY_LEG, true, false), + optionalProjection(LedgerProjectionRole.CASH_COMPENSATION, true, true)), + cashCompensationPostingGroupRules(), + SETS.alternativeGroups( + LedgerRequirementGroup.parameterTypes("BOND_CONVERSION_RATIO", //$NON-NLS-1$ + LedgerRequirement.REQUIRED, + SETS.parameterTypes(LedgerParameterType.CONVERSION_RATIO, + LedgerParameterType.RATIO_NUMERATOR, + LedgerParameterType.RATIO_DENOMINATOR)), + LedgerRequirementGroup.parameterTypes("BOND_CONVERSION_DATE", //$NON-NLS-1$ + LedgerRequirement.REQUIRED, + SETS.parameterTypes(LedgerParameterType.EFFECTIVE_DATE, + LedgerParameterType.SETTLEMENT_DATE))), + bondConversionLegDefinitions(), + LedgerReportingClass.SECURITY_REORGANIZATION, + LedgerPerformanceTreatment.INTERNAL_RECLASSIFICATION, downstreamResults()); + } + + private static LedgerPostingRule requiredPosting(LedgerPostingType postingType, + EnumSet requiredParameterTypes, + EnumSet optionalParameterTypes) + { + return LedgerPostingRule.required(postingType, requiredParameterTypes, optionalParameterTypes); + } + + 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 requiredProjection(LedgerProjectionRole role, boolean primaryPostingExpected, + boolean postingGroupExpected) + { + return LedgerProjectionRule.required(role, primaryPostingExpected, postingGroupExpected); + } + + 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() + { + return SETS.legDefinitions( + LedgerLegDefinition.of(LedgerLegRole.SOURCE_SECURITY_LEG, LedgerPostingType.SECURITY, + LedgerLegCardinality.EXACTLY_ONE) + .requiredParameters(SETS.parameterTypes( + 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.EXACTLY_ONE) + .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.OPTIONAL) + .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 Set stockDividendLegDefinitions() + { + return SETS.legDefinitions( + LedgerLegDefinition.of(LedgerLegRole.RECEIVED_SECURITY_LEG, LedgerPostingType.SECURITY, + LedgerLegCardinality.EXACTLY_ONE) + .requiredParameters(SETS.parameterTypes( + LedgerParameterType.CORPORATE_ACTION_LEG, + LedgerParameterType.TARGET_SECURITY, + LedgerParameterType.RATIO_NUMERATOR, + LedgerParameterType.RATIO_DENOMINATOR)) + .optionalParameters(stockDividendSecurityOptionalParameters()) + .projection(LedgerProjectionRole.DELIVERY_INBOUND, true, false).build(), + cashCompensationLeg(), + feeLeg(), + taxLeg()); + } + + private static Set bonusIssueLegDefinitions() + { + return SETS.legDefinitions( + LedgerLegDefinition.of(LedgerLegRole.RECEIVED_SECURITY_LEG, LedgerPostingType.SECURITY, + LedgerLegCardinality.EXACTLY_ONE) + .requiredParameters(SETS.parameterTypes( + LedgerParameterType.CORPORATE_ACTION_LEG, + LedgerParameterType.TARGET_SECURITY, + LedgerParameterType.RATIO_NUMERATOR, + LedgerParameterType.RATIO_DENOMINATOR)) + .optionalParameters(bonusIssueSecurityOptionalParameters()) + .projection(LedgerProjectionRole.DELIVERY_INBOUND, true, false).build(), + cashCompensationLeg(), + feeLeg(), + taxLeg()); + } + + private static Set rightsDistributionLegDefinitions() + { + return SETS.legDefinitions( + LedgerLegDefinition.of(LedgerLegRole.DISTRIBUTED_RIGHT_LEG, LedgerPostingType.RIGHT, + LedgerLegCardinality.OPTIONAL) + .requiredParameters(SETS.parameterTypes( + LedgerParameterType.CORPORATE_ACTION_LEG, + LedgerParameterType.SOURCE_SECURITY, + LedgerParameterType.RIGHT_SECURITY, + LedgerParameterType.RATIO_NUMERATOR, + LedgerParameterType.RATIO_DENOMINATOR)) + .optionalParameters(rightsOptionalParameters()) + .projection(LedgerProjectionRole.NEW_SECURITY_LEG, true, false).build(), + LedgerLegDefinition.of(LedgerLegRole.DISTRIBUTED_SECURITY_LEG, LedgerPostingType.SECURITY, + LedgerLegCardinality.OPTIONAL) + .requiredParameters(SETS.parameterTypes( + LedgerParameterType.CORPORATE_ACTION_LEG)) + .optionalParameters(rightsOptionalParameters()) + .projection(LedgerProjectionRole.NEW_SECURITY_LEG, true, false).build(), + LedgerLegDefinition.of(LedgerLegRole.SOURCE_SECURITY_LEG, LedgerPostingType.SECURITY, + LedgerLegCardinality.OPTIONAL) + .optionalParameters(rightsOptionalParameters()) + .projection(LedgerProjectionRole.OLD_SECURITY_LEG, true, false).build(), + cashCompensationLeg(), + feeLeg(), + taxLeg()); + } + + private static Set bondConversionLegDefinitions() + { + return SETS.legDefinitions( + LedgerLegDefinition.of(LedgerLegRole.SOURCE_BOND_LEG, LedgerPostingType.BOND, + LedgerLegCardinality.EXACTLY_ONE) + .requiredParameters(SETS.parameterTypes( + LedgerParameterType.CORPORATE_ACTION_LEG, + LedgerParameterType.SOURCE_SECURITY, + LedgerParameterType.NOMINAL_VALUE, + LedgerParameterType.QUOTATION_STYLE)) + .optionalParameters(bondOptionalParameters()) + .projection(LedgerProjectionRole.OLD_SECURITY_LEG, true, false).build(), + LedgerLegDefinition.of(LedgerLegRole.TARGET_SECURITY_LEG, LedgerPostingType.SECURITY, + LedgerLegCardinality.EXACTLY_ONE) + .requiredParameters(SETS.parameterTypes( + LedgerParameterType.CORPORATE_ACTION_LEG, + LedgerParameterType.TARGET_SECURITY)) + .optionalParameters(bondOptionalParameters()) + .projection(LedgerProjectionRole.NEW_SECURITY_LEG, true, false).build(), + LedgerLegDefinition.of(LedgerLegRole.CASH_LEG, LedgerPostingType.CASH, + LedgerLegCardinality.OPTIONAL) + .optionalParameters(cashOptionalParameters()).build(), + cashCompensationLeg(), + LedgerLegDefinition.of(LedgerLegRole.ACCRUED_INTEREST_LEG, + LedgerPostingType.ACCRUED_INTEREST, LedgerLegCardinality.OPTIONAL) + .optionalParameters(accruedInterestOptionalParameters()).build(), + feeLeg(), + taxLeg()); + } + + private static LedgerLegDefinition cashCompensationLeg() + { + return LedgerLegDefinition.of(LedgerLegRole.CASH_COMPENSATION_LEG, + LedgerPostingType.CASH_COMPENSATION, LedgerLegCardinality.OPTIONAL) + .optionalParameters(cashCompensationOptionalParameters()) + .projection(LedgerProjectionRole.CASH_COMPENSATION, true, true) + .group(CASH_COMPENSATION_GROUP).build(); + } + + private static LedgerLegDefinition feeLeg() + { + return LedgerLegDefinition.of(LedgerLegRole.FEE_LEG, LedgerPostingType.FEE, LedgerLegCardinality.REPEATABLE) + .optionalParameters(feeOptionalParameters()) + .group(CASH_COMPENSATION_GROUP).build(); + } + + private static LedgerLegDefinition taxLeg() + { + return LedgerLegDefinition.of(LedgerLegRole.TAX_LEG, LedgerPostingType.TAX, LedgerLegCardinality.REPEATABLE) + .optionalParameters(taxOptionalParameters()) + .group(CASH_COMPENSATION_GROUP).build(); + } + + 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 stockDividendSecurityOptionalParameters() + { + return SETS.parameterTypes(LedgerParameterType.SOURCE_SECURITY, LedgerParameterType.FRACTION_QUANTITY, + LedgerParameterType.FRACTION_TREATMENT, LedgerParameterType.CASH_IN_LIEU_AMOUNT, + LedgerParameterType.CASH_IN_LIEU_APPLIED, LedgerParameterType.COST_ALLOCATION_METHOD, + LedgerParameterType.REFERENCE_PRICE, LedgerParameterType.FAIR_MARKET_VALUE, + LedgerParameterType.VALUATION_PRICE, LedgerParameterType.TAXABLE_DISTRIBUTION); + } + + private static EnumSet bonusIssueSecurityOptionalParameters() + { + return SETS.parameterTypes(LedgerParameterType.SOURCE_SECURITY, + LedgerParameterType.SAME_SECURITY_AS_SOURCE, LedgerParameterType.FRACTION_TREATMENT, + LedgerParameterType.ROUNDING_MODE, LedgerParameterType.COST_ALLOCATION_METHOD, + LedgerParameterType.REFERENCE_PRICE, LedgerParameterType.FAIR_MARKET_VALUE, + LedgerParameterType.VALUATION_PRICE); + } + + private static EnumSet rightsOptionalParameters() + { + return SETS.parameterTypes(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); + } + + private static EnumSet bondOptionalParameters() + { + return SETS.parameterTypes(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); + } + + private static EnumSet cashOptionalParameters() + { + return SETS.parameterTypes(LedgerParameterType.SOURCE_ACCOUNT, LedgerParameterType.TARGET_ACCOUNT, + LedgerParameterType.CASH_ACCOUNT, LedgerParameterType.EVENT_REFERENCE, + LedgerParameterType.PAYMENT_DATE, LedgerParameterType.SETTLEMENT_DATE); + } + + 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 EnumSet accruedInterestOptionalParameters() + { + return SETS.parameterTypes(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 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..99696408c1 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerEntryType.java @@ -0,0 +1,92 @@ +package name.abuchen.portfolio.model.ledger.configuration; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; + +/** + * Defines stable Ledger type codes used by persistence and validation. + * This is Ledger configuration metadata. Existing persistence ids must stay stable, and + * normal transaction-editing code should use higher-level write paths. + * + *

+ * Protobuf stores {@link #getProtobufId()} in {@code PLedgerEntry.typeId}. Existing ids must + * never be changed or reused, and new entry types must receive a new stable id. + *

+ */ +public enum LedgerEntryType +{ + DEPOSIT(1, Shape.LEGACY_FIXED), + REMOVAL(2, Shape.LEGACY_FIXED), + INTEREST(3, Shape.LEGACY_FIXED), + INTEREST_CHARGE(4, Shape.LEGACY_FIXED), + FEES(5, Shape.LEGACY_FIXED), + FEES_REFUND(6, Shape.LEGACY_FIXED), + TAXES(7, Shape.LEGACY_FIXED), + TAX_REFUND(8, Shape.LEGACY_FIXED), + DIVIDENDS(9, Shape.LEGACY_FIXED), + BUY(10, Shape.LEGACY_FIXED), + SELL(11, Shape.LEGACY_FIXED), + CASH_TRANSFER(12, Shape.LEGACY_FIXED), + SECURITY_TRANSFER(13, Shape.LEGACY_FIXED), + DELIVERY_INBOUND(14, Shape.LEGACY_FIXED), + DELIVERY_OUTBOUND(15, Shape.LEGACY_FIXED), + SPIN_OFF(16, Shape.LEDGER_NATIVE_TARGETED), + STOCK_DIVIDEND(17, Shape.LEDGER_NATIVE_TARGETED), + BONUS_ISSUE(18, Shape.LEDGER_NATIVE_TARGETED), + RIGHTS_DISTRIBUTION(19, Shape.LEDGER_NATIVE_TARGETED), + BOND_CONVERSION(20, Shape.LEDGER_NATIVE_TARGETED); + + private enum Shape + { + LEGACY_FIXED, + LEDGER_NATIVE_TARGETED + } + + // Protobuf persistence ID. + // Never change existing IDs. + // Never reuse removed IDs. + // New enum constants must receive a new unique ID. + private final int protobufId; + + private final Shape shape; + + private LedgerEntryType(int protobufId, Shape shape) + { + this.protobufId = protobufId; + this.shape = shape; + } + + public int getProtobufId() + { + return protobufId; + } + + public static LedgerEntryType fromProtobufId(int id) + { + for (LedgerEntryType type : values()) + if (type.protobufId == id) + return type; + + throw new IllegalArgumentException( + LedgerDiagnosticCode.LEDGER_CORE_017.message("Unknown LedgerEntryType protobuf ID: " + id)); //$NON-NLS-1$ + } + + public boolean isLegacyFixedShape() + { + return shape == Shape.LEGACY_FIXED; + } + + public boolean isLedgerNativeTargeted() + { + return shape == Shape.LEDGER_NATIVE_TARGETED; + } + + public boolean requiresTargetedProjectionRefs() + { + return isLedgerNativeTargeted(); + } + + 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..fe023e7945 --- /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 projection refs, 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..fee0af67e5 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerLegRole.java @@ -0,0 +1,23 @@ +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, + FEE_LEG, + TAX_LEG, + FOREX_CONTEXT_LEG +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java new file mode 100644 index 0000000000..7476dea9bc --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java @@ -0,0 +1,668 @@ +package name.abuchen.portfolio.model.ledger.configuration; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerParameter; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.configuration.rule.LedgerRequirement; +import name.abuchen.portfolio.model.ledger.configuration.rule.LedgerRequirementGroup; + +/** + * Validates native Ledger entries against Java-owned entry and leg definitions. + * This validator is separate from {@code LedgerStructuralValidator}: it checks + * native business shape metadata for supported create and edit paths, while + * structural validation remains the generic persisted-fact guard. + */ +public final class LedgerNativeEntryDefinitionValidator +{ + public enum IssueCode + { + ENTRY_REQUIRED, + ENTRY_TYPE_REQUIRED, + ENTRY_DEFINITION_MISSING, + REQUIRED_ENTRY_PARAMETER_MISSING, + ENTRY_PARAMETER_NOT_ALLOWED, + REQUIRED_ALTERNATIVE_GROUP_MISSING, + POSTING_TYPE_NOT_ALLOWED, + LEG_CARDINALITY_VIOLATED, + AMBIGUOUS_LEG_MATCH, + REQUIRED_LEG_PARAMETER_MISSING, + LEG_PARAMETER_NOT_ALLOWED, + PARAMETER_PLACEMENT_INVALID, + LEG_PARAMETER_VALUE_MISMATCH, + REQUIRED_PROJECTION_MISSING, + PROJECTION_PRIMARY_POSTING_REQUIRED, + PROJECTION_PRIMARY_POSTING_MISMATCH, + PROJECTION_POSTING_GROUP_REQUIRED, + PROJECTION_POSTING_GROUP_NOT_FOUND + } + + private LedgerNativeEntryDefinitionValidator() + { + } + + public static ValidationResult validate(LedgerEntry entry) + { + var issues = new ArrayList(); + + if (entry == null) + { + issues.add(new ValidationIssue(IssueCode.ENTRY_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_035.message("Ledger entry is required"))); //$NON-NLS-1$ + return new ValidationResult(issues); + } + + var entryType = entry.getType(); + if (entryType == null) + { + issues.add(issue(IssueCode.ENTRY_TYPE_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_036.message("Ledger entry type is required"), entry)); //$NON-NLS-1$ + return new ValidationResult(issues); + } + + if (!entryType.isLedgerNativeTargeted()) + return new ValidationResult(issues); + + var definition = LedgerEntryDefinitionRegistry.lookup(entryType); + if (definition.isEmpty()) + { + issues.add(issue(IssueCode.ENTRY_DEFINITION_MISSING, + LedgerDiagnosticCode.LEDGER_STRUCT_037 + .message("Missing native Ledger entry definition for " + entryType), //$NON-NLS-1$ + entry)); + return new ValidationResult(issues); + } + + validateEntryParameters(entry, definition.get(), issues); + validatePostingTypes(entry, definition.get(), issues); + validateAlternativeGroups(entry, definition.get(), issues); + validateLegs(entry, definition.get(), issues); + + return new ValidationResult(issues); + } + + public static void assertValid(LedgerEntry entry) + { + var result = validate(entry); + + if (!result.isOK()) + throw new ValidationException(result); + } + + private static void validateEntryParameters(LedgerEntry entry, LedgerEntryDefinition definition, + List issues) + { + var entryParameterTypes = parameterTypes(entry.getParameters()); + + for (var rule : definition.getRequiredEntryParameterRules()) + { + if (!entryParameterTypes.contains(rule.getParameterType())) + issues.add(issue(IssueCode.REQUIRED_ENTRY_PARAMETER_MISSING, + LedgerDiagnosticCode.LEDGER_STRUCT_038.message( + "Required native entry parameter is missing: " //$NON-NLS-1$ + + rule.getParameterType()), + entry) + .withDetail("parameterType", rule.getParameterType())); //$NON-NLS-1$ + } + + for (var parameter : entry.getParameters()) + { + var parameterType = parameter.getType(); + + if (parameterType != null && !definition.getEntryParameterTypes().contains(parameterType)) + issues.add(issue(IssueCode.ENTRY_PARAMETER_NOT_ALLOWED, + LedgerDiagnosticCode.LEDGER_STRUCT_039.message( + "Entry parameter is not allowed for " + definition.getEntryType() + ": " //$NON-NLS-1$ //$NON-NLS-2$ + + parameterType), + entry).withParameter(parameter)); + } + } + + private static void validatePostingTypes(LedgerEntry entry, LedgerEntryDefinition definition, + List issues) + { + for (var posting : entry.getPostings()) + { + var postingType = posting.getType(); + + if (postingType != null && !definition.getPostingTypes().contains(postingType)) + issues.add(issue(IssueCode.POSTING_TYPE_NOT_ALLOWED, + LedgerDiagnosticCode.LEDGER_STRUCT_040.message( + "Posting type is not allowed for " + definition.getEntryType() + ": " //$NON-NLS-1$ //$NON-NLS-2$ + + postingType), + entry).withPosting(posting)); + } + } + + private static void validateAlternativeGroups(LedgerEntry entry, LedgerEntryDefinition definition, + List issues) + { + for (var group : definition.getAlternativeRequirementGroups()) + { + if (group.getRequirement() == LedgerRequirement.REQUIRED && !isSatisfied(entry, group)) + issues.add(issue(IssueCode.REQUIRED_ALTERNATIVE_GROUP_MISSING, + LedgerDiagnosticCode.LEDGER_STRUCT_041.message( + "Required native alternative group is missing: " + group.getName()), //$NON-NLS-1$ + entry) + .withDetail("groupName", group.getName())); //$NON-NLS-1$ + } + } + + private static boolean isSatisfied(LedgerEntry entry, LedgerRequirementGroup group) + { + if (!group.getParameterTypes().isEmpty()) + { + for (var type : group.getParameterTypes()) + { + if (hasParameter(entry.getParameters(), type)) + return true; + + for (var posting : entry.getPostings()) + if (hasParameter(posting.getParameters(), type)) + return true; + } + } + + if (!group.getPostingTypes().isEmpty()) + return entry.getPostings().stream().map(LedgerPosting::getType) + .anyMatch(group.getPostingTypes()::contains); + + return false; + } + + private static void validateLegs(LedgerEntry entry, LedgerEntryDefinition definition, + List issues) + { + var postingsByUUID = entry.getPostings().stream() // + .collect(Collectors.toMap(LedgerPosting::getUUID, posting -> posting, (left, right) -> left, + LinkedHashMap::new)); + + for (var leg : definition.getLegDefinitions()) + { + var match = matchLeg(entry, definition, leg, postingsByUUID, issues); + + validateCardinality(entry, leg, match.postings(), issues); + validateLegParameters(entry, leg, match.postings(), issues); + } + } + + private static LegMatch matchLeg(LedgerEntry entry, LedgerEntryDefinition definition, LedgerLegDefinition leg, + Map postingsByUUID, List issues) + { + var projectionRole = leg.getProjectionRole(); + + if (projectionRole.isPresent()) + return matchProjectedLeg(entry, definition, leg, projectionRole.get(), postingsByUUID, issues); + + return new LegMatch(entry.getPostings().stream() // + .filter(posting -> postingMatchesLeg(entry.getType(), posting, leg)).toList()); + } + + private static LegMatch matchProjectedLeg(LedgerEntry entry, LedgerEntryDefinition definition, + LedgerLegDefinition leg, LedgerProjectionRole projectionRole, Map postingsByUUID, + List issues) + { + var refs = entry.getProjectionRefs().stream().filter(ref -> ref.getRole() == projectionRole).toList(); + var sameTypePostings = entry.getPostings().stream().filter(posting -> posting.getType() == leg.getPostingType()) + .toList(); + var matchingPostings = new ArrayList(); + + if (refs.isEmpty() && (requiresLeg(leg.getCardinality()) || !sameTypePostings.isEmpty())) + issues.add(issue(IssueCode.REQUIRED_PROJECTION_MISSING, + LedgerDiagnosticCode.LEDGER_STRUCT_042 + .message("Native leg projection is missing: " + projectionRole), //$NON-NLS-1$ + entry) + .withDetail("legRole", leg.getRole()) //$NON-NLS-1$ + .withDetail("projectionRole", projectionRole)); //$NON-NLS-1$ + + for (var ref : refs) + { + if (leg.isPrimaryPostingExpected() && isBlank(ref.getPrimaryPostingUUID())) + { + issues.add(issue(IssueCode.PROJECTION_PRIMARY_POSTING_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_043.message( + "Native leg projection requires a primary posting: " + projectionRole), //$NON-NLS-1$ + entry) + .withProjection(ref) + .withDetail("legRole", leg.getRole())); //$NON-NLS-1$ + continue; + } + + var posting = postingsByUUID.get(ref.getPrimaryPostingUUID()); + if (posting != null) + { + if (postingMatchesLeg(entry.getType(), posting, leg)) + matchingPostings.add(posting); + else + issues.add(issue(IssueCode.PROJECTION_PRIMARY_POSTING_MISMATCH, + LedgerDiagnosticCode.LEDGER_STRUCT_044.message( + "Projection primary posting does not match native leg " //$NON-NLS-1$ + + leg.getRole()), + entry) + .withProjection(ref).withPosting(posting) + .withDetail("legRole", leg.getRole())); //$NON-NLS-1$ + } + + if (leg.isPostingGroupExpected()) + { + if (isBlank(ref.getPostingGroupUUID())) + issues.add(issue(IssueCode.PROJECTION_POSTING_GROUP_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_045.message( + "Native leg projection requires a posting group anchor: " //$NON-NLS-1$ + + projectionRole), + entry).withProjection(ref).withDetail("legRole", leg.getRole())); //$NON-NLS-1$ + else if (!postingsByUUID.containsKey(ref.getPostingGroupUUID())) + issues.add(issue(IssueCode.PROJECTION_POSTING_GROUP_NOT_FOUND, + LedgerDiagnosticCode.LEDGER_STRUCT_046.message( + "Native leg projection posting group anchor does not exist: " //$NON-NLS-1$ + + ref.getPostingGroupUUID()), + entry).withProjection(ref).withDetail("legRole", leg.getRole())); //$NON-NLS-1$ + } + } + + if (refs.size() > 1 && leg.getCardinality() != LedgerLegCardinality.REPEATABLE + && leg.getCardinality() != LedgerLegCardinality.AT_LEAST_ONE) + issues.add(issue(IssueCode.AMBIGUOUS_LEG_MATCH, + LedgerDiagnosticCode.LEDGER_STRUCT_047 + .message("Native leg maps to multiple projection refs: " + leg.getRole()), //$NON-NLS-1$ + entry) + .withDetail("legRole", leg.getRole()) //$NON-NLS-1$ + .withDetail("projectionRole", projectionRole)); //$NON-NLS-1$ + + validateAllowedProjectionRole(definition, entry, leg, projectionRole, issues); + + return new LegMatch(matchingPostings); + } + + private static void validateAllowedProjectionRole(LedgerEntryDefinition definition, LedgerEntry entry, + LedgerLegDefinition leg, LedgerProjectionRole projectionRole, List issues) + { + if (!definition.getProjectionRoles().contains(projectionRole)) + issues.add(issue(IssueCode.REQUIRED_PROJECTION_MISSING, + LedgerDiagnosticCode.LEDGER_STRUCT_048.message( + "Native leg projection role is not allowed by entry definition: " //$NON-NLS-1$ + + projectionRole), + entry) + .withDetail("legRole", leg.getRole()) //$NON-NLS-1$ + .withDetail("projectionRole", projectionRole)); //$NON-NLS-1$ + } + + private static boolean requiresLeg(LedgerLegCardinality cardinality) + { + return cardinality == LedgerLegCardinality.EXACTLY_ONE || cardinality == LedgerLegCardinality.AT_LEAST_ONE; + } + + private static void validateCardinality(LedgerEntry entry, LedgerLegDefinition leg, List postings, + List issues) + { + var count = postings.size(); + + switch (leg.getCardinality()) + { + case EXACTLY_ONE: + if (count != 1) + issues.add(cardinalityIssue(LedgerDiagnosticCode.LEDGER_STRUCT_049, entry, leg, count, + "exactly one")); //$NON-NLS-1$ + break; + case AT_LEAST_ONE: + if (count < 1) + issues.add(cardinalityIssue(LedgerDiagnosticCode.LEDGER_STRUCT_050, entry, leg, count, + "at least one")); //$NON-NLS-1$ + break; + case OPTIONAL: + if (count > 1) + issues.add(issue(IssueCode.AMBIGUOUS_LEG_MATCH, + LedgerDiagnosticCode.LEDGER_STRUCT_051.message( + "Optional native leg maps to multiple postings: " + leg.getRole()), //$NON-NLS-1$ + entry) + .withDetail("legRole", leg.getRole()) //$NON-NLS-1$ + .withDetail("actualCount", count)); //$NON-NLS-1$ + break; + case REPEATABLE: + break; + default: + throw new IllegalStateException(LedgerDiagnosticCode.LEDGER_STRUCT_052 + .message("Unhandled LedgerLegCardinality: " + leg.getCardinality())); //$NON-NLS-1$ + } + } + + private static ValidationIssue cardinalityIssue(LedgerDiagnosticCode diagnosticCode, LedgerEntry entry, + LedgerLegDefinition leg, int actual, String expected) + { + return issue(IssueCode.LEG_CARDINALITY_VIOLATED, + diagnosticCode.message( + "Native leg cardinality violated for " + leg.getRole() + ": expected " //$NON-NLS-1$ //$NON-NLS-2$ + + expected + ", actual " + actual), //$NON-NLS-1$ + entry).withDetail("legRole", leg.getRole()) //$NON-NLS-1$ + .withDetail("expectedCardinality", leg.getCardinality()) //$NON-NLS-1$ + .withDetail("actualCount", actual); //$NON-NLS-1$ + } + + private static void validateLegParameters(LedgerEntry entry, LedgerLegDefinition leg, List postings, + List issues) + { + var allowed = EnumSet.noneOf(LedgerParameterType.class); + allowed.addAll(leg.getRequiredParameterTypes()); + allowed.addAll(leg.getOptionalParameterTypes()); + + for (var posting : postings) + { + for (var required : leg.getRequiredParameterTypes()) + { + if (!hasParameter(posting.getParameters(), required)) + { + var misplaced = hasParameter(entry.getParameters(), required) + || entry.getPostings().stream().filter(other -> other != posting) + .anyMatch(other -> hasParameter(other.getParameters(), required)); + + var code = misplaced ? IssueCode.PARAMETER_PLACEMENT_INVALID + : IssueCode.REQUIRED_LEG_PARAMETER_MISSING; + issues.add(issue(code, + LedgerDiagnosticCode.LEDGER_STRUCT_053.message( + "Required native leg parameter is missing from posting: " //$NON-NLS-1$ + + required), + entry) + .withPosting(posting) + .withDetail("legRole", leg.getRole()) //$NON-NLS-1$ + .withDetail("parameterType", required)); //$NON-NLS-1$ + } + } + + for (var parameter : posting.getParameters()) + { + var parameterType = parameter.getType(); + + if (parameterType != null && !allowed.contains(parameterType)) + issues.add(issue(IssueCode.LEG_PARAMETER_NOT_ALLOWED, + LedgerDiagnosticCode.LEDGER_STRUCT_054.message( + "Native leg parameter is not allowed for " + leg.getRole() + ": " //$NON-NLS-1$ //$NON-NLS-2$ + + parameterType), + entry).withPosting(posting).withParameter(parameter) + .withDetail("legRole", leg.getRole())); //$NON-NLS-1$ + } + + validateExpectedLegCode(entry, leg, posting, issues); + } + } + + private static void validateExpectedLegCode(LedgerEntry entry, LedgerLegDefinition leg, LedgerPosting posting, + List issues) + { + var expected = expectedCorporateActionLegCode(entry.getType(), leg.getRole()); + + if (expected.isEmpty()) + return; + + var value = parameterValue(posting.getParameters(), LedgerParameterType.CORPORATE_ACTION_LEG); + + if (value.isPresent() && !expected.get().equals(value.get())) + issues.add(issue(IssueCode.LEG_PARAMETER_VALUE_MISMATCH, + LedgerDiagnosticCode.LEDGER_STRUCT_055 + .message("Native leg has unexpected CORPORATE_ACTION_LEG value"), //$NON-NLS-1$ + entry) + .withPosting(posting) + .withDetail("legRole", leg.getRole()) //$NON-NLS-1$ + .withDetail("expectedValue", expected.get()) //$NON-NLS-1$ + .withDetail("actualValue", value.get())); //$NON-NLS-1$ + } + + private static boolean postingMatchesLeg(LedgerEntryType entryType, LedgerPosting posting, + LedgerLegDefinition leg) + { + if (posting.getType() != leg.getPostingType()) + return false; + + var expectedLegCode = expectedCorporateActionLegCode(entryType, leg.getRole()); + + return expectedLegCode.isEmpty() || parameterValue(posting.getParameters(), LedgerParameterType.CORPORATE_ACTION_LEG) + .filter(expectedLegCode.get()::equals).isPresent(); + } + + private static Optional expectedCorporateActionLegCode(LedgerEntryType entryType, LedgerLegRole role) + { + if (entryType == LedgerEntryType.SPIN_OFF) + { + if (role == LedgerLegRole.SOURCE_SECURITY_LEG) + return Optional.of(CorporateActionLeg.SOURCE_SECURITY.getCode()); + if (role == LedgerLegRole.TARGET_SECURITY_LEG) + return Optional.of(CorporateActionLeg.TARGET_SECURITY.getCode()); + } + + if (entryType == LedgerEntryType.STOCK_DIVIDEND || entryType == LedgerEntryType.BONUS_ISSUE) + { + if (role == LedgerLegRole.RECEIVED_SECURITY_LEG) + return Optional.of(CorporateActionLeg.TARGET_SECURITY.getCode()); + } + + if (entryType == LedgerEntryType.RIGHTS_DISTRIBUTION) + { + if (role == LedgerLegRole.DISTRIBUTED_RIGHT_LEG) + return Optional.of(CorporateActionLeg.RIGHT_SECURITY.getCode()); + if (role == LedgerLegRole.DISTRIBUTED_SECURITY_LEG) + return Optional.of(CorporateActionLeg.DISTRIBUTED_SECURITY.getCode()); + if (role == LedgerLegRole.SOURCE_SECURITY_LEG) + return Optional.of(CorporateActionLeg.SOURCE_SECURITY.getCode()); + } + + if (entryType == LedgerEntryType.BOND_CONVERSION) + { + if (role == LedgerLegRole.SOURCE_BOND_LEG) + return Optional.of(CorporateActionLeg.CONVERSION_SOURCE.getCode()); + if (role == LedgerLegRole.TARGET_SECURITY_LEG) + return Optional.of(CorporateActionLeg.CONVERSION_TARGET.getCode()); + } + + if (role == LedgerLegRole.CASH_COMPENSATION_LEG) + return Optional.of(CorporateActionLeg.CASH_COMPENSATION.getCode()); + if (role == LedgerLegRole.FEE_LEG) + return Optional.of(CorporateActionLeg.FEE.getCode()); + if (role == LedgerLegRole.TAX_LEG) + return Optional.of(CorporateActionLeg.TAX.getCode()); + if (role == LedgerLegRole.ACCRUED_INTEREST_LEG) + return Optional.of(CorporateActionLeg.ACCRUED_INTEREST.getCode()); + + return Optional.empty(); + } + + private static Set parameterTypes(List> parameters) + { + var values = EnumSet.noneOf(LedgerParameterType.class); + + parameters.stream().map(LedgerParameter::getType).filter(Objects::nonNull).forEach(values::add); + + return values; + } + + private static boolean hasParameter(List> parameters, LedgerParameterType type) + { + return parameters.stream().anyMatch(parameter -> parameter.getType() == type); + } + + private static Optional parameterValue(List> parameters, LedgerParameterType type) + { + return parameters.stream() // + .filter(parameter -> parameter.getType() == type) // + .map(LedgerParameter::getValue) // + .filter(String.class::isInstance) // + .map(String.class::cast) // + .findFirst(); + } + + private static boolean isBlank(String value) + { + return value == null || value.isBlank(); + } + + private static ValidationIssue issue(IssueCode code, String message, LedgerEntry entry) + { + return new ValidationIssue(code, message).withEntry(entry); + } + + private record LegMatch(List postings) + { + private LegMatch + { + postings = List.copyOf(postings); + } + } + + 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$ + + return issues.stream().map(ValidationIssue::format).collect(Collectors.joining("\n\n")); //$NON-NLS-1$ + } + } + + 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 = Objects.requireNonNull(code); + this.message = Objects.requireNonNull(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$ + + for (var entry : details.entrySet()) + builder.append("\n ").append(entry.getKey()).append(": ").append(entry.getValue()); //$NON-NLS-1$ //$NON-NLS-2$ + + return builder.toString(); + } + + 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$ + } + + 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$ + } + + private ValidationIssue withProjection(LedgerProjectionRef projectionRef) + { + if (projectionRef == null) + return this; + + return withDetail("projectionUUID", projectionRef.getUUID()) //$NON-NLS-1$ + .withDetail("projectionRole", projectionRef.getRole()) //$NON-NLS-1$ + .withDetail("primaryPostingUUID", projectionRef.getPrimaryPostingUUID()) //$NON-NLS-1$ + .withDetail("postingGroupUUID", projectionRef.getPostingGroupUUID()); //$NON-NLS-1$ + } + + private ValidationIssue withParameter(LedgerParameter parameter) + { + if (parameter == null) + return this; + + return withDetail("parameterType", parameter.getType()) //$NON-NLS-1$ + .withDetail("parameterValue", parameter.getValue()); //$NON-NLS-1$ + } + + private ValidationIssue withDetail(String key, Object value) + { + details.put(key, detailValue(value)); + return this; + } + + private String detailValue(Object value) + { + if (value == null) + return ""; //$NON-NLS-1$ + + var string = String.valueOf(value); + + return string.isBlank() ? "" : string; //$NON-NLS-1$ + } + } + + public static final class ValidationException extends IllegalArgumentException + { + private static final long serialVersionUID = 1L; + + private final ValidationResult result; + + private ValidationException(ValidationResult result) + { + super("Invalid native Ledger entry definition: " + result.format()); //$NON-NLS-1$ + this.result = result; + } + + public ValidationResult getResult() + { + return result; + } + } +} 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..d58a757b08 --- /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 postingGroupUUIDExpected; + + private LedgerPostingGroupRule(String name, LedgerRequirement requirement, Set postingTypes, + Set projectionRoles, boolean postingGroupUUIDExpected) + { + this.name = requireName(name); + this.requirement = Objects.requireNonNull(requirement); + this.postingTypes = copyPostingTypes(postingTypes); + this.projectionRoles = copyProjectionRoles(projectionRoles); + this.postingGroupUUIDExpected = postingGroupUUIDExpected; + } + + public static LedgerPostingGroupRule of(String name, LedgerRequirement requirement, + Set postingTypes, Set projectionRoles, + boolean postingGroupUUIDExpected) + { + return new LedgerPostingGroupRule(name, requirement, postingTypes, projectionRoles, postingGroupUUIDExpected); + } + + 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 isPostingGroupUUIDExpected() + { + return postingGroupUUIDExpected; + } + + 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); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssembler.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssembler.java new file mode 100644 index 0000000000..d83da82c86 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssembler.java @@ -0,0 +1,475 @@ +package name.abuchen.portfolio.model.ledger.nativeentry; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Function; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.ledger.Ledger; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerMutationContext; +import name.abuchen.portfolio.model.ledger.LedgerParameter; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinition; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinitionRegistry; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerNativeEntryDefinitionValidator; +import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingTypeDefinition; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingTypeDefinitionRegistry; +import name.abuchen.portfolio.money.Money; + +/** + * Assembles ledger-native entries from declarative native-entry input. + * This is internal infrastructure for ledger-native transactions. It is not a completed UI + * or reporting workflow by itself. + * + *

+ * The assembler input is not persisted as a separate configuration object. It uses the + * static Ledger configuration model to create persisted Ledger entries, postings, + * parameters, and projection refs. + *

+ */ +public final class LedgerNativeEntryAssembler +{ + private final Client client; + private final Function> entryDefinitionLookup; + private final Function> postingDefinitionLookup; + + private LedgerNativeEntryAssembler(Client client, + Function> entryDefinitionLookup, + Function> postingDefinitionLookup) + { + this.client = Objects.requireNonNull(client); + this.entryDefinitionLookup = Objects.requireNonNull(entryDefinitionLookup); + this.postingDefinitionLookup = Objects.requireNonNull(postingDefinitionLookup); + } + + LedgerNativeEntryAssembler(Client client, + Function> entryDefinitionLookup) + { + this(client, entryDefinitionLookup, LedgerPostingTypeDefinitionRegistry::lookup); + } + + public static LedgerNativeEntryAssembler forClient(Client client) + { + return new LedgerNativeEntryAssembler(client, LedgerEntryDefinitionRegistry::lookup, + LedgerPostingTypeDefinitionRegistry::lookup); + } + + public EntryBuilder forType(LedgerEntryType entryType) + { + Objects.requireNonNull(entryType); + + if (!entryType.isLedgerNativeTargeted()) + throw issue(LedgerNativeEntryAssemblyIssue.ENTRY_TYPE_NOT_NATIVE, + entryType + " is a standard transaction family. " //$NON-NLS-1$ + + "Use LedgerTransactionCreator for standard transaction families"); //$NON-NLS-1$ + + var definition = entryDefinitionLookup.apply(entryType).orElseThrow( + () -> issue(LedgerNativeEntryAssemblyIssue.ENTRY_DEFINITION_MISSING, + "Missing LedgerEntryDefinition for " + entryType)); //$NON-NLS-1$ + + return new EntryBuilder(client, definition, postingDefinitionLookup); + } + + public EntryBuilder spinOff() + { + return forType(LedgerEntryType.SPIN_OFF); + } + + static LedgerNativeEntryAssemblyException issue(LedgerNativeEntryAssemblyIssue issue, String message) + { + return new LedgerNativeEntryAssemblyException(issue, message); + } + + public static final class EntryBuilder + { + private final Client client; + private final LedgerEntryDefinition definition; + private final Function> postingDefinitionLookup; + private final List securityLegs = new ArrayList<>(); + private final List fees = new ArrayList<>(); + private final List taxes = new ArrayList<>(); + private NativeEntryMetadata metadata; + private NativeCorporateActionEvent event; + private NativeCashCompensation cashCompensation; + + private EntryBuilder(Client client, LedgerEntryDefinition definition, + Function> postingDefinitionLookup) + { + this.client = client; + this.definition = definition; + this.postingDefinitionLookup = postingDefinitionLookup; + } + + public EntryBuilder metadata(NativeEntryMetadata metadata) + { + this.metadata = Objects.requireNonNull(metadata); + return this; + } + + public EntryBuilder event(NativeCorporateActionEvent event) + { + this.event = Objects.requireNonNull(event); + return this; + } + + public EntryBuilder securityLeg(NativeSecurityLeg leg) + { + securityLegs.add(Objects.requireNonNull(leg)); + return this; + } + + public EntryBuilder cashCompensation(NativeCashCompensation compensation) + { + this.cashCompensation = Objects.requireNonNull(compensation); + return this; + } + + public EntryBuilder fee(NativeFee fee) + { + fees.add(Objects.requireNonNull(fee)); + return this; + } + + public EntryBuilder tax(NativeTax tax) + { + taxes.add(Objects.requireNonNull(tax)); + return this; + } + + public LedgerNativeEntryBuildResult buildDetached() + { + var entry = new LedgerEntry(); + entry.setType(definition.getEntryType()); + + applyMetadata(entry); + applyEvent(entry); + + for (var leg : securityLegs) + addSecurityLeg(entry, leg); + + LedgerPosting compensationPosting = null; + if (cashCompensation != null) + compensationPosting = addCashCompensation(entry, cashCompensation); + + for (var fee : fees) + addFee(entry, fee); + + for (var tax : taxes) + addTax(entry, tax); + + if (compensationPosting != null) + addCashCompensationProjection(entry, cashCompensation, compensationPosting); + + var validationResult = validateDetached(entry); + + if (!validationResult.isOK()) + throw issue(LedgerNativeEntryAssemblyIssue.STRUCTURAL_VALIDATION_FAILED, + validationResult.format()); + + var definitionValidationResult = LedgerNativeEntryDefinitionValidator.validate(entry); + + if (!definitionValidationResult.isOK()) + throw issue(LedgerNativeEntryAssemblyIssue.NATIVE_DEFINITION_VALIDATION_FAILED, + definitionValidationResult.format()); + + return new LedgerNativeEntryBuildResult(entry, validationResult); + } + + public LedgerNativeEntryBuildResult buildAndAdd() + { + var detached = buildDetached(); + var context = new LedgerMutationContext(client); + + var liveEntry = context.attachEntry(detached.getEntry()); + + context.refresh(); + + var validationResult = LedgerStructuralValidator.validate(client.getLedger()); + + if (!validationResult.isOK()) + throw issue(LedgerNativeEntryAssemblyIssue.STRUCTURAL_VALIDATION_FAILED, + validationResult.format()); + + return new LedgerNativeEntryBuildResult(liveEntry, validationResult); + } + + private void applyMetadata(LedgerEntry entry) + { + if (metadata == null) + throw issue(LedgerNativeEntryAssemblyIssue.REQUIRED_VALUE_MISSING, + "Native entry metadata is required"); //$NON-NLS-1$ + + entry.setDateTime(metadata.getDateTime()); + entry.setNote(metadata.getNote()); + entry.setSource(metadata.getSource()); + } + + private void applyEvent(LedgerEntry entry) + { + if (event == null) + return; + + for (var parameter : event.getParameters()) + addEntryParameter(entry, parameter); + } + + private void addSecurityLeg(LedgerEntry entry, NativeSecurityLeg leg) + { + assertPostingTypeAllowed(leg.getPostingType()); + + var posting = new LedgerPosting(); + posting.setType(leg.getPostingType()); + posting.setPortfolio(leg.getPortfolio()); + posting.setSecurity(leg.getSecurity()); + posting.setShares(leg.getShares()); + applyMoney(posting, leg.getAmount()); + + if (leg.getLegCode() != null) + addPostingParameter(posting, leg.getPostingType(), + new NativeParameterValue(LedgerParameterType.CORPORATE_ACTION_LEG, leg.getLegCode())); + + for (var parameter : leg.getParameters()) + addPostingParameter(posting, leg.getPostingType(), parameter); + + entry.addPosting(posting); + + if (leg.getProjectionRole() != null) + addPortfolioProjection(entry, leg.getProjectionRole(), leg.getPortfolio(), posting); + } + + private LedgerPosting addCashCompensation(LedgerEntry entry, NativeCashCompensation compensation) + { + assertPostingTypeAllowed(LedgerPostingType.CASH_COMPENSATION); + + var posting = new LedgerPosting(); + posting.setType(LedgerPostingType.CASH_COMPENSATION); + posting.setAccount(compensation.getAccount()); + applyMoney(posting, compensation.getAmount()); + + if (compensation.getAccount() != null) + addPostingParameter(posting, LedgerPostingType.CASH_COMPENSATION, + new NativeParameterValue(LedgerParameterType.CASH_ACCOUNT, + compensation.getAccount())); + + if (compensation.getAmount() != null) + addPostingParameter(posting, LedgerPostingType.CASH_COMPENSATION, + new NativeParameterValue(LedgerParameterType.CASH_IN_LIEU_AMOUNT, + compensation.getAmount())); + + addPostingParameter(posting, LedgerPostingType.CASH_COMPENSATION, + new NativeParameterValue(LedgerParameterType.CORPORATE_ACTION_LEG, + CorporateActionLeg.CASH_COMPENSATION.getCode())); + + for (var parameter : compensation.getParameters()) + addPostingParameter(posting, LedgerPostingType.CASH_COMPENSATION, parameter); + + entry.addPosting(posting); + + return posting; + } + + private void addCashCompensationProjection(LedgerEntry entry, NativeCashCompensation compensation, + LedgerPosting posting) + { + addProjection(entry, ProjectionIntent.account(LedgerProjectionRole.CASH_COMPENSATION, + compensation.getAccount(), posting, posting)); + } + + private void addFee(LedgerEntry entry, NativeFee fee) + { + assertPostingTypeAllowed(LedgerPostingType.FEE); + + var posting = new LedgerPosting(); + posting.setType(LedgerPostingType.FEE); + posting.setAccount(fee.getAccount()); + applyMoney(posting, fee.getAmount()); + addPostingParameter(posting, LedgerPostingType.FEE, + new NativeParameterValue(LedgerParameterType.CORPORATE_ACTION_LEG, + CorporateActionLeg.FEE.getCode())); + + for (var parameter : fee.getParameters()) + addPostingParameter(posting, LedgerPostingType.FEE, parameter); + + entry.addPosting(posting); + } + + private void addTax(LedgerEntry entry, NativeTax tax) + { + assertPostingTypeAllowed(LedgerPostingType.TAX); + + var posting = new LedgerPosting(); + posting.setType(LedgerPostingType.TAX); + posting.setAccount(tax.getAccount()); + applyMoney(posting, tax.getAmount()); + addPostingParameter(posting, LedgerPostingType.TAX, + new NativeParameterValue(LedgerParameterType.CORPORATE_ACTION_LEG, + CorporateActionLeg.TAX.getCode())); + + for (var parameter : tax.getParameters()) + addPostingParameter(posting, LedgerPostingType.TAX, parameter); + + entry.addPosting(posting); + } + + private void applyMoney(LedgerPosting posting, Money amount) + { + if (amount == null) + throw issue(LedgerNativeEntryAssemblyIssue.REQUIRED_VALUE_MISSING, + posting.getType() + " posting amount is required"); //$NON-NLS-1$ + + posting.setAmount(amount.getAmount()); + posting.setCurrency(amount.getCurrencyCode()); + } + + private void addPortfolioProjection(LedgerEntry entry, LedgerProjectionRole role, Portfolio portfolio, + LedgerPosting posting) + { + addProjection(entry, ProjectionIntent.portfolio(role, portfolio, posting)); + } + + private void addProjection(LedgerEntry entry, ProjectionIntent intent) + { + assertProjectionRoleAllowed(intent.role()); + + if (intent.primaryPosting() == null) + throw issue(LedgerNativeEntryAssemblyIssue.PROJECTION_TARGET_MISSING, + "Projection target posting is required for " + intent.role()); //$NON-NLS-1$ + + var projection = new LedgerProjectionRef(); + projection.setRole(intent.role()); + + if (intent.account() != null) + projection.setAccount(intent.account()); + + if (intent.portfolio() != null) + projection.setPortfolio(intent.portfolio()); + + projection.setPrimaryPosting(intent.primaryPosting()); + + if (intent.postingGroup() != null) + projection.setPostingGroup(intent.postingGroup()); + + entry.addProjectionRef(projection); + } + + private void addEntryParameter(LedgerEntry entry, NativeParameterValue parameter) + { + if (!definition.getEntryParameterTypes().contains(parameter.getType())) + throw issue(LedgerNativeEntryAssemblyIssue.PARAMETER_NOT_IN_ENTRY_DEFINITION, + parameter.getType() + " is not an entry parameter for " //$NON-NLS-1$ + + definition.getEntryType()); + + entry.addParameter(parameter(parameter.getType(), parameter.getValue())); + } + + private void addPostingParameter(LedgerPosting posting, LedgerPostingType postingType, + NativeParameterValue parameter) + { + if (!definition.getPostingParameterTypes().contains(parameter.getType())) + throw issue(LedgerNativeEntryAssemblyIssue.PARAMETER_NOT_IN_POSTING_DEFINITION, + parameter.getType() + " is not a posting parameter for " //$NON-NLS-1$ + + definition.getEntryType()); + + var postingDefinition = postingDefinitionLookup.apply(postingType) + .orElseThrow(() -> issue( + LedgerNativeEntryAssemblyIssue.PARAMETER_NOT_IN_POSTING_TYPE_DEFINITION, + "Missing LedgerPostingTypeDefinition for " + postingType)); //$NON-NLS-1$ + + if (!postingDefinition.supportsParameterType(parameter.getType())) + throw issue(LedgerNativeEntryAssemblyIssue.PARAMETER_NOT_IN_POSTING_TYPE_DEFINITION, + parameter.getType() + " is not meaningful for " + postingType); //$NON-NLS-1$ + + posting.addParameter(parameter(parameter.getType(), parameter.getValue())); + } + + private LedgerParameter parameter(LedgerParameterType type, Object value) + { + Objects.requireNonNull(type); + + if (!type.supportsValue(value)) + throw issue(LedgerNativeEntryAssemblyIssue.VALUE_KIND_MISMATCH, + type + " requires " + type.getExpectedValueKind() + " backed by " //$NON-NLS-1$ //$NON-NLS-2$ + + type.getExpectedJavaType().getSimpleName()); + + if (type.hasCodeDomain() && !type.supportsCode((String) value)) + throw issue(LedgerNativeEntryAssemblyIssue.PARAMETER_CODE_NOT_ALLOWED, + value + " is not allowed for " + type + "; allowed: " //$NON-NLS-1$ //$NON-NLS-2$ + + type.getCodeDomain().getAllowedCodes()); + + return switch (type.getExpectedValueKind()) + { + case STRING -> LedgerParameter.ofString(type, (String) value); + case DECIMAL -> LedgerParameter.ofDecimal(type, (BigDecimal) value); + case LONG -> LedgerParameter.ofLong(type, ((Long) value).longValue()); + case MONEY -> LedgerParameter.ofMoney(type, (Money) value); + case SECURITY -> LedgerParameter.ofSecurity(type, (Security) value); + case ACCOUNT -> LedgerParameter.ofAccount(type, (Account) value); + case PORTFOLIO -> LedgerParameter.ofPortfolio(type, (Portfolio) value); + case BOOLEAN -> LedgerParameter.ofBoolean(type, (Boolean) value); + case LOCAL_DATE -> LedgerParameter.ofLocalDate(type, (LocalDate) value); + case LOCAL_DATE_TIME -> LedgerParameter.ofLocalDateTime(type, (LocalDateTime) value); + }; + } + + private void assertPostingTypeAllowed(LedgerPostingType postingType) + { + if (!definition.getPostingTypes().contains(postingType)) + throw issue(LedgerNativeEntryAssemblyIssue.POSTING_TYPE_NOT_IN_ENTRY_DEFINITION, + postingType + " is not a posting type for " + definition.getEntryType()); //$NON-NLS-1$ + } + + private void assertProjectionRoleAllowed(LedgerProjectionRole role) + { + if (!definition.getProjectionRoles().contains(role)) + throw issue(LedgerNativeEntryAssemblyIssue.PROJECTION_ROLE_NOT_IN_ENTRY_DEFINITION, + role + " is not a projection role for " + definition.getEntryType()); //$NON-NLS-1$ + } + + private LedgerStructuralValidator.ValidationResult validateDetached(LedgerEntry entry) + { + var candidate = new Ledger(); + + client.getLedger().getEntries().forEach(candidate::addEntry); + candidate.addEntry(entry); + + return LedgerStructuralValidator.validate(candidate); + } + + private record ProjectionIntent(LedgerProjectionRole role, Account account, Portfolio portfolio, + LedgerPosting primaryPosting, LedgerPosting postingGroup) + { + private ProjectionIntent + { + Objects.requireNonNull(role); + } + + private static ProjectionIntent portfolio(LedgerProjectionRole role, Portfolio portfolio, + LedgerPosting primaryPosting) + { + return new ProjectionIntent(role, null, portfolio, primaryPosting, null); + } + + private static ProjectionIntent account(LedgerProjectionRole role, Account account, + LedgerPosting primaryPosting, LedgerPosting postingGroup) + { + return new ProjectionIntent(role, account, null, primaryPosting, postingGroup); + } + } + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblyException.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblyException.java new file mode 100644 index 0000000000..f35fddd556 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblyException.java @@ -0,0 +1,24 @@ +package name.abuchen.portfolio.model.ledger.nativeentry; + +/** + * Reports failed ledger-native entry assembly. + * This is internal native-entry infrastructure. It carries diagnostics instead of applying + * partial Ledger mutations. + */ +public final class LedgerNativeEntryAssemblyException extends IllegalArgumentException +{ + private static final long serialVersionUID = 1L; + + private final LedgerNativeEntryAssemblyIssue issue; + + LedgerNativeEntryAssemblyException(LedgerNativeEntryAssemblyIssue issue, String message) + { + super(message); + this.issue = issue; + } + + public LedgerNativeEntryAssemblyIssue getIssue() + { + return issue; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblyIssue.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblyIssue.java new file mode 100644 index 0000000000..cda796535e --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblyIssue.java @@ -0,0 +1,23 @@ +package name.abuchen.portfolio.model.ledger.nativeentry; + +/** + * Describes a validation or assembly issue for ledger-native entries. + * This is internal native-entry infrastructure. It lets callers report precise failures + * without guessing missing transaction facts. + */ +public enum LedgerNativeEntryAssemblyIssue +{ + ENTRY_TYPE_NOT_NATIVE, + ENTRY_DEFINITION_MISSING, + POSTING_TYPE_NOT_IN_ENTRY_DEFINITION, + PARAMETER_NOT_IN_ENTRY_DEFINITION, + PARAMETER_NOT_IN_POSTING_DEFINITION, + PARAMETER_NOT_IN_POSTING_TYPE_DEFINITION, + VALUE_KIND_MISMATCH, + PARAMETER_CODE_NOT_ALLOWED, + PROJECTION_ROLE_NOT_IN_ENTRY_DEFINITION, + PROJECTION_TARGET_MISSING, + REQUIRED_VALUE_MISSING, + STRUCTURAL_VALIDATION_FAILED, + NATIVE_DEFINITION_VALIDATION_FAILED +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryBuildResult.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryBuildResult.java new file mode 100644 index 0000000000..56f76d1ab0 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryBuildResult.java @@ -0,0 +1,45 @@ +package name.abuchen.portfolio.model.ledger.nativeentry; + +import java.util.List; +import java.util.Objects; + +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; + +/** + * Carries the result of assembling ledger-native entries. + * This is internal native-entry infrastructure. Contributor code should use higher-level + * workflows before treating native entries as user-facing transactions. + * + *

+ * The result object is not persisted. Only the assembled {@link LedgerEntry} and its + * projection refs become part of the Ledger model. + *

+ */ +public final class LedgerNativeEntryBuildResult +{ + private final LedgerEntry entry; + private final LedgerStructuralValidator.ValidationResult validationResult; + + LedgerNativeEntryBuildResult(LedgerEntry entry, LedgerStructuralValidator.ValidationResult validationResult) + { + this.entry = Objects.requireNonNull(entry); + this.validationResult = Objects.requireNonNull(validationResult); + } + + public LedgerEntry getEntry() + { + return entry; + } + + public List getProjectionRefs() + { + return entry.getProjectionRefs(); + } + + public LedgerStructuralValidator.ValidationResult getValidationResult() + { + return validationResult; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeCashCompensation.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeCashCompensation.java new file mode 100644 index 0000000000..7239ca4abd --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeCashCompensation.java @@ -0,0 +1,130 @@ +package name.abuchen.portfolio.model.ledger.nativeentry; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.ledger.configuration.CashCompensationKind; +import name.abuchen.portfolio.model.ledger.configuration.FractionTreatment; +import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; +import name.abuchen.portfolio.model.ledger.configuration.RoundingModeCode; +import name.abuchen.portfolio.money.Money; + +/** + * Carries native cash compensation input for ledger-native entry assembly. + * This is internal native-entry infrastructure. It describes facts for assembly and does not + * mutate Ledger truth by itself. + * + *

+ * This input object is not persisted directly. The assembler converts it into Ledger + * postings and parameters using stable posting type and parameter type codes. + *

+ */ +public final class NativeCashCompensation +{ + private final Account account; + private final Money amount; + private final List parameters; + + private NativeCashCompensation(Builder builder) + { + this.account = builder.account; + this.amount = builder.amount; + this.parameters = List.copyOf(builder.parameters); + } + + public static Builder builder() + { + return new Builder(); + } + + Account getAccount() + { + return account; + } + + Money getAmount() + { + return amount; + } + + List getParameters() + { + return parameters; + } + + public static final class Builder + { + private Account account; + private Money amount; + private final List parameters = new ArrayList<>(); + + private Builder() + { + } + + public Builder account(Account account) + { + this.account = account; + return this; + } + + public Builder amount(Money amount) + { + this.amount = amount; + return this; + } + + public Builder kind(String kind) + { + return parameter(LedgerParameterType.CASH_COMPENSATION_KIND, kind); + } + + public Builder kind(CashCompensationKind kind) + { + return kind(kind.getCode()); + } + + public Builder applied(boolean applied) + { + return parameter(LedgerParameterType.CASH_IN_LIEU_APPLIED, Boolean.valueOf(applied)); + } + + public Builder fractionQuantity(BigDecimal quantity) + { + return parameter(LedgerParameterType.FRACTION_QUANTITY, quantity); + } + + public Builder fractionTreatment(String treatment) + { + return parameter(LedgerParameterType.FRACTION_TREATMENT, treatment); + } + + public Builder fractionTreatment(FractionTreatment treatment) + { + return fractionTreatment(treatment.getCode()); + } + + public Builder roundingMode(String roundingMode) + { + return parameter(LedgerParameterType.ROUNDING_MODE, roundingMode); + } + + public Builder roundingMode(RoundingModeCode roundingMode) + { + return roundingMode(roundingMode.getCode()); + } + + Builder parameter(LedgerParameterType type, Object value) + { + parameters.add(new NativeParameterValue(type, value)); + return this; + } + + public NativeCashCompensation build() + { + return new NativeCashCompensation(this); + } + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeCorporateActionEvent.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeCorporateActionEvent.java new file mode 100644 index 0000000000..3b27c9149b --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeCorporateActionEvent.java @@ -0,0 +1,121 @@ +package name.abuchen.portfolio.model.ledger.nativeentry; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionKind; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionSubtype; +import name.abuchen.portfolio.model.ledger.configuration.EventStage; +import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; + +/** + * Carries native corporate action event input for ledger-native entry assembly. + * This is internal native-entry infrastructure. It describes facts for assembly and does not + * mutate Ledger truth by itself. + * + *

+ * This input object is not persisted directly. The assembler converts its event facts into + * Ledger parameters whose type ids and controlled string codes carry the persisted meaning. + *

+ */ +public final class NativeCorporateActionEvent +{ + private final List parameters; + + private NativeCorporateActionEvent(List parameters) + { + this.parameters = List.copyOf(parameters); + } + + public static Builder builder() + { + return new Builder(); + } + + List getParameters() + { + return parameters; + } + + public static final class Builder + { + private final List parameters = new ArrayList<>(); + + private Builder() + { + } + + public Builder kind(String kind) + { + return parameter(LedgerParameterType.CORPORATE_ACTION_KIND, kind); + } + + public Builder kind(CorporateActionKind kind) + { + return kind(kind.getCode()); + } + + public Builder subtype(String subtype) + { + return parameter(LedgerParameterType.CORPORATE_ACTION_SUBTYPE, subtype); + } + + public Builder subtype(CorporateActionSubtype subtype) + { + return subtype(subtype.getCode()); + } + + public Builder reference(String reference) + { + return parameter(LedgerParameterType.EVENT_REFERENCE, reference); + } + + public Builder stage(String stage) + { + return parameter(LedgerParameterType.EVENT_STAGE, stage); + } + + public Builder stage(EventStage stage) + { + return stage(stage.getCode()); + } + + public Builder exDate(LocalDateTime exDate) + { + return parameter(LedgerParameterType.EX_DATE, exDate); + } + + public Builder recordDate(LocalDate recordDate) + { + return parameter(LedgerParameterType.RECORD_DATE, recordDate); + } + + public Builder paymentDate(LocalDate paymentDate) + { + return parameter(LedgerParameterType.PAYMENT_DATE, paymentDate); + } + + public Builder effectiveDate(LocalDate effectiveDate) + { + return parameter(LedgerParameterType.EFFECTIVE_DATE, effectiveDate); + } + + public Builder settlementDate(LocalDate settlementDate) + { + return parameter(LedgerParameterType.SETTLEMENT_DATE, settlementDate); + } + + Builder parameter(LedgerParameterType type, Object value) + { + parameters.add(new NativeParameterValue(type, value)); + return this; + } + + public NativeCorporateActionEvent build() + { + return new NativeCorporateActionEvent(parameters); + } + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeEntryMetadata.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeEntryMetadata.java new file mode 100644 index 0000000000..fb1158266e --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeEntryMetadata.java @@ -0,0 +1,58 @@ +package name.abuchen.portfolio.model.ledger.nativeentry; + +import java.time.LocalDateTime; +import java.util.Objects; + +/** + * Carries native entry metadata input for ledger-native entry assembly. + * This is internal native-entry infrastructure. It describes facts for assembly and does not + * mutate Ledger truth by itself. + * + *

+ * This input object is not persisted directly. The assembler copies supported values onto + * the resulting Ledger entry. + *

+ */ +public final class NativeEntryMetadata +{ + private final LocalDateTime dateTime; + private final String note; + private final String source; + + private NativeEntryMetadata(LocalDateTime dateTime, String note, String source) + { + this.dateTime = Objects.requireNonNull(dateTime); + this.note = note; + this.source = source; + } + + public static NativeEntryMetadata of(LocalDateTime dateTime) + { + return new NativeEntryMetadata(dateTime, null, null); + } + + public NativeEntryMetadata note(String note) + { + return new NativeEntryMetadata(dateTime, note, source); + } + + public NativeEntryMetadata source(String source) + { + return new NativeEntryMetadata(dateTime, note, source); + } + + LocalDateTime getDateTime() + { + return dateTime; + } + + String getNote() + { + return note; + } + + String getSource() + { + return source; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeFee.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeFee.java new file mode 100644 index 0000000000..c73e9c13e9 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeFee.java @@ -0,0 +1,112 @@ +package name.abuchen.portfolio.model.ledger.nativeentry; + +import java.util.ArrayList; +import java.util.List; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.ledger.configuration.FeeReason; +import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; +import name.abuchen.portfolio.money.Money; + +/** + * Carries native fee input for ledger-native entry assembly. + * This is internal native-entry infrastructure. It describes facts for assembly and does not + * mutate Ledger truth by itself. + * + *

+ * This input object is not persisted directly. The assembler converts it into Ledger + * postings and parameters using stable posting type and parameter type codes. + *

+ */ +public final class NativeFee +{ + private final Account account; + private final Money amount; + private final List parameters; + + private NativeFee(Builder builder) + { + this.account = builder.account; + this.amount = builder.amount; + this.parameters = List.copyOf(builder.parameters); + } + + public static Builder builder() + { + return new Builder(); + } + + public static NativeFee of(Account account, Money amount, String reason) + { + return builder().account(account).amount(amount).reason(reason).build(); + } + + public static NativeFee of(Account account, Money amount, FeeReason reason) + { + return builder().account(account).amount(amount).reason(reason).build(); + } + + Account getAccount() + { + return account; + } + + Money getAmount() + { + return amount; + } + + List getParameters() + { + return parameters; + } + + public static final class Builder + { + private Account account; + private Money amount; + private final List parameters = new ArrayList<>(); + + private Builder() + { + } + + public Builder account(Account account) + { + this.account = account; + return this; + } + + public Builder amount(Money amount) + { + this.amount = amount; + return this; + } + + public Builder reason(String reason) + { + return parameter(LedgerParameterType.FEE_REASON, reason); + } + + public Builder reason(FeeReason reason) + { + return reason(reason.getCode()); + } + + public Builder stampDuty(boolean stampDuty) + { + return parameter(LedgerParameterType.STAMP_DUTY, Boolean.valueOf(stampDuty)); + } + + Builder parameter(LedgerParameterType type, Object value) + { + parameters.add(new NativeParameterValue(type, value)); + return this; + } + + public NativeFee build() + { + return new NativeFee(this); + } + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeParameterValue.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeParameterValue.java new file mode 100644 index 0000000000..13c164db2c --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeParameterValue.java @@ -0,0 +1,37 @@ +package name.abuchen.portfolio.model.ledger.nativeentry; + +import java.util.Objects; + +import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; + +/** + * Carries native parameter value input for ledger-native entry assembly. + * This is internal native-entry infrastructure. It describes facts for assembly and does not + * mutate Ledger truth by itself. + * + *

+ * This input object is not persisted directly. The assembler converts it into a + * {@code LedgerParameter} with a stable parameter type code and a matching value kind. + *

+ */ +final class NativeParameterValue +{ + private final LedgerParameterType type; + private final Object value; + + NativeParameterValue(LedgerParameterType type, Object value) + { + this.type = Objects.requireNonNull(type); + this.value = Objects.requireNonNull(value); + } + + LedgerParameterType getType() + { + return type; + } + + Object getValue() + { + return value; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeSecurityLeg.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeSecurityLeg.java new file mode 100644 index 0000000000..f4cd018430 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeSecurityLeg.java @@ -0,0 +1,194 @@ +package name.abuchen.portfolio.model.ledger.nativeentry; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; +import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.money.Money; + +/** + * Carries native security leg input for ledger-native entry assembly. + * This is internal native-entry infrastructure. It describes facts for assembly and does not + * mutate Ledger truth by itself. + * + *

+ * This input object is not persisted directly. The assembler converts it into Ledger + * security postings, controlled leg codes, and projection refs. + *

+ */ +public final class NativeSecurityLeg +{ + private final LedgerPostingType postingType; + private final String legCode; + private final Portfolio portfolio; + private final Security security; + private final long shares; + private final Money amount; + private final LedgerProjectionRole projectionRole; + private final List parameters; + + private NativeSecurityLeg(Builder builder) + { + this.postingType = Objects.requireNonNull(builder.postingType); + this.legCode = builder.legCode; + this.portfolio = builder.portfolio; + this.security = builder.security; + this.shares = builder.shares; + this.amount = builder.amount; + this.projectionRole = builder.projectionRole; + this.parameters = List.copyOf(builder.parameters); + } + + public static Builder source() + { + return new Builder(LedgerPostingType.SECURITY, CorporateActionLeg.SOURCE_SECURITY.getCode(), + LedgerProjectionRole.OLD_SECURITY_LEG); + } + + public static Builder target() + { + return new Builder(LedgerPostingType.SECURITY, CorporateActionLeg.TARGET_SECURITY.getCode(), + LedgerProjectionRole.NEW_SECURITY_LEG); + } + + static Builder ofType(LedgerPostingType postingType) + { + return new Builder(postingType, CorporateActionLeg.SOURCE_SECURITY.getCode(), + LedgerProjectionRole.OLD_SECURITY_LEG); + } + + LedgerPostingType getPostingType() + { + return postingType; + } + + String getLegCode() + { + return legCode; + } + + Portfolio getPortfolio() + { + return portfolio; + } + + Security getSecurity() + { + return security; + } + + long getShares() + { + return shares; + } + + Money getAmount() + { + return amount; + } + + LedgerProjectionRole getProjectionRole() + { + return projectionRole; + } + + List getParameters() + { + return parameters; + } + + public static final class Builder + { + private LedgerPostingType postingType; + private String legCode; + private Portfolio portfolio; + private Security security; + private long shares; + private Money amount; + private LedgerProjectionRole projectionRole; + private final List parameters = new ArrayList<>(); + + private Builder(LedgerPostingType postingType, String legCode, LedgerProjectionRole projectionRole) + { + this.postingType = postingType; + this.legCode = legCode; + this.projectionRole = projectionRole; + } + + public Builder portfolio(Portfolio portfolio) + { + this.portfolio = portfolio; + return this; + } + + public Builder security(Security security) + { + this.security = security; + return this; + } + + public Builder shares(long shares) + { + this.shares = shares; + return this; + } + + public Builder amount(Money amount) + { + this.amount = amount; + return this; + } + + public Builder sourceSecurity(Security security) + { + return parameter(LedgerParameterType.SOURCE_SECURITY, security); + } + + public Builder targetSecurity(Security security) + { + return parameter(LedgerParameterType.TARGET_SECURITY, security); + } + + public Builder ratio(Ratio ratio) + { + Objects.requireNonNull(ratio); + parameter(LedgerParameterType.RATIO_NUMERATOR, ratio.getNumerator()); + return parameter(LedgerParameterType.RATIO_DENOMINATOR, ratio.getDenominator()); + } + + public Builder projectAs(LedgerProjectionRole projectionRole) + { + this.projectionRole = projectionRole; + return this; + } + + Builder postingType(LedgerPostingType postingType) + { + this.postingType = postingType; + return this; + } + + Builder legCode(String legCode) + { + this.legCode = legCode; + return this; + } + + Builder parameter(LedgerParameterType type, Object value) + { + parameters.add(new NativeParameterValue(type, value)); + return this; + } + + public NativeSecurityLeg build() + { + return new NativeSecurityLeg(this); + } + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeTax.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeTax.java new file mode 100644 index 0000000000..7924d0796e --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeTax.java @@ -0,0 +1,123 @@ +package name.abuchen.portfolio.model.ledger.nativeentry; + +import java.util.ArrayList; +import java.util.List; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; +import name.abuchen.portfolio.model.ledger.configuration.TaxReason; +import name.abuchen.portfolio.money.Money; + +/** + * Carries native tax input for ledger-native entry assembly. + * This is internal native-entry infrastructure. It describes facts for assembly and does not + * mutate Ledger truth by itself. + * + *

+ * This input object is not persisted directly. The assembler converts it into Ledger + * postings and parameters using stable posting type and parameter type codes. + *

+ */ +public final class NativeTax +{ + private final Account account; + private final Money amount; + private final List parameters; + + private NativeTax(Builder builder) + { + this.account = builder.account; + this.amount = builder.amount; + this.parameters = List.copyOf(builder.parameters); + } + + public static Builder builder() + { + return new Builder(); + } + + public static NativeTax withholding(Account account, Money amount) + { + return builder().account(account).amount(amount).reason(TaxReason.WITHHOLDING_TAX) + .withholdingTax(true).build(); + } + + Account getAccount() + { + return account; + } + + Money getAmount() + { + return amount; + } + + List getParameters() + { + return parameters; + } + + public static final class Builder + { + private Account account; + private Money amount; + private final List parameters = new ArrayList<>(); + + private Builder() + { + } + + public Builder account(Account account) + { + this.account = account; + return this; + } + + public Builder amount(Money amount) + { + this.amount = amount; + return this; + } + + public Builder reason(String reason) + { + return parameter(LedgerParameterType.TAX_REASON, reason); + } + + public Builder reason(TaxReason reason) + { + return reason(reason.getCode()); + } + + public Builder taxableDistribution(boolean taxableDistribution) + { + return parameter(LedgerParameterType.TAXABLE_DISTRIBUTION, Boolean.valueOf(taxableDistribution)); + } + + public Builder withholdingTax(boolean withholdingTax) + { + return parameter(LedgerParameterType.WITHHOLDING_TAX, Boolean.valueOf(withholdingTax)); + } + + public Builder transactionTax(boolean transactionTax) + { + return parameter(LedgerParameterType.TRANSACTION_TAX, Boolean.valueOf(transactionTax)); + } + + public Builder reclaimableTax(boolean reclaimableTax) + { + return parameter(LedgerParameterType.RECLAIMABLE_TAX, Boolean.valueOf(reclaimableTax)); + } + + Builder parameter(LedgerParameterType type, Object value) + { + parameters.add(new NativeParameterValue(type, value)); + return this; + } + + public NativeTax build() + { + return new NativeTax(this); + } + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/Ratio.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/Ratio.java new file mode 100644 index 0000000000..8c5a976606 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/Ratio.java @@ -0,0 +1,41 @@ +package name.abuchen.portfolio.model.ledger.nativeentry; + +import java.math.BigDecimal; +import java.util.Objects; + +/** + * Carries ratio input for ledger-native entry assembly. + * This is internal native-entry infrastructure. It describes facts for assembly and does not + * mutate Ledger truth by itself. + * + *

+ * This value object is not persisted directly. The assembler writes ratios as Ledger + * parameters with stable parameter type codes. + *

+ */ +public final class Ratio +{ + private final BigDecimal numerator; + private final BigDecimal denominator; + + private Ratio(BigDecimal numerator, BigDecimal denominator) + { + this.numerator = Objects.requireNonNull(numerator); + this.denominator = Objects.requireNonNull(denominator); + } + + public static Ratio of(BigDecimal numerator, BigDecimal denominator) + { + return new Ratio(numerator, denominator); + } + + public BigDecimal getNumerator() + { + return numerator; + } + + public BigDecimal getDenominator() + { + return denominator; + } +} From 3a04f1af7ab5b7f9e275b86291d00f6e5f0aaad0 Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Fri, 26 Jun 2026 14:28:57 +0200 Subject: [PATCH 03/68] Add Ledger projection materialization Add Ledger projection references, membership support, runtime projection classes, materialization, and projection tests. This isolates how Ledger truth becomes compatibility transaction rows. Persistence, UI editing, and import guardrails are intentionally left to later commits. --- .../LedgerProjectionMaterializerTest.java | 502 ++++++++++++++ .../LedgerProjectionWriteGuardrailTest.java | 231 +++++++ .../LedgerRuntimeProjectionRestorerTest.java | 638 ++++++++++++++++++ .../model/ledger/LedgerProjectionRef.java | 179 +++++ .../model/ledger/LedgerProjectionRole.java | 28 + .../model/ledger/ProjectionMembership.java | 38 ++ .../ledger/ProjectionMembershipRole.java | 16 + .../LedgerBackedAccountTransaction.java | 308 +++++++++ .../projection/LedgerBackedCrossEntry.java | 105 +++ .../LedgerBackedPortfolioTransaction.java | 285 ++++++++ .../projection/LedgerBackedTransaction.java | 16 + .../projection/LedgerProjectionFactory.java | 151 +++++ .../LedgerProjectionMaterializer.java | 82 +++ .../projection/LedgerProjectionService.java | 78 +++ .../projection/LedgerProjectionSupport.java | 281 ++++++++ .../LedgerRuntimeProjectionRestorer.java | 423 ++++++++++++ 16 files changed, 3361 insertions(+) create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerProjectionMaterializerTest.java create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerProjectionWriteGuardrailTest.java create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerRuntimeProjectionRestorerTest.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerProjectionRef.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerProjectionRole.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/ProjectionMembership.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/ProjectionMembershipRole.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedAccountTransaction.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedCrossEntry.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedPortfolioTransaction.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedTransaction.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionFactory.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionMaterializer.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionService.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionSupport.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerRuntimeProjectionRestorer.java diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerProjectionMaterializerTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerProjectionMaterializerTest.java new file mode 100644 index 0000000000..eac3147c60 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerProjectionMaterializerTest.java @@ -0,0 +1,502 @@ +package name.abuchen.portfolio.model.ledger; + +import static org.hamcrest.CoreMatchers.instanceOf; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertNull; +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.LocalDateTime; +import java.util.List; + +import org.junit.Test; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +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.model.Transaction.Unit; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountCashLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerCashTransferLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerCreationUnit; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerCreationUnits; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerDeliveryLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerDividend; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerForexAmount; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerOptionalSecurity; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioSecurityLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferSecurity; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerSecurityQuantity; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerTransactionCreator; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedAccountTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedPortfolioTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionService; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.Values; + +/** + * Tests rebuilding runtime transaction rows from ledger entries. + * These tests make sure account and portfolio views are derived from ledger truth without duplicate rows. + */ +@SuppressWarnings("nls") +public class LedgerProjectionMaterializerTest +{ + private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 1, 2, 0, 0); + + /** + * Checks the projection rebuild scenario: service creates account backed deposit projection. + * Account and portfolio lists must be derived from the ledger entry. + * This protects Ledger-V6 from stale or duplicated runtime projections. + */ + @Test + public void testServiceCreatesAccountBackedDepositProjection() + { + var client = new Client(); + var account = account(); + var entry = creator(client).createDeposit(metadata(), cashLeg(account, 100)).getEntry(); + var projectionRef = entry.getProjectionRefs().get(0); + var projection = LedgerProjectionService.createProjection(entry, projectionRef); + + assertThat(projection, instanceOf(LedgerBackedAccountTransaction.class)); + assertThat(projection.getUUID(), is(projectionRef.getUUID())); + assertThat(((AccountTransaction) projection).getType(), is(AccountTransaction.Type.DEPOSIT)); + assertThat(projection.getDateTime(), is(DATE_TIME)); + assertThat(projection.getNote(), is("note")); + assertThat(projection.getSource(), is("source")); + assertThat(projection.getAmount(), is(Values.Amount.factorize(100))); + assertThat(projection.getCurrencyCode(), is(CurrencyUnit.EUR)); + } + + /** + * Checks the projection rebuild scenario: materializer adds account projection only. + * Account and portfolio lists must be derived from the ledger entry. + * This protects Ledger-V6 from stale or duplicated runtime projections. + */ + @Test + public void testMaterializerAddsAccountProjectionOnly() + { + var client = new Client(); + var account = account(); + var portfolio = new Portfolio(); + + client.addAccount(account); + client.addPortfolio(portfolio); + creator(client).createDeposit(metadata(), cashLeg(account, 100)); + + LedgerProjectionService.materialize(client); + + assertThat(account.getTransactions().size(), is(1)); + assertThat(account.getTransactions().get(0), instanceOf(LedgerBackedAccountTransaction.class)); + assertTrue(portfolio.getTransactions().isEmpty()); + } + + /** + * Checks the projection rebuild scenario: dividend projection exposes ex-date and units. + * Account and portfolio lists must be derived from the ledger entry. + * This protects Ledger-V6 from stale or duplicated runtime projections. + */ + @Test + public void testDividendProjectionExposesExDateAndUnits() + { + var client = new Client(); + var account = account(); + var security = security(); + var exDate = LocalDateTime.of(2025, 12, 20, 0, 0); + var forex = LedgerForexAmount.of(Money.of(CurrencyUnit.USD, Values.Amount.factorize(40)), + BigDecimal.valueOf(0.90)); + var units = LedgerCreationUnits.of(LedgerCreationUnit.tax(money(3)), LedgerCreationUnit.fee(money(2)), + LedgerCreationUnit.grossValue(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(36)), + forex)); + var dividend = LedgerDividend.withExDate(cashLeg(account, 30), LedgerOptionalSecurity.of(security), units, + exDate); + + creator(client).createDividend(metadata(), dividend); + + LedgerProjectionService.materialize(client); + + var transaction = account.getTransactions().get(0); + var grossValue = transaction.getUnit(Unit.Type.GROSS_VALUE).orElseThrow(); + + assertThat(transaction.getType(), is(AccountTransaction.Type.DIVIDENDS)); + assertThat(transaction.getSecurity(), is(security)); + assertThat(transaction.getExDate(), is(exDate)); + assertThat(transaction.getUnit(Unit.Type.TAX).orElseThrow().getAmount().getAmount(), + is(Values.Amount.factorize(3))); + assertThat(transaction.getUnit(Unit.Type.FEE).orElseThrow().getAmount().getAmount(), + is(Values.Amount.factorize(2))); + assertThat(grossValue.getAmount().getAmount(), is(Values.Amount.factorize(36))); + assertThat(grossValue.getForex().getAmount(), is(Values.Amount.factorize(40))); + assertThat(grossValue.getExchangeRate(), is(BigDecimal.valueOf(0.90))); + } + + /** + * Checks the projection rebuild scenario: buy materializes linked account and portfolio projections. + * Account and portfolio lists must be derived from the ledger entry. + * This protects Ledger-V6 from stale or duplicated runtime projections. + */ + @Test + public void testBuyMaterializesLinkedAccountAndPortfolioProjections() + { + var client = new Client(); + var account = account(); + var portfolio = portfolio(); + + client.addAccount(account); + client.addPortfolio(portfolio); + creator(client).createBuy(metadata(), cashLeg(account, 100), portfolioLeg(portfolio, 100), + LedgerCreationUnits.of(LedgerCreationUnit.fee(money(1)))); + + LedgerProjectionService.materialize(client); + + var accountTransaction = account.getTransactions().get(0); + var portfolioTransaction = portfolio.getTransactions().get(0); + + assertThat(accountTransaction, instanceOf(LedgerBackedAccountTransaction.class)); + assertThat(portfolioTransaction, instanceOf(LedgerBackedPortfolioTransaction.class)); + assertThat(accountTransaction.getType(), is(AccountTransaction.Type.BUY)); + assertThat(portfolioTransaction.getType(), is(PortfolioTransaction.Type.BUY)); + assertThat(account.getTransactions(), is(List.of(accountTransaction))); + assertThat(portfolio.getTransactions(), is(List.of(portfolioTransaction))); + assertSame(accountTransaction.getCrossEntry(), portfolioTransaction.getCrossEntry()); + assertSame(portfolioTransaction, accountTransaction.getCrossEntry().getCrossTransaction(accountTransaction)); + assertSame(portfolio, accountTransaction.getCrossEntry().getCrossOwner(accountTransaction)); + assertThat(portfolioTransaction.getUnit(Unit.Type.FEE).orElseThrow().getAmount().getAmount(), + is(Values.Amount.factorize(1))); + } + + /** + * Checks the projection rebuild scenario: sell materializes linked projections. + * Account and portfolio lists must be derived from the ledger entry. + * This protects Ledger-V6 from stale or duplicated runtime projections. + */ + @Test + public void testSellMaterializesLinkedProjections() + { + var client = new Client(); + var account = account(); + var portfolio = portfolio(); + + creator(client).createSell(metadata(), cashLeg(account, 100), portfolioLeg(portfolio, 100), + LedgerCreationUnits.none()); + + LedgerProjectionService.materialize(client); + + assertThat(account.getTransactions().get(0).getType(), is(AccountTransaction.Type.SELL)); + assertThat(portfolio.getTransactions().get(0).getType(), is(PortfolioTransaction.Type.SELL)); + assertSame(account.getTransactions().get(0).getCrossEntry(), portfolio.getTransactions().get(0).getCrossEntry()); + } + + /** + * Checks the projection rebuild scenario: account transfer materializes transfer out and transfer in. + * Account and portfolio lists must be derived from the ledger entry. + * This protects Ledger-V6 from stale or duplicated runtime projections. + */ + @Test + public void testAccountTransferMaterializesTransferOutAndTransferIn() + { + var client = new Client(); + var source = account(); + var target = account(); + + creator(client).createAccountTransfer(metadata(), LedgerCashTransferLeg.of(source, money(100)), + LedgerCashTransferLeg.of(target, money(100))); + + LedgerProjectionService.materialize(client); + + var sourceTransaction = source.getTransactions().get(0); + var targetTransaction = target.getTransactions().get(0); + + assertThat(sourceTransaction.getType(), is(AccountTransaction.Type.TRANSFER_OUT)); + assertThat(targetTransaction.getType(), is(AccountTransaction.Type.TRANSFER_IN)); + assertThat(source.getTransactions(), is(List.of(sourceTransaction))); + assertThat(target.getTransactions(), is(List.of(targetTransaction))); + assertSame(targetTransaction, sourceTransaction.getCrossEntry().getCrossTransaction(sourceTransaction)); + assertSame(target, sourceTransaction.getCrossEntry().getCrossOwner(sourceTransaction)); + } + + /** + * Checks the projection rebuild scenario: portfolio transfer materializes transfer out and transfer in. + * Account and portfolio lists must be derived from the ledger entry. + * This protects Ledger-V6 from stale or duplicated runtime projections. + */ + @Test + public void testPortfolioTransferMaterializesTransferOutAndTransferIn() + { + var client = new Client(); + var source = portfolio(); + var target = portfolio(); + + creator(client).createPortfolioTransfer(metadata(), + LedgerPortfolioTransferSecurity.of(security(), Values.Share.factorize(5)), + LedgerPortfolioTransferLeg.of(source, money(100)), + LedgerPortfolioTransferLeg.of(target, money(100))); + + LedgerProjectionService.materialize(client); + + var sourceTransaction = source.getTransactions().get(0); + var targetTransaction = target.getTransactions().get(0); + + assertThat(sourceTransaction.getType(), is(PortfolioTransaction.Type.TRANSFER_OUT)); + assertThat(targetTransaction.getType(), is(PortfolioTransaction.Type.TRANSFER_IN)); + assertThat(source.getTransactions(), is(List.of(sourceTransaction))); + assertThat(target.getTransactions(), is(List.of(targetTransaction))); + assertSame(targetTransaction, sourceTransaction.getCrossEntry().getCrossTransaction(sourceTransaction)); + assertSame(target, sourceTransaction.getCrossEntry().getCrossOwner(sourceTransaction)); + } + + /** + * Checks the projection rebuild scenario: delivery materializes one portfolio projection. + * Account and portfolio lists must be derived from the ledger entry. + * This protects Ledger-V6 from stale or duplicated runtime projections. + */ + @Test + public void testDeliveryMaterializesOnePortfolioProjection() + { + var client = new Client(); + var portfolio = portfolio(); + var security = security(); + + creator(client).createInboundDelivery(metadata(), LedgerDeliveryLeg.of(portfolio, + LedgerSecurityQuantity.of(security, Values.Share.factorize(5)), money(100))); + + LedgerProjectionService.materialize(client); + + var transaction = portfolio.getTransactions().get(0); + + assertThat(transaction.getType(), is(PortfolioTransaction.Type.DELIVERY_INBOUND)); + assertThat(transaction.getSecurity(), is(security)); + assertThat(transaction.getShares(), is(Values.Share.factorize(5))); + assertNull(transaction.getCrossEntry()); + } + + /** + * Checks the projection rebuild scenario: repeated materialization does not duplicate ledger backed projections. + * Account and portfolio lists must be derived from the ledger entry. + * This protects Ledger-V6 from stale or duplicated runtime projections. + */ + @Test + public void testRepeatedMaterializationDoesNotDuplicateLedgerBackedProjections() + { + var client = new Client(); + var account = account(); + + creator(client).createDeposit(metadata(), cashLeg(account, 100)); + + LedgerProjectionService.materialize(client); + LedgerProjectionService.materialize(client); + + assertThat(account.getTransactions().size(), is(1)); + } + + /** + * Checks the projection rebuild scenario: client all transactions sees deduplicated materialized views. + * Account and portfolio lists must be derived from the ledger entry. + * This protects Ledger-V6 from stale or duplicated runtime projections. + */ + @Test + public void testClientAllTransactionsSeesDeduplicatedMaterializedViews() + { + var client = new Client(); + var account = account(); + var portfolio = portfolio(); + var source = account(); + var target = account(); + + client.addAccount(account); + client.addAccount(source); + client.addAccount(target); + client.addPortfolio(portfolio); + + creator(client).createDeposit(metadata(), cashLeg(account, 100)); + creator(client).createBuy(metadata(), cashLeg(account, 100), portfolioLeg(portfolio, 100), + LedgerCreationUnits.none()); + creator(client).createAccountTransfer(metadata(), LedgerCashTransferLeg.of(source, money(10)), + LedgerCashTransferLeg.of(target, money(10))); + + LedgerProjectionService.materialize(client); + + List transactions = client.getAllTransactions().stream().map(pair -> (Transaction) pair.getTransaction()) + .toList(); + + assertThat(transactions.size(), is(3)); + assertTrue(transactions.stream().anyMatch(t -> t instanceof LedgerBackedAccountTransaction + && ((AccountTransaction) t).getType() == AccountTransaction.Type.DEPOSIT)); + assertTrue(transactions.stream().anyMatch(t -> t instanceof LedgerBackedPortfolioTransaction + && ((PortfolioTransaction) t).getType() == PortfolioTransaction.Type.BUY)); + assertTrue(transactions.stream().anyMatch(t -> t instanceof LedgerBackedAccountTransaction + && ((AccountTransaction) t).getType() == AccountTransaction.Type.TRANSFER_OUT)); + } + + /** + * Checks the projection rebuild scenario: ledger backed setters are rejected. + * Account and portfolio lists must be derived from the ledger entry. + * This protects Ledger-V6 from stale or duplicated runtime projections. + */ + @Test + public void testLedgerBackedSettersAreRejected() + { + var client = new Client(); + var account = account(); + var portfolio = portfolio(); + + creator(client).createBuy(metadata(), cashLeg(account, 100), portfolioLeg(portfolio, 100), + LedgerCreationUnits.none()); + LedgerProjectionService.materialize(client); + + var transaction = account.getTransactions().get(0); + + transaction.setDateTime(DATE_TIME.plusDays(1)); + transaction.setNote("new note"); + transaction.setSource("new source"); + + var ledgerTransaction = (LedgerBackedTransaction) transaction; + + assertThat(ledgerTransaction.getLedgerEntry().getDateTime(), is(DATE_TIME.plusDays(1))); + assertThat(ledgerTransaction.getLedgerEntry().getNote(), is("new note")); + assertThat(ledgerTransaction.getLedgerEntry().getSource(), is("new source")); + + assertThrows(UnsupportedOperationException.class, () -> transaction.setAmount(1L)); + assertThrows(UnsupportedOperationException.class, () -> transaction.setCurrencyCode(CurrencyUnit.USD)); + assertThrows(UnsupportedOperationException.class, () -> transaction.setSecurity(security())); + assertThrows(UnsupportedOperationException.class, () -> transaction.addUnit(new Unit(Unit.Type.FEE, money(1)))); + assertThrows(UnsupportedOperationException.class, transaction::clearUnits); + assertThrows(UnsupportedOperationException.class, () -> transaction.setType(AccountTransaction.Type.REMOVAL)); + assertThrows(UnsupportedOperationException.class, () -> transaction.getCrossEntry().insert()); + assertThrows(UnsupportedOperationException.class, () -> transaction.getCrossEntry().setSource("new source")); + } + + /** + * Checks the projection rebuild scenario: unsupported account projection messages carry stable projection codes. + * Account and portfolio lists must be derived from ledger truth. + * This protects support diagnostics from ambiguous projection materialization failures. + */ + @Test + public void testUnsupportedAccountProjectionMessageHasProjectionCode() + { + var entry = accountProjectionEntry(LedgerEntryType.DELIVERY_INBOUND); + var transaction = (AccountTransaction) LedgerProjectionService.createProjection(entry, + entry.getProjectionRefs().get(0)); + + var exception = assertThrows(UnsupportedOperationException.class, transaction::getType); + + assertThat(exception.getMessage(), is(LedgerDiagnosticCode.LEDGER_PROJ_066 + .message("Unsupported account projection for DELIVERY_INBOUND"))); + } + + /** + * Checks the projection rebuild scenario: unsupported portfolio projection messages carry stable projection codes. + * Account and portfolio lists must be derived from ledger truth. + * This protects support diagnostics from ambiguous projection materialization failures. + */ + @Test + public void testUnsupportedPortfolioProjectionMessageHasProjectionCode() + { + var entry = portfolioProjectionEntry(LedgerEntryType.DEPOSIT); + var transaction = (PortfolioTransaction) LedgerProjectionService.createProjection(entry, + entry.getProjectionRefs().get(0)); + + var exception = assertThrows(UnsupportedOperationException.class, transaction::getType); + + assertThat(exception.getMessage(), is(LedgerDiagnosticCode.LEDGER_PROJ_069 + .message("Unsupported portfolio projection for DEPOSIT"))); + } + + private LedgerTransactionCreator creator(Client client) + { + return new LedgerTransactionCreator(client); + } + + private LedgerTransactionMetadata metadata() + { + return LedgerTransactionMetadata.of(DATE_TIME).withNote("note").withSource("source"); + } + + private Account account() + { + return new Account(); + } + + private Portfolio portfolio() + { + return new Portfolio(); + } + + private Security security() + { + return new Security("Security", CurrencyUnit.EUR); + } + + private LedgerAccountCashLeg cashLeg(Account account, int amount) + { + return LedgerAccountCashLeg.of(account, money(amount)); + } + + private LedgerPortfolioSecurityLeg portfolioLeg(Portfolio portfolio, int amount) + { + return LedgerPortfolioSecurityLeg.of(portfolio, + LedgerSecurityQuantity.of(security(), Values.Share.factorize(5)), money(amount)); + } + + private Money money(int amount) + { + return Money.of(CurrencyUnit.EUR, Values.Amount.factorize(amount)); + } + + private LedgerEntry accountProjectionEntry(LedgerEntryType type) + { + var account = account(); + var entry = new LedgerEntry(); + var posting = new LedgerPosting("posting-1"); + var projectionRef = new LedgerProjectionRef("projection-1"); + + entry.setType(type); + posting.setType(LedgerPostingType.CASH); + posting.setAmount(Values.Amount.factorize(100)); + posting.setCurrency(CurrencyUnit.EUR); + posting.setAccount(account); + entry.addPosting(posting); + + projectionRef.setRole(LedgerProjectionRole.ACCOUNT); + projectionRef.setAccount(account); + projectionRef.addMembership(posting.getUUID(), ProjectionMembershipRole.PRIMARY); + entry.addProjectionRef(projectionRef); + + return entry; + } + + private LedgerEntry portfolioProjectionEntry(LedgerEntryType type) + { + var portfolio = portfolio(); + var entry = new LedgerEntry(); + var posting = new LedgerPosting("posting-1"); + var projectionRef = new LedgerProjectionRef("projection-1"); + + entry.setType(type); + posting.setType(LedgerPostingType.SECURITY); + posting.setAmount(Values.Amount.factorize(100)); + posting.setCurrency(CurrencyUnit.EUR); + posting.setSecurity(security()); + posting.setShares(Values.Share.factorize(5)); + posting.setPortfolio(portfolio); + entry.addPosting(posting); + + projectionRef.setRole(LedgerProjectionRole.PORTFOLIO); + projectionRef.setPortfolio(portfolio); + projectionRef.addMembership(posting.getUUID(), ProjectionMembershipRole.PRIMARY); + entry.addProjectionRef(projectionRef); + + return entry; + } +} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerProjectionWriteGuardrailTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerProjectionWriteGuardrailTest.java new file mode 100644 index 0000000000..5f77fe533c --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerProjectionWriteGuardrailTest.java @@ -0,0 +1,231 @@ +package name.abuchen.portfolio.model.ledger; + +import static name.abuchen.portfolio.model.ledger.LedgerGuardrailTestSupport.DATE_TIME; +import static name.abuchen.portfolio.model.ledger.LedgerGuardrailTestSupport.account; +import static name.abuchen.portfolio.model.ledger.LedgerGuardrailTestSupport.assertMetadataSetterUpdatesLedgerTruthOnly; +import static name.abuchen.portfolio.model.ledger.LedgerGuardrailTestSupport.assertRejectedWithoutChangingState; +import static name.abuchen.portfolio.model.ledger.LedgerGuardrailTestSupport.cashLeg; +import static name.abuchen.portfolio.model.ledger.LedgerGuardrailTestSupport.creator; +import static name.abuchen.portfolio.model.ledger.LedgerGuardrailTestSupport.deliveryLeg; +import static name.abuchen.portfolio.model.ledger.LedgerGuardrailTestSupport.metadata; +import static name.abuchen.portfolio.model.ledger.LedgerGuardrailTestSupport.money; +import static name.abuchen.portfolio.model.ledger.LedgerGuardrailTestSupport.portfolio; +import static name.abuchen.portfolio.model.ledger.LedgerGuardrailTestSupport.portfolioLeg; +import static name.abuchen.portfolio.model.ledger.LedgerGuardrailTestSupport.security; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; + +import java.time.Instant; + +import org.junit.Test; + +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.PortfolioTransaction; +import name.abuchen.portfolio.model.Transaction; +import name.abuchen.portfolio.model.Transaction.Unit; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerCashTransferLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerCreationUnit; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerCreationUnits; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerDividend; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerOptionalSecurity; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferSecurity; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionService; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.Values; + +/** + * Tests write guardrails on ledger-backed runtime projections. + * These tests make sure safe metadata edits update the ledger while structural setters remain blocked. + */ +@SuppressWarnings("nls") +public class LedgerProjectionWriteGuardrailTest +{ + /** + * Verifies that ledger-backed metadata setters update the ledger entry itself. + * Date, note, and source remain safe projection edits because they write the persisted truth. + */ + @Test + public void testLedgerBackedMetadataSettersUpdateLedgerTruth() + { + var client = new Client(); + var account = account(); + + creator(client).createDeposit(metadata(), cashLeg(account, 100)); + LedgerProjectionService.materialize(client); + + var transaction = account.getTransactions().get(0); + var ledgerBacked = (LedgerBackedTransaction) transaction; + + transaction.setDateTime(DATE_TIME.plusDays(2)); + transaction.setNote(""); + transaction.setSource(null); + transaction.setSource(""); + + assertThat(ledgerBacked.getLedgerEntry().getDateTime(), is(DATE_TIME.plusDays(2))); + assertThat(ledgerBacked.getLedgerEntry().getNote(), is("")); + assertThat(ledgerBacked.getLedgerEntry().getSource(), is("")); + assertThat(transaction.getDateTime(), is(DATE_TIME.plusDays(2))); + assertThat(transaction.getNote(), is("")); + assertThat(transaction.getSource(), is("")); + + transaction.setNote(null); + transaction.setSource(null); + + assertNull(ledgerBacked.getLedgerEntry().getNote()); + assertNull(ledgerBacked.getLedgerEntry().getSource()); + assertNull(transaction.getNote()); + assertNull(transaction.getSource()); + } + + /** + * Verifies that setting a null date is rejected without changing ledger truth. + * The projection and ledger entry must both keep the original date. + */ + @Test + public void testLedgerBackedSetDateTimeNullFailsWithoutMutatingLedgerTruth() + { + var client = new Client(); + var account = account(); + + creator(client).createDeposit(metadata(), cashLeg(account, 100)); + LedgerProjectionService.materialize(client); + + var transaction = account.getTransactions().get(0); + var ledgerBacked = (LedgerBackedTransaction) transaction; + + assertThrows(IllegalArgumentException.class, () -> transaction.setDateTime(null)); + + assertThat(ledgerBacked.getLedgerEntry().getDateTime(), is(DATE_TIME)); + assertThat(transaction.getDateTime(), is(DATE_TIME)); + } + + /** + * Verifies that structural setters stay blocked on ledger-backed projections. + * Amount, currency, security, unit, and type changes need explicit ledger editors. + */ + @Test + public void testLedgerBackedStructuralSettersRemainBlocked() + { + var client = new Client(); + var account = account(); + + creator(client).createDeposit(metadata(), cashLeg(account, 100)); + LedgerProjectionService.materialize(client); + + var transaction = account.getTransactions().get(0); + + assertThrows(UnsupportedOperationException.class, () -> transaction.setAmount(1L)); + assertThrows(UnsupportedOperationException.class, () -> transaction.setCurrencyCode(CurrencyUnit.USD)); + assertThrows(UnsupportedOperationException.class, () -> transaction.setSecurity(security())); + assertThrows(UnsupportedOperationException.class, () -> transaction.addUnit(new Unit(Unit.Type.FEE, money(1)))); + assertThrows(UnsupportedOperationException.class, transaction::clearUnits); + assertThrows(UnsupportedOperationException.class, () -> transaction.setType(AccountTransaction.Type.REMOVAL)); + } + + /** + * Verifies that account projection setters reject structural writes without partial mutation. + * Metadata writes remain safe, but business facts must not be changed through legacy setters. + */ + @Test + public void testLedgerBackedAccountProjectionSettersRejectWithoutPartialMutation() + { + var client = new Client(); + var source = account(); + var target = account(); + + creator(client).createDeposit(metadata(), cashLeg(source, 100)); + creator(client).createDividend(metadata(), LedgerDividend.withExDate(cashLeg(source, 20), + LedgerOptionalSecurity.of(security()), LedgerCreationUnits.of(LedgerCreationUnit.fee(money(1))), + DATE_TIME.minusDays(1))); + creator(client).createBuy(metadata(), cashLeg(source, 30), portfolioLeg(portfolio(), 30), + LedgerCreationUnits.of(LedgerCreationUnit.tax(money(2)))); + creator(client).createSell(metadata(), cashLeg(source, 35), portfolioLeg(portfolio(), 35), + LedgerCreationUnits.none()); + creator(client).createAccountTransfer(metadata(), LedgerCashTransferLeg.of(source, money(40)), + LedgerCashTransferLeg.of(target, money(40))); + LedgerProjectionService.materialize(client); + + var index = 0; + for (var transaction : source.getTransactions()) + { + assertMetadataSetterUpdatesLedgerTruthOnly(transaction, ++index); + assertAccountProjectionRejectsStructuralMutationWithoutChangingState(transaction); + } + + for (var transaction : target.getTransactions()) + { + assertMetadataSetterUpdatesLedgerTruthOnly(transaction, ++index); + assertAccountProjectionRejectsStructuralMutationWithoutChangingState(transaction); + } + } + + /** + * Verifies that portfolio projection setters reject structural writes without partial mutation. + * Metadata writes remain safe, but security and share facts must stay owned by the ledger. + */ + @Test + public void testLedgerBackedPortfolioProjectionSettersRejectWithoutPartialMutation() + { + var client = new Client(); + var source = portfolio(); + var target = portfolio(); + + creator(client).createInboundDelivery(metadata(), deliveryLeg(source)); + creator(client).createBuy(metadata(), cashLeg(account(), 30), portfolioLeg(source, 30), + LedgerCreationUnits.of(LedgerCreationUnit.tax(money(2)))); + creator(client).createSell(metadata(), cashLeg(account(), 35), portfolioLeg(source, 35), + LedgerCreationUnits.none()); + creator(client).createPortfolioTransfer(metadata(), + LedgerPortfolioTransferSecurity.of(security(), Values.Share.factorize(5)), + LedgerPortfolioTransferLeg.of(source, money(40)), + LedgerPortfolioTransferLeg.of(target, money(40))); + LedgerProjectionService.materialize(client); + + var index = 0; + for (var transaction : source.getTransactions()) + { + assertMetadataSetterUpdatesLedgerTruthOnly(transaction, ++index); + assertPortfolioProjectionRejectsStructuralMutationWithoutChangingState(transaction); + } + + for (var transaction : target.getTransactions()) + { + assertMetadataSetterUpdatesLedgerTruthOnly(transaction, ++index); + assertPortfolioProjectionRejectsStructuralMutationWithoutChangingState(transaction); + } + } + + private void assertAccountProjectionRejectsStructuralMutationWithoutChangingState(AccountTransaction transaction) + { + assertRejectedWithoutChangingState(transaction, t -> t.setType(AccountTransaction.Type.REMOVAL)); + assertRejectedWithoutChangingState(transaction, t -> t.setAmount(1L)); + assertRejectedWithoutChangingState(transaction, t -> t.setCurrencyCode(CurrencyUnit.USD)); + assertRejectedWithoutChangingState(transaction, t -> t.setMonetaryAmount(Money.of(CurrencyUnit.USD, 1L))); + assertRejectedWithoutChangingState(transaction, t -> t.setSecurity(security())); + assertRejectedWithoutChangingState(transaction, t -> t.setShares(1L)); + assertRejectedWithoutChangingState(transaction, t -> t.setUpdatedAt(Instant.EPOCH)); + assertRejectedWithoutChangingState(transaction, t -> t.addUnit(new Unit(Unit.Type.FEE, money(1)))); + assertRejectedWithoutChangingState(transaction, Transaction::clearUnits); + assertRejectedWithoutChangingState(transaction, t -> t.removeUnits(Unit.Type.FEE)); + } + + private void assertPortfolioProjectionRejectsStructuralMutationWithoutChangingState(PortfolioTransaction transaction) + { + assertRejectedWithoutChangingState(transaction, t -> t.setType(PortfolioTransaction.Type.DELIVERY_OUTBOUND)); + assertRejectedWithoutChangingState(transaction, t -> t.setAmount(1L)); + assertRejectedWithoutChangingState(transaction, t -> t.setCurrencyCode(CurrencyUnit.USD)); + assertRejectedWithoutChangingState(transaction, t -> t.setMonetaryAmount(Money.of(CurrencyUnit.USD, 1L))); + assertRejectedWithoutChangingState(transaction, t -> t.setSecurity(security())); + assertRejectedWithoutChangingState(transaction, t -> t.setShares(1L)); + assertRejectedWithoutChangingState(transaction, t -> t.setUpdatedAt(Instant.EPOCH)); + assertRejectedWithoutChangingState(transaction, t -> t.addUnit(new Unit(Unit.Type.FEE, money(1)))); + assertRejectedWithoutChangingState(transaction, Transaction::clearUnits); + assertRejectedWithoutChangingState(transaction, t -> t.removeUnits(Unit.Type.FEE)); + } +} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerRuntimeProjectionRestorerTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerRuntimeProjectionRestorerTest.java new file mode 100644 index 0000000000..8fcb43822d --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerRuntimeProjectionRestorerTest.java @@ -0,0 +1,638 @@ +package name.abuchen.portfolio.model.ledger.projection; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.instanceOf; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.function.Consumer; + +import org.junit.Test; + +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.InvestmentPlan; +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.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; +import name.abuchen.portfolio.model.ledger.LedgerTransactionMetadata; +import name.abuchen.portfolio.model.ledger.ProjectionMembershipRole; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountCashLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerCashTransferLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerCreationUnits; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioSecurityLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferSecurity; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerSecurityQuantity; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerTransactionCreator; +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.Money; +import name.abuchen.portfolio.money.Values; + +/** + * Tests rebuilding runtime transaction rows from ledger entries. + * These tests make sure account and portfolio views are derived from ledger truth without duplicate rows. + */ +@SuppressWarnings("nls") +public class LedgerRuntimeProjectionRestorerTest +{ + private record LogEvent(LedgerRuntimeProjectionRestorer.Severity severity, String message) + { + } + + private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 6, 17, 0, 0); + + /** + * Checks the projection rebuild scenario: missing owner list projection is restored with same projection uuid. + * Account and portfolio lists must be derived from the ledger entry. + * This protects Ledger-V6 from stale or duplicated runtime projections. + */ + @Test + public void testMissingOwnerListProjectionIsRestoredWithSameProjectionUUID() + { + var client = new Client(); + var account = register(client, account()); + var entry = creator(client).createDeposit(metadata(), cashLeg(account, 100)).getEntry(); + var projectionUUID = entry.getProjectionRefs().get(0).getUUID(); + + assertThat(entry.getProjectionRefs().get(0).getPrimaryMembership().orElseThrow().getPostingUUID(), + is(entry.getProjectionRefs().get(0).getPrimaryPostingUUID())); + assertTrue(account.getTransactions().isEmpty()); + + var result = new LedgerRuntimeProjectionRestorer().restoreIfValid(client); + + assertTrue(result.isOK()); + assertThat(account.getTransactions().size(), is(1)); + assertThat(account.getTransactions().get(0), instanceOf(LedgerBackedTransaction.class)); + assertThat(account.getTransactions().get(0).getUUID(), is(projectionUUID)); + } + + /** + * Checks the projection rebuild scenario: primary projection membership is preferred over scalar fallback. + * Account and portfolio lists must be derived from ledger membership targeting when present. + * This protects Ledger-V6 from loose scalar projection targeting. + */ + @Test + public void testPrimaryMembershipMaterializesAccountProjection() + { + var client = new Client(); + var account = register(client, account()); + var entry = creator(client).createDeposit(metadata(), cashLeg(account, 100)).getEntry(); + var projectionRef = entry.getProjectionRefs().get(0); + var alternatePosting = cashPosting("membership-primary-posting", account, 200); + + entry.addPosting(alternatePosting); + projectionRef.setPrimaryPostingTargetUUID(null); + projectionRef.addMembership(alternatePosting.getUUID(), ProjectionMembershipRole.PRIMARY); + + var result = new LedgerRuntimeProjectionRestorer().restoreIfValid(client); + + assertTrue(result.isOK()); + assertThat(account.getTransactions().size(), is(1)); + assertThat(account.getTransactions().get(0).getAmount(), is(Values.Amount.factorize(200))); + } + + /** + * Checks the projection rebuild scenario: group anchor membership drives native targeted units. + * Unit projections must be derived from membership targeting before scalar fallback. + * This protects Ledger-V6 from relying on loose postingGroupUUID targeting. + */ + @Test + public void testGroupAnchorMembershipMaterializesNativeTargetedUnits() + { + var client = new Client(); + var account = register(client, account()); + var entry = new LedgerEntry("entry-1"); + var compensation = cashPosting("cash-compensation", account, 5); + var fee = unitPosting("fee-posting", LedgerPostingType.FEE, account, 2); + var tax = unitPosting("tax-posting", LedgerPostingType.TAX, account, 1); + var projectionRef = new LedgerProjectionRef("projection-1"); + + entry.setType(LedgerEntryType.SPIN_OFF); + entry.setDateTime(DATE_TIME); + compensation.setType(LedgerPostingType.CASH_COMPENSATION); + entry.addPosting(compensation); + entry.addPosting(fee); + entry.addPosting(tax); + projectionRef.setRole(LedgerProjectionRole.CASH_COMPENSATION); + projectionRef.setAccount(account); + projectionRef.addMembership(compensation.getUUID(), ProjectionMembershipRole.PRIMARY); + projectionRef.addMembership(compensation.getUUID(), ProjectionMembershipRole.GROUP_ANCHOR); + entry.addProjectionRef(projectionRef); + client.getLedger().addEntry(entry); + + var result = new LedgerRuntimeProjectionRestorer().restoreIfValid(client); + + assertTrue(result.isOK()); + assertThat(account.getTransactions().size(), is(1)); + assertThat(account.getTransactions().get(0).getUnits().map(Transaction.Unit::getType).toList(), + is(List.of(Transaction.Unit.Type.FEE, Transaction.Unit.Type.TAX))); + } + + /** + * Checks the projection rebuild scenario: missing owner list projection does not log info. + * Account and portfolio lists must be derived from the ledger entry. + * This protects Ledger-V6 from stale or duplicated runtime projections. + */ + @Test + public void testMissingOwnerListProjectionDoesNotLogInfo() + { + var client = new Client(); + var account = register(client, account()); + + creator(client).createDeposit(metadata(), cashLeg(account, 100)); + + var statuses = ledgerLogStatuses(client); + + assertTrue(statuses.isEmpty()); + } + + /** + * Checks the projection rebuild scenario: duplicate ledger backed projection is removed and rematerialized once. + * Account and portfolio lists must be derived from the ledger entry. + * This protects Ledger-V6 from stale or duplicated runtime projections. + */ + @Test + public void testDuplicateLedgerBackedProjectionIsRemovedAndRematerializedOnce() + { + var client = new Client(); + var account = register(client, account()); + var entry = creator(client).createDeposit(metadata(), cashLeg(account, 100)).getEntry(); + var projectionRef = entry.getProjectionRefs().get(0); + + new LedgerProjectionMaterializer().materialize(client); + account.getTransactions().add((AccountTransaction) new LedgerProjectionFactory().createProjection(entry, + projectionRef)); + + assertThat(account.getTransactions().size(), is(2)); + + var result = new LedgerRuntimeProjectionRestorer().restoreIfValid(client); + + assertTrue(result.isOK()); + assertThat(account.getTransactions().size(), is(1)); + assertThat(account.getTransactions().get(0).getUUID(), is(projectionRef.getUUID())); + } + + /** + * Checks the projection rebuild scenario: duplicate ledger backed projection logs info. + * Account and portfolio lists must be derived from the ledger entry. + * This protects Ledger-V6 from stale or duplicated runtime projections. + */ + @Test + public void testDuplicateLedgerBackedProjectionLogsInfo() + { + var client = new Client(); + var account = register(client, account()); + var entry = creator(client).createDeposit(metadata(), cashLeg(account, 100)).getEntry(); + var projectionRef = entry.getProjectionRefs().get(0); + + new LedgerProjectionMaterializer().materialize(client); + account.getTransactions().add((AccountTransaction) new LedgerProjectionFactory().createProjection(entry, + projectionRef)); + + var statuses = ledgerLogStatuses(client); + + assertThat(statuses.size(), is(1)); + assertThat(statuses.get(0).severity(), is(LedgerRuntimeProjectionRestorer.Severity.INFO)); + assertThat(statuses.get(0).message(), is(LedgerRuntimeProjectionRestorer.restoredLedgerMessage(2, 1, 1, + Set.of(), Set.of(projectionRef.getUUID()), Set.of()))); + } + + /** + * Checks the projection rebuild scenario: stale ledger backed projection without ledger projection ref is removed. + * Account and portfolio lists must be derived from the ledger entry. + * This protects Ledger-V6 from stale or duplicated runtime projections. + */ + @Test + public void testStaleLedgerBackedProjectionWithoutLedgerProjectionRefIsRemoved() + { + var client = new Client(); + var account = register(client, account()); + + account.setName("Restored Account"); + var entry = creator(client).createDeposit(metadata(), cashLeg(account, 100)).getEntry(); + + new LedgerProjectionMaterializer().materialize(client); + client.getLedger().removeEntry(entry); + + var result = new LedgerRuntimeProjectionRestorer().restoreIfValid(client); + + assertTrue(result.isOK()); + assertTrue(account.getTransactions().isEmpty()); + } + + /** + * Checks the projection rebuild scenario: stale ledger backed projection logs info. + * Account and portfolio lists must be derived from the ledger entry. + * This protects Ledger-V6 from stale or duplicated runtime projections. + */ + @Test + public void testStaleLedgerBackedProjectionLogsInfo() + { + var client = new Client(); + var account = register(client, account()); + var entry = creator(client).createDeposit(metadata(), cashLeg(account, 100)).getEntry(); + var projectionUUID = entry.getProjectionRefs().get(0).getUUID(); + + new LedgerProjectionMaterializer().materialize(client); + client.getLedger().removeEntry(entry); + + var statuses = ledgerLogStatuses(client); + + assertThat(statuses.size(), is(1)); + assertThat(statuses.get(0).severity(), is(LedgerRuntimeProjectionRestorer.Severity.INFO)); + assertThat(statuses.get(0).message(), is(LedgerRuntimeProjectionRestorer.restoredLedgerMessage(1, 0, 1, + Set.of(), Set.of(), Set.of(projectionUUID)))); + } + + /** + * Checks the projection rebuild scenario: valid no op restoration does not log. + * Account and portfolio lists must be derived from the ledger entry. + * This protects Ledger-V6 from stale or duplicated runtime projections. + */ + @Test + public void testValidNoOpRestorationDoesNotLog() + { + var client = new Client(); + var account = register(client, account()); + + creator(client).createDeposit(metadata(), cashLeg(account, 100)); + new LedgerProjectionMaterializer().materialize(client); + + var statuses = ledgerLogStatuses(client); + + assertTrue(statuses.isEmpty()); + } + + /** + * Checks the projection rebuild scenario: non ledger transactions remain untouched. + * Account and portfolio lists must be derived from the ledger entry. + * This protects Ledger-V6 from stale or duplicated runtime projections. + */ + @Test + public void testNonLedgerTransactionsRemainUntouched() + { + var client = new Client(); + var account = register(client, account()); + var legacy = legacyDeposit(); + + account.addTransaction(legacy); + creator(client).createDeposit(metadata(), cashLeg(account, 100)); + + var result = new LedgerRuntimeProjectionRestorer().restoreIfValid(client); + + assertTrue(result.isOK()); + assertThat(account.getTransactions().size(), is(2)); + assertSame(legacy, account.getTransactions().get(0)); + assertThat(account.getTransactions().get(1), instanceOf(LedgerBackedTransaction.class)); + } + + /** + * Checks the projection rebuild scenario: buy/sell cross entry is rematerialized. + * Account and portfolio lists must be derived from the ledger entry. + * This protects Ledger-V6 from stale or duplicated runtime projections. + */ + @Test + public void testBuySellCrossEntryIsRematerialized() + { + var client = new Client(); + var account = register(client, account()); + var portfolio = register(client, portfolio()); + + creator(client).createBuy(metadata(), cashLeg(account, 100), portfolioLeg(portfolio, 100), + LedgerCreationUnits.none()); + + var result = new LedgerRuntimeProjectionRestorer().restoreIfValid(client); + + assertTrue(result.isOK()); + + var accountTransaction = account.getTransactions().get(0); + var portfolioTransaction = portfolio.getTransactions().get(0); + + assertThat(accountTransaction.getType(), is(AccountTransaction.Type.BUY)); + assertThat(portfolioTransaction.getType(), is(PortfolioTransaction.Type.BUY)); + assertSame(accountTransaction.getCrossEntry(), portfolioTransaction.getCrossEntry()); + assertSame(portfolioTransaction, accountTransaction.getCrossEntry().getCrossTransaction(accountTransaction)); + assertSame(portfolio, accountTransaction.getCrossEntry().getCrossOwner(accountTransaction)); + } + + /** + * Checks the projection rebuild scenario: account transfer cross entry is rematerialized. + * Account and portfolio lists must be derived from the ledger entry. + * This protects Ledger-V6 from stale or duplicated runtime projections. + */ + @Test + public void testAccountTransferCrossEntryIsRematerialized() + { + var client = new Client(); + var source = register(client, account()); + var target = register(client, account()); + + creator(client).createAccountTransfer(metadata(), LedgerCashTransferLeg.of(source, money(100)), + LedgerCashTransferLeg.of(target, money(100))); + + var result = new LedgerRuntimeProjectionRestorer().restoreIfValid(client); + + assertTrue(result.isOK()); + + var sourceTransaction = source.getTransactions().get(0); + var targetTransaction = target.getTransactions().get(0); + + assertThat(sourceTransaction.getType(), is(AccountTransaction.Type.TRANSFER_OUT)); + assertThat(targetTransaction.getType(), is(AccountTransaction.Type.TRANSFER_IN)); + assertSame(targetTransaction, sourceTransaction.getCrossEntry().getCrossTransaction(sourceTransaction)); + assertSame(target, sourceTransaction.getCrossEntry().getCrossOwner(sourceTransaction)); + } + + /** + * Checks the projection rebuild scenario: portfolio transfer cross entry is rematerialized. + * Account and portfolio lists must be derived from the ledger entry. + * This protects Ledger-V6 from stale or duplicated runtime projections. + */ + @Test + public void testPortfolioTransferCrossEntryIsRematerialized() + { + var client = new Client(); + var source = register(client, portfolio()); + var target = register(client, portfolio()); + + creator(client).createPortfolioTransfer(metadata(), + LedgerPortfolioTransferSecurity.of(security(), Values.Share.factorize(5)), + LedgerPortfolioTransferLeg.of(source, money(100)), + LedgerPortfolioTransferLeg.of(target, money(100))); + + var result = new LedgerRuntimeProjectionRestorer().restoreIfValid(client); + + assertTrue(result.isOK()); + + var sourceTransaction = source.getTransactions().get(0); + var targetTransaction = target.getTransactions().get(0); + + assertThat(sourceTransaction.getType(), is(PortfolioTransaction.Type.TRANSFER_OUT)); + assertThat(targetTransaction.getType(), is(PortfolioTransaction.Type.TRANSFER_IN)); + assertSame(targetTransaction, sourceTransaction.getCrossEntry().getCrossTransaction(sourceTransaction)); + assertSame(target, sourceTransaction.getCrossEntry().getCrossOwner(sourceTransaction)); + } + + /** + * Checks the projection rebuild scenario: investment plan ledger execution refs remain unchanged and resolvable. + * Account and portfolio lists must be derived from the ledger entry. + * This protects Ledger-V6 from stale or duplicated runtime projections. + */ + @Test + public void testInvestmentPlanLedgerExecutionRefsRemainUnchangedAndResolvable() + { + var client = new Client(); + var account = register(client, account()); + var portfolio = register(client, portfolio()); + var plan = new InvestmentPlan("plan"); + + client.addPlan(plan); + creator(client).createBuy(metadata(), cashLeg(account, 100), portfolioLeg(portfolio, 100), + LedgerCreationUnits.none()); + new LedgerProjectionMaterializer().materialize(client); + + var portfolioProjection = portfolio.getTransactions().get(0); + var ref = InvestmentPlan.LedgerExecutionRef.of((LedgerBackedTransaction) portfolioProjection); + + plan.addLedgerExecutionRef(ref); + account.getTransactions().clear(); + portfolio.getTransactions().clear(); + + var result = new LedgerRuntimeProjectionRestorer().restoreIfValid(client); + + assertTrue(result.isOK()); + assertThat(plan.getLedgerExecutionRefs(), is(List.of(ref))); + assertThat(plan.getTransactions(client).size(), is(1)); + assertThat(plan.getTransactions(client).get(0).getOwner(), is(portfolio)); + assertThat(plan.getTransactions(client).get(0).getTransaction().getUUID(), + is(portfolioProjection.getUUID())); + } + + /** + * Checks the projection rebuild scenario: invalid ledger is not repaired and is not materialized. + * Account and portfolio lists must be derived from valid ledger entries only. + * This protects Ledger-V6 from silently changing persisted ledger truth. + */ + @Test + public void testInvalidLedgerIsNotRepairedAndInvalidEntryIsNotMaterialized() + { + var client = new Client(); + var account = register(client, account()); + + account.setName("Restored Account"); + var entry = creator(client).createDeposit(metadata(), cashLeg(account, 100)).getEntry(); + + new LedgerProjectionMaterializer().materialize(client); + + entry.getPostings().get(0).setCurrency(null); + var postingUUID = entry.getPostings().get(0).getUUID(); + var projectionUUID = entry.getProjectionRefs().get(0).getUUID(); + + final LedgerStructuralValidator.ValidationResult[] result = new LedgerStructuralValidator.ValidationResult[1]; + var statuses = ledgerLogStatuses(client, value -> result[0] = value); + + assertFalse(result[0].isOK()); + assertTrue(account.getTransactions().isEmpty()); + assertThat(entry.getPostings().get(0).getUUID(), is(postingUUID)); + assertThat(entry.getPostings().get(0).getCurrency(), is((String) null)); + assertThat(entry.getProjectionRefs().get(0).getUUID(), is(projectionUUID)); + assertTrue(result[0].hasIssue(LedgerStructuralValidator.IssueCode.POSTING_CURRENCY_REQUIRED)); + assertThat(statuses.size(), is(1)); + assertThat(statuses.get(0).severity(), is(LedgerRuntimeProjectionRestorer.Severity.WARNING)); + assertThat(statuses.get(0).message(), containsString("[POSTING_CURRENCY_REQUIRED] ")); + assertThat(statuses.get(0).message(), containsString("\n Entry:\n")); + assertThat(statuses.get(0).message(), containsString("\n Posting:\n")); + assertThat(statuses.get(0).message(), containsString("UUID: " + postingUUID)); + assertThat(statuses.get(0).message(), containsString("Type: CASH")); + assertThat(statuses.get(0).message(), + containsString(Messages.LedgerDiagnosticMessageFormatterTransactionContext + ":\n")); + assertThat(statuses.get(0).message(), + containsString(Messages.LedgerDiagnosticMessageFormatterDate + ": 2026-06-17T00:00")); + assertThat(statuses.get(0).message(), + containsString(Messages.LedgerDiagnosticMessageFormatterAccount + ": Restored Account")); + assertThat(statuses.get(0).message(), + containsString(Messages.LedgerDiagnosticMessageFormatterSource + ": source")); + } + + /** + * Checks the projection rebuild scenario: valid entries are restored when a sibling entry is invalid. + * Local Ledger defects must not hide otherwise usable runtime projections. + */ + @Test + public void testInvalidEntryIsSkippedAndValidEntryIsMaterialized() + { + var client = new Client(); + var invalidAccount = register(client, account()); + var validAccount = register(client, account()); + var invalidEntry = creator(client).createDeposit(metadata(), cashLeg(invalidAccount, 100)).getEntry(); + var validEntry = creator(client).createDeposit(metadata(), cashLeg(validAccount, 200)).getEntry(); + var invalidProjectionUUID = invalidEntry.getProjectionRefs().get(0).getUUID(); + var validProjectionUUID = validEntry.getProjectionRefs().get(0).getUUID(); + + invalidEntry.getProjectionRefs().get(0).setPrimaryPostingUUID("missing-primary-posting"); + + var result = new LedgerRuntimeProjectionRestorer().restoreIfValid(client); + + assertFalse(result.isOK()); + assertTrue(result.hasIssue(LedgerStructuralValidator.IssueCode.PRIMARY_POSTING_REF_NOT_FOUND)); + assertThat(client.getLedger().getEntries(), is(List.of(invalidEntry, validEntry))); + assertTrue(invalidAccount.getTransactions().isEmpty()); + assertThat(validAccount.getTransactions().size(), is(1)); + assertThat(validAccount.getTransactions().get(0).getUUID(), is(validProjectionUUID)); + assertThat(invalidEntry.getProjectionRefs().get(0).getUUID(), is(invalidProjectionUUID)); + assertThat(invalidEntry.getProjectionRefs().get(0).getPrimaryPostingUUID(), is("missing-primary-posting")); + } + + /** + * Checks the projection rebuild scenario: a missing primary posting ref is diagnosed and skipped. + * The persisted Ledger entry must remain available for later repair/delete workflows. + */ + @Test + public void testMissingPrimaryPostingRefIsDiagnosedAndSkipped() + { + var client = new Client(); + var account = register(client, account()); + var entry = creator(client).createDeposit(metadata(), cashLeg(account, 100)).getEntry(); + var projectionUUID = entry.getProjectionRefs().get(0).getUUID(); + + entry.getProjectionRefs().get(0).setPrimaryPostingUUID("missing-primary-posting"); + + var result = new LedgerRuntimeProjectionRestorer().restoreIfValid(client); + + assertFalse(result.isOK()); + assertTrue(result.hasIssue(LedgerStructuralValidator.IssueCode.PRIMARY_POSTING_REF_NOT_FOUND)); + assertThat(client.getLedger().getEntries(), is(List.of(entry))); + assertTrue(account.getTransactions().isEmpty()); + assertThat(entry.getProjectionRefs().get(0).getUUID(), is(projectionUUID)); + assertThat(entry.getProjectionRefs().get(0).getPrimaryPostingUUID(), is("missing-primary-posting")); + } + + private List ledgerLogStatuses(Client client) + { + var statuses = new ArrayList(); + var restorer = new LedgerRuntimeProjectionRestorer(new LedgerProjectionMaterializer(), + (severity, message) -> statuses.add(new LogEvent(severity, message))); + + restorer.restoreIfValid(client); + return statuses; + } + + private List ledgerLogStatuses(Client client, + Consumer resultConsumer) + { + var statuses = new ArrayList(); + var restorer = new LedgerRuntimeProjectionRestorer(new LedgerProjectionMaterializer(), + (severity, message) -> statuses.add(new LogEvent(severity, message))); + + resultConsumer.accept(restorer.restoreIfValid(client)); + return statuses; + } + + private LedgerTransactionCreator creator(Client client) + { + return new LedgerTransactionCreator(client); + } + + private LedgerTransactionMetadata metadata() + { + return LedgerTransactionMetadata.of(DATE_TIME).withNote("note").withSource("source"); + } + + private Account register(Client client, Account account) + { + client.addAccount(account); + return account; + } + + private Portfolio register(Client client, Portfolio portfolio) + { + client.addPortfolio(portfolio); + return portfolio; + } + + private Account account() + { + var account = new Account(); + + account.setCurrencyCode(CurrencyUnit.EUR); + + return account; + } + + private Portfolio portfolio() + { + return new Portfolio(); + } + + private Security security() + { + return new Security("Security", CurrencyUnit.EUR); + } + + private LedgerAccountCashLeg cashLeg(Account account, int amount) + { + return LedgerAccountCashLeg.of(account, money(amount)); + } + + private LedgerPortfolioSecurityLeg portfolioLeg(Portfolio portfolio, int amount) + { + return LedgerPortfolioSecurityLeg.of(portfolio, + LedgerSecurityQuantity.of(security(), Values.Share.factorize(5)), money(amount)); + } + + private Money money(int amount) + { + return Money.of(CurrencyUnit.EUR, Values.Amount.factorize(amount)); + } + + private LedgerPosting cashPosting(String uuid, Account account, int amount) + { + var posting = new LedgerPosting(uuid); + + posting.setType(LedgerPostingType.CASH); + posting.setAccount(account); + posting.setAmount(Values.Amount.factorize(amount)); + posting.setCurrency(CurrencyUnit.EUR); + + return posting; + } + + private LedgerPosting unitPosting(String uuid, LedgerPostingType type, Account account, int amount) + { + var posting = cashPosting(uuid, account, amount); + + posting.setType(type); + + return posting; + } + + private AccountTransaction legacyDeposit() + { + var transaction = new AccountTransaction(); + + transaction.setType(AccountTransaction.Type.DEPOSIT); + transaction.setDateTime(DATE_TIME); + transaction.setCurrencyCode(CurrencyUnit.EUR); + transaction.setAmount(Values.Amount.factorize(1)); + + return transaction; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerProjectionRef.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerProjectionRef.java new file mode 100644 index 0000000000..62b2b73315 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerProjectionRef.java @@ -0,0 +1,179 @@ +package name.abuchen.portfolio.model.ledger; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.Portfolio; + +/** + * Links a persisted Ledger entry to a runtime legacy transaction projection. + * This is internal Ledger model data. Projection references identify views; they are not a + * second persisted transaction truth. + */ +public class LedgerProjectionRef +{ + private String uuid; + private LedgerProjectionRole role; + private Account account; + private Portfolio portfolio; + private String primaryPostingUUID; + private String postingGroupUUID; + private List memberships = new ArrayList<>(); + + public LedgerProjectionRef() + { + this(UUID.randomUUID().toString()); + } + + public LedgerProjectionRef(String uuid) + { + this.uuid = Objects.requireNonNull(uuid); + } + + public String getUUID() + { + return uuid; + } + + public void setUUID(String uuid) + { + this.uuid = Objects.requireNonNull(uuid); + } + + public LedgerProjectionRole getRole() + { + return role; + } + + public void setRole(LedgerProjectionRole role) + { + this.role = role; + } + + 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 String getPrimaryPostingUUID() + { + return primaryPostingUUID; + } + + public void setPrimaryPostingUUID(String primaryPostingUUID) + { + this.primaryPostingUUID = primaryPostingUUID; + } + + public void setPrimaryPostingTargetUUID(String primaryPostingUUID) + { + setPrimaryPostingUUID(primaryPostingUUID); + setMembership(ProjectionMembershipRole.PRIMARY, primaryPostingUUID); + } + + public void setPrimaryPosting(LedgerPosting posting) + { + setPrimaryPostingTargetUUID(Objects.requireNonNull(posting).getUUID()); + } + + public String getPostingGroupUUID() + { + return postingGroupUUID; + } + + public void setPostingGroupUUID(String postingGroupUUID) + { + this.postingGroupUUID = postingGroupUUID; + } + + public void setPostingGroupTargetUUID(String postingGroupUUID) + { + setPostingGroupUUID(postingGroupUUID); + setMembership(ProjectionMembershipRole.GROUP_ANCHOR, postingGroupUUID); + } + + public void setPostingGroup(LedgerPosting posting) + { + setPostingGroupTargetUUID(Objects.requireNonNull(posting).getUUID()); + } + + public List getMemberships() + { + return Collections.unmodifiableList(memberships()); + } + + public void addMembership(ProjectionMembership membership) + { + memberships().add(Objects.requireNonNull(membership)); + } + + public ProjectionMembership addMembership(String postingUUID, ProjectionMembershipRole role) + { + var membership = new ProjectionMembership(postingUUID, role); + + addMembership(membership); + + return membership; + } + + public Optional getPrimaryMembership() + { + return memberships().stream().filter(membership -> membership.getRole() == ProjectionMembershipRole.PRIMARY) + .findFirst(); + } + + public List getMembershipsByRole(ProjectionMembershipRole role) + { + Objects.requireNonNull(role); + return memberships().stream().filter(membership -> membership.getRole() == role).toList(); + } + + public boolean hasMembershipRole(ProjectionMembershipRole role) + { + Objects.requireNonNull(role); + return memberships().stream().anyMatch(membership -> membership.getRole() == role); + } + + public void removeMembershipsForPostingUUID(String postingUUID) + { + memberships().removeIf(membership -> Objects.equals(membership.getPostingUUID(), postingUUID)); + } + + private List memberships() + { + if (memberships == null) + memberships = new ArrayList<>(); + + return memberships; + } + + private void setMembership(ProjectionMembershipRole role, String postingUUID) + { + Objects.requireNonNull(role); + + memberships().removeIf(membership -> membership.getRole() == role); + + if (postingUUID != null) + addMembership(postingUUID, role); + } +} 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/ProjectionMembership.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/ProjectionMembership.java new file mode 100644 index 0000000000..dbeeb96f68 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/ProjectionMembership.java @@ -0,0 +1,38 @@ +package name.abuchen.portfolio.model.ledger; + +import java.util.Objects; + +/** + * Links one Ledger posting to a projection with an explicit projection-local role. + */ +public class ProjectionMembership +{ + private String postingUUID; + private ProjectionMembershipRole role; + + public ProjectionMembership(String postingUUID, ProjectionMembershipRole role) + { + this.postingUUID = Objects.requireNonNull(postingUUID); + this.role = Objects.requireNonNull(role); + } + + public String getPostingUUID() + { + return postingUUID; + } + + public void setPostingUUID(String postingUUID) + { + this.postingUUID = Objects.requireNonNull(postingUUID); + } + + public ProjectionMembershipRole getRole() + { + return role; + } + + public void setRole(ProjectionMembershipRole role) + { + this.role = Objects.requireNonNull(role); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/ProjectionMembershipRole.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/ProjectionMembershipRole.java new file mode 100644 index 0000000000..b1331d508d --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/ProjectionMembershipRole.java @@ -0,0 +1,16 @@ +package name.abuchen.portfolio.model.ledger; + +/** + * Defines how a posting participates in a compatibility projection. + * Projection memberships are owned by {@link LedgerProjectionRef}; they do not move + * compatibility identity or owner routing into {@link LedgerPosting}. + */ +public enum ProjectionMembershipRole +{ + PRIMARY, + GROUP_ANCHOR, + FEE_UNIT, + TAX_UNIT, + GROSS_VALUE_UNIT, + FOREX_CONTEXT +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedAccountTransaction.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedAccountTransaction.java new file mode 100644 index 0000000000..2684239b08 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedAccountTransaction.java @@ -0,0 +1,308 @@ +package name.abuchen.portfolio.model.ledger.projection; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.stream.Stream; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.CrossEntry; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerEntryMetadataPatchHelper; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.money.CurrencyConverter; +import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.MoneyCollectors; +import name.abuchen.portfolio.money.Values; + +/** + * Represents a runtime legacy transaction view backed by a Ledger entry. + * This class belongs to projection support. It must not be treated as persisted transaction + * truth or mutated through legacy setters. + */ +public final class LedgerBackedAccountTransaction extends AccountTransaction implements LedgerBackedTransaction +{ + private final LedgerEntry entry; + private final LedgerProjectionRef projectionRef; + private final LedgerPosting primaryPosting; + private CrossEntry crossEntry; + + LedgerBackedAccountTransaction(LedgerEntry entry, LedgerProjectionRef projectionRef) + { + this.entry = entry; + this.projectionRef = projectionRef; + this.primaryPosting = LedgerProjectionSupport.primaryPosting(entry, projectionRef); + } + + @Override + public LedgerEntry getLedgerEntry() + { + return entry; + } + + @Override + public LedgerProjectionRef getLedgerProjectionRef() + { + return projectionRef; + } + + void setLedgerCrossEntry(CrossEntry crossEntry) + { + this.crossEntry = crossEntry; + } + + @Override + public String getUUID() + { + return projectionRef.getUUID(); + } + + @Override + public Type getType() + { + if (entry.getType().isLedgerNativeTargeted()) + return LedgerProjectionSupport.targetedAccountType(projectionRef); + + return switch (entry.getType()) + { + case DEPOSIT -> Type.DEPOSIT; + case REMOVAL -> Type.REMOVAL; + case INTEREST -> Type.INTEREST; + case INTEREST_CHARGE -> Type.INTEREST_CHARGE; + case FEES -> Type.FEES; + case FEES_REFUND -> Type.FEES_REFUND; + case TAXES -> Type.TAXES; + case TAX_REFUND -> Type.TAX_REFUND; + case DIVIDENDS -> Type.DIVIDENDS; + case BUY -> Type.BUY; + case SELL -> Type.SELL; + case CASH_TRANSFER -> transferType(); + default -> throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PROJ_066 + .message("Unsupported account projection for " + entry.getType())); //$NON-NLS-1$ + }; + } + + @Override + public LocalDateTime getDateTime() + { + return entry.getDateTime(); + } + + @Override + public String getNote() + { + return entry.getNote(); + } + + @Override + public String getSource() + { + return entry.getSource(); + } + + @Override + public Instant getUpdatedAt() + { + return entry.getUpdatedAt(); + } + + @Override + public long getAmount() + { + return primaryPosting.getAmount(); + } + + @Override + public String getCurrencyCode() + { + return primaryPosting.getCurrency(); + } + + @Override + public Money getMonetaryAmount() + { + return Money.of(getCurrencyCode(), getAmount()); + } + + @Override + public Security getSecurity() + { + return primaryPosting.getSecurity(); + } + + @Override + public long getShares() + { + return primaryPosting.getShares(); + } + + @Override + public LocalDateTime getExDate() + { + return LedgerProjectionSupport.exDate(primaryPosting).orElse(null); + } + + @Override + public CrossEntry getCrossEntry() + { + return crossEntry; + } + + @Override + public Stream getUnits() + { + return LedgerProjectionSupport.units(entry, projectionRef, primaryPosting); + } + + @Override + public long getGrossValueAmount() + { + long taxAndFees = getUnitSum(Unit.Type.FEE, Unit.Type.TAX).getAmount(); + return getAmount() + (getType().isCredit() ? taxAndFees : -taxAndFees); + } + + @Override + public Money getUnitSum(Unit.Type type, CurrencyConverter converter) + { + return getUnits().filter(u -> u.getType() == type) + .collect(MoneyCollectors.sum(converter.getTermCurrency(), unit -> { + if (converter.getTermCurrency().equals(unit.getAmount().getCurrencyCode())) + return unit.getAmount(); + else if (unit.getForex() != null + && converter.getTermCurrency().equals(unit.getForex().getCurrencyCode())) + return unit.getForex(); + else + return unit.getAmount().with(converter.at(getDateTime())); + })); + } + + @Override + public String toString() + { + return String.format("%s %-17s %s %9s %s %s", //$NON-NLS-1$ + Values.Date.format(getDateTime().toLocalDate()), // + getType().name(), // + getCurrencyCode(), // + Values.Amount.format(getAmount()), // + getSecurity() != null ? getSecurity().getName() : "", //$NON-NLS-1$ + getCrossEntry() != null && getCrossEntry().getCrossOwner(this) != null + ? getCrossEntry().getCrossOwner(this).toString() + : "" //$NON-NLS-1$ + ); + } + + @Override + public void setType(Type type) + { + throw LedgerProjectionSupport.unsupportedMutation(); + } + + @Override + public void setDateTime(LocalDateTime date) + { + LedgerEntryMetadataPatchHelper.setDateTime(entry, date); + } + + @Override + public void setNote(String note) + { + LedgerEntryMetadataPatchHelper.setNote(entry, note); + } + + @Override + public void setSource(String source) + { + LedgerEntryMetadataPatchHelper.setSource(entry, source); + } + + @Override + public void setUpdatedAt(Instant updatedAt) + { + throw LedgerProjectionSupport.unsupportedMutation(); + } + + @Override + public void setAmount(long amount) + { + throw LedgerProjectionSupport.unsupportedMutation(); + } + + @Override + public void setCurrencyCode(String currencyCode) + { + throw LedgerProjectionSupport.unsupportedMutation(); + } + + @Override + public void setMonetaryAmount(Money value) + { + throw LedgerProjectionSupport.unsupportedMutation(); + } + + @Override + public void setSecurity(Security security) + { + throw LedgerProjectionSupport.unsupportedMutation(); + } + + @Override + public void setShares(long shares) + { + throw LedgerProjectionSupport.unsupportedMutation(); + } + + @Override + public void setExDate(LocalDateTime exDate) + { + throw LedgerProjectionSupport.unsupportedMutation(); + } + + @Override + public void clearUnits() + { + throw LedgerProjectionSupport.unsupportedMutation(); + } + + @Override + public void addUnit(Unit unit) + { + throw LedgerProjectionSupport.unsupportedMutation(); + } + + @Override + public void addUnits(Stream items) + { + throw LedgerProjectionSupport.unsupportedMutation(); + } + + @Override + public void removeUnit(Unit unit) + { + throw LedgerProjectionSupport.unsupportedMutation(); + } + + @Override + public void removeUnits(Unit.Type type) + { + throw LedgerProjectionSupport.unsupportedMutation(); + } + + @Override + protected void setCrossEntry(CrossEntry crossEntry) + { + throw LedgerProjectionSupport.unsupportedMutation(); + } + + private Type transferType() + { + return switch (projectionRef.getRole()) + { + case SOURCE_ACCOUNT -> Type.TRANSFER_OUT; + case TARGET_ACCOUNT -> Type.TRANSFER_IN; + default -> throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PROJ_067 + .message("Unsupported cash transfer role " + projectionRef.getRole())); //$NON-NLS-1$ + }; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedCrossEntry.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedCrossEntry.java new file mode 100644 index 0000000000..dfe86bc3de --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedCrossEntry.java @@ -0,0 +1,105 @@ +package name.abuchen.portfolio.model.ledger.projection; + +import java.util.List; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.CrossEntry; +import name.abuchen.portfolio.model.Transaction; +import name.abuchen.portfolio.model.TransactionOwner; +import name.abuchen.portfolio.model.ledger.LedgerEntry; + +/** + * Represents a runtime legacy cross-entry view backed by a Ledger entry. + * This class belongs to projection support. Cross-entry views are compatibility objects and + * must not become a second persisted transaction truth. + */ +final class LedgerBackedCrossEntry implements CrossEntry +{ + private final LedgerEntry entry; + private final List transactions; + + LedgerBackedCrossEntry(LedgerEntry entry, List transactions) + { + this.entry = entry; + this.transactions = List.copyOf(transactions); + } + + @Override + public void updateFrom(Transaction t) + { + projection(t); + } + + @Override + public TransactionOwner getOwner(Transaction t) + { + return projection(t).owner(); + } + + @Override + public void setOwner(Transaction t, TransactionOwner owner) + { + throw LedgerProjectionSupport.unsupportedMutation(); + } + + @Override + public Transaction getCrossTransaction(Transaction t) + { + var projection = projection(t); + + return transactions.stream() // + .filter(transaction -> transaction != projection.transaction()) // + .findFirst().orElse(null); + } + + @Override + public TransactionOwner getCrossOwner(Transaction t) + { + var crossTransaction = getCrossTransaction(t); + + return crossTransaction != null ? projection(crossTransaction).owner() : null; + } + + @Override + public void insert() + { + throw LedgerProjectionSupport.unsupportedMutation(); + } + + @Override + public String getSource() + { + return entry.getSource(); + } + + @Override + public void setSource(String source) + { + throw LedgerProjectionSupport.unsupportedMutation(); + } + + private Projection projection(Transaction transaction) + { + for (var candidate : transactions) + { + if (candidate == transaction || candidate.getUUID().equals(transaction.getUUID())) + { + var ledgerBacked = (LedgerBackedTransaction) candidate; + var projectionRef = ledgerBacked.getLedgerProjectionRef(); + + if (candidate instanceof LedgerBackedAccountTransaction) + return new Projection(candidate, projectionRef.getAccount()); + + if (candidate instanceof LedgerBackedPortfolioTransaction) + return new Projection(candidate, projectionRef.getPortfolio()); + } + } + + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PROJ_068 + .message("Transaction does not belong to this Ledger cross entry: " + transaction.getUUID())); //$NON-NLS-1$ + } + + private record Projection(Transaction transaction, TransactionOwner owner) + { + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedPortfolioTransaction.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedPortfolioTransaction.java new file mode 100644 index 0000000000..f5a23673bb --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedPortfolioTransaction.java @@ -0,0 +1,285 @@ +package name.abuchen.portfolio.model.ledger.projection; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.stream.Stream; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.CrossEntry; +import name.abuchen.portfolio.model.PortfolioTransaction; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerEntryMetadataPatchHelper; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.money.CurrencyConverter; +import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.MoneyCollectors; +import name.abuchen.portfolio.money.Values; + +/** + * Represents a runtime legacy transaction view backed by a Ledger entry. + * This class belongs to projection support. It must not be treated as persisted transaction + * truth or mutated through legacy setters. + */ +public final class LedgerBackedPortfolioTransaction extends PortfolioTransaction implements LedgerBackedTransaction +{ + private final LedgerEntry entry; + private final LedgerProjectionRef projectionRef; + private final LedgerPosting primaryPosting; + private CrossEntry crossEntry; + + LedgerBackedPortfolioTransaction(LedgerEntry entry, LedgerProjectionRef projectionRef) + { + this.entry = entry; + this.projectionRef = projectionRef; + this.primaryPosting = LedgerProjectionSupport.primaryPosting(entry, projectionRef); + } + + @Override + public LedgerEntry getLedgerEntry() + { + return entry; + } + + @Override + public LedgerProjectionRef getLedgerProjectionRef() + { + return projectionRef; + } + + void setLedgerCrossEntry(CrossEntry crossEntry) + { + this.crossEntry = crossEntry; + } + + @Override + public String getUUID() + { + return projectionRef.getUUID(); + } + + @Override + public Type getType() + { + if (entry.getType().isLedgerNativeTargeted()) + return LedgerProjectionSupport.targetedPortfolioType(projectionRef); + + return switch (entry.getType()) + { + case BUY -> Type.BUY; + case SELL -> Type.SELL; + case SECURITY_TRANSFER -> transferType(); + case DELIVERY_INBOUND -> Type.DELIVERY_INBOUND; + case DELIVERY_OUTBOUND -> Type.DELIVERY_OUTBOUND; + default -> throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PROJ_069 + .message("Unsupported portfolio projection for " + entry.getType())); //$NON-NLS-1$ + }; + } + + @Override + public LocalDateTime getDateTime() + { + return entry.getDateTime(); + } + + @Override + public String getNote() + { + return entry.getNote(); + } + + @Override + public String getSource() + { + return entry.getSource(); + } + + @Override + public Instant getUpdatedAt() + { + return entry.getUpdatedAt(); + } + + @Override + public long getAmount() + { + return primaryPosting.getAmount(); + } + + @Override + public String getCurrencyCode() + { + return primaryPosting.getCurrency(); + } + + @Override + public Money getMonetaryAmount() + { + return Money.of(getCurrencyCode(), getAmount()); + } + + @Override + public Security getSecurity() + { + return primaryPosting.getSecurity(); + } + + @Override + public long getShares() + { + return primaryPosting.getShares(); + } + + @Override + public CrossEntry getCrossEntry() + { + return crossEntry; + } + + @Override + public Stream getUnits() + { + return LedgerProjectionSupport.units(entry, projectionRef, primaryPosting); + } + + @Override + public long getGrossValueAmount() + { + long taxAndFees = getUnitSum(Unit.Type.FEE, Unit.Type.TAX).getAmount(); + + if (getType().isPurchase()) + return getAmount() - taxAndFees; + else + return getAmount() + taxAndFees; + } + + @Override + public Money getUnitSum(Unit.Type type, CurrencyConverter converter) + { + return getUnits().filter(u -> u.getType() == type) + .collect(MoneyCollectors.sum(converter.getTermCurrency(), unit -> { + if (converter.getTermCurrency().equals(unit.getAmount().getCurrencyCode())) + return unit.getAmount(); + else if (unit.getForex() != null + && converter.getTermCurrency().equals(unit.getForex().getCurrencyCode())) + return unit.getForex(); + else + return unit.getAmount().with(converter.at(getDateTime())); + })); + } + + @Override + public String toString() + { + return String.format("%s %-17s %s %9s %s", Values.DateTime.format(this.getDateTime()), getType().name(), //$NON-NLS-1$ + getCurrencyCode(), Values.Amount.format(getAmount()), getSecurity().getName()); + } + + @Override + public void setType(Type type) + { + throw LedgerProjectionSupport.unsupportedMutation(); + } + + @Override + public void setDateTime(LocalDateTime date) + { + LedgerEntryMetadataPatchHelper.setDateTime(entry, date); + } + + @Override + public void setNote(String note) + { + LedgerEntryMetadataPatchHelper.setNote(entry, note); + } + + @Override + public void setSource(String source) + { + LedgerEntryMetadataPatchHelper.setSource(entry, source); + } + + @Override + public void setUpdatedAt(Instant updatedAt) + { + throw LedgerProjectionSupport.unsupportedMutation(); + } + + @Override + public void setAmount(long amount) + { + throw LedgerProjectionSupport.unsupportedMutation(); + } + + @Override + public void setCurrencyCode(String currencyCode) + { + throw LedgerProjectionSupport.unsupportedMutation(); + } + + @Override + public void setMonetaryAmount(Money value) + { + throw LedgerProjectionSupport.unsupportedMutation(); + } + + @Override + public void setSecurity(Security security) + { + throw LedgerProjectionSupport.unsupportedMutation(); + } + + @Override + public void setShares(long shares) + { + throw LedgerProjectionSupport.unsupportedMutation(); + } + + @Override + public void clearUnits() + { + throw LedgerProjectionSupport.unsupportedMutation(); + } + + @Override + public void addUnit(Unit unit) + { + throw LedgerProjectionSupport.unsupportedMutation(); + } + + @Override + public void addUnits(Stream items) + { + throw LedgerProjectionSupport.unsupportedMutation(); + } + + @Override + public void removeUnit(Unit unit) + { + throw LedgerProjectionSupport.unsupportedMutation(); + } + + @Override + public void removeUnits(Unit.Type type) + { + throw LedgerProjectionSupport.unsupportedMutation(); + } + + @Override + protected void setCrossEntry(CrossEntry crossEntry) + { + throw LedgerProjectionSupport.unsupportedMutation(); + } + + private Type transferType() + { + return switch (projectionRef.getRole()) + { + case SOURCE_PORTFOLIO -> Type.TRANSFER_OUT; + case TARGET_PORTFOLIO -> Type.TRANSFER_IN; + default -> throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PROJ_070 + .message("Unsupported security transfer role " + projectionRef.getRole())); //$NON-NLS-1$ + }; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedTransaction.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedTransaction.java new file mode 100644 index 0000000000..ee5f7e9773 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedTransaction.java @@ -0,0 +1,16 @@ +package name.abuchen.portfolio.model.ledger.projection; + +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; + +/** + * Marks a runtime legacy transaction view that is backed by a Ledger entry. + * This projection interface lets compatibility code route writes back to the Ledger. + * Contributors should still use creator, editor, converter, or deleter classes for writes. + */ +public interface LedgerBackedTransaction +{ + LedgerEntry getLedgerEntry(); + + LedgerProjectionRef getLedgerProjectionRef(); +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionFactory.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionFactory.java new file mode 100644 index 0000000000..23506a98d6 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionFactory.java @@ -0,0 +1,151 @@ +package name.abuchen.portfolio.model.ledger.projection; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.AccountTransferEntry; +import name.abuchen.portfolio.model.BuySellEntry; +import name.abuchen.portfolio.model.PortfolioTransaction; +import name.abuchen.portfolio.model.PortfolioTransferEntry; +import name.abuchen.portfolio.model.Transaction; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; + +/** + * Builds runtime legacy projection objects for a Ledger entry. + * This is projection infrastructure. The created objects are compatibility views, not + * persisted transaction facts. + */ +final class LedgerProjectionFactory +{ + Transaction createProjection(LedgerEntry entry, LedgerProjectionRef projectionRef) + { + Objects.requireNonNull(projectionRef); + + return createProjections(entry).stream() // + .filter(transaction -> transaction.getUUID().equals(projectionRef.getUUID())) // + .findFirst().orElseThrow(() -> new IllegalArgumentException( + "Projection ref does not belong to entry: " + projectionRef.getUUID())); //$NON-NLS-1$ + } + + List createProjections(LedgerEntry entry) + { + Objects.requireNonNull(entry); + + var transactions = new ArrayList(); + + for (var projectionRef : entry.getProjectionRefs()) + transactions.add(create(entry, projectionRef)); + + attachCrossEntry(entry, transactions); + + return List.copyOf(transactions); + } + + private Transaction create(LedgerEntry entry, LedgerProjectionRef projectionRef) + { + if (LedgerProjectionSupport.isAccountProjection(projectionRef)) + return new LedgerBackedAccountTransaction(entry, projectionRef); + + if (LedgerProjectionSupport.isPortfolioProjection(projectionRef)) + return new LedgerBackedPortfolioTransaction(entry, projectionRef); + + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PROJ_071 + .message("Unsupported ledger projection role " + projectionRef.getRole())); //$NON-NLS-1$ + } + + private void attachCrossEntry(LedgerEntry entry, List transactions) + { + if (transactions.size() != 2) + return; + + if (entry.getType() == LedgerEntryType.CASH_TRANSFER) + { + attachAccountTransferCrossEntry(entry, transactions); + return; + } + + if (entry.getType() == LedgerEntryType.SECURITY_TRANSFER) + { + attachPortfolioTransferCrossEntry(entry, transactions); + return; + } + + if (entry.getType() == LedgerEntryType.BUY || entry.getType() == LedgerEntryType.SELL) + { + attachBuySellCrossEntry(entry, transactions); + return; + } + + var crossEntry = new LedgerBackedCrossEntry(entry, transactions); + + for (var transaction : transactions) + { + if (transaction instanceof LedgerBackedAccountTransaction accountTransaction) + accountTransaction.setLedgerCrossEntry(crossEntry); + else if (transaction instanceof LedgerBackedPortfolioTransaction portfolioTransaction) + portfolioTransaction.setLedgerCrossEntry(crossEntry); + } + } + + private void attachAccountTransferCrossEntry(LedgerEntry entry, List transactions) + { + var sourceTransaction = (LedgerBackedAccountTransaction) transaction(entry, transactions, + LedgerProjectionRole.SOURCE_ACCOUNT); + var targetTransaction = (LedgerBackedAccountTransaction) transaction(entry, transactions, + LedgerProjectionRole.TARGET_ACCOUNT); + var crossEntry = AccountTransferEntry.readOnly(sourceTransaction.getLedgerProjectionRef().getAccount(), + (AccountTransaction) sourceTransaction, targetTransaction.getLedgerProjectionRef().getAccount(), + (AccountTransaction) targetTransaction); + + sourceTransaction.setLedgerCrossEntry(crossEntry); + targetTransaction.setLedgerCrossEntry(crossEntry); + } + + private void attachPortfolioTransferCrossEntry(LedgerEntry entry, List transactions) + { + var sourceTransaction = (LedgerBackedPortfolioTransaction) transaction(entry, transactions, + LedgerProjectionRole.SOURCE_PORTFOLIO); + var targetTransaction = (LedgerBackedPortfolioTransaction) transaction(entry, transactions, + LedgerProjectionRole.TARGET_PORTFOLIO); + var crossEntry = PortfolioTransferEntry.readOnly(sourceTransaction.getLedgerProjectionRef().getPortfolio(), + (PortfolioTransaction) sourceTransaction, + targetTransaction.getLedgerProjectionRef().getPortfolio(), + (PortfolioTransaction) targetTransaction); + + sourceTransaction.setLedgerCrossEntry(crossEntry); + targetTransaction.setLedgerCrossEntry(crossEntry); + } + + private void attachBuySellCrossEntry(LedgerEntry entry, List transactions) + { + var accountTransaction = (LedgerBackedAccountTransaction) transaction(entry, transactions, + LedgerProjectionRole.ACCOUNT); + var portfolioTransaction = (LedgerBackedPortfolioTransaction) transaction(entry, transactions, + LedgerProjectionRole.PORTFOLIO); + var crossEntry = BuySellEntry.readOnly(portfolioTransaction.getLedgerProjectionRef().getPortfolio(), + (PortfolioTransaction) portfolioTransaction, accountTransaction.getLedgerProjectionRef() + .getAccount(), + (AccountTransaction) accountTransaction); + + accountTransaction.setLedgerCrossEntry(crossEntry); + portfolioTransaction.setLedgerCrossEntry(crossEntry); + } + + private Transaction transaction(LedgerEntry entry, List transactions, LedgerProjectionRole role) + { + var projection = entry.getProjectionRefs().stream() // + .filter(ref -> ref.getRole() == role) // + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Projection not found: " + role)); //$NON-NLS-1$ + + return transactions.stream() // + .filter(transaction -> transaction.getUUID().equals(projection.getUUID())) // + .findFirst().orElseThrow(); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionMaterializer.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionMaterializer.java new file mode 100644 index 0000000000..b7dfe523bf --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionMaterializer.java @@ -0,0 +1,82 @@ +package name.abuchen.portfolio.model.ledger.projection; + +import java.util.Objects; +import java.util.function.Predicate; + +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.PortfolioTransaction; +import name.abuchen.portfolio.model.ledger.LedgerEntry; + +/** + * Materializes Ledger entries into account, portfolio, and cross-entry runtime views. + * This is projection infrastructure used after creation, mutation, and load. It keeps + * legacy owner lists in sync with Ledger truth. + */ +final class LedgerProjectionMaterializer +{ + private final LedgerProjectionFactory factory; + + LedgerProjectionMaterializer() + { + this(new LedgerProjectionFactory()); + } + + LedgerProjectionMaterializer(LedgerProjectionFactory factory) + { + this.factory = Objects.requireNonNull(factory); + } + + void materialize(Client client) + { + materialize(client, entry -> true); + } + + void materialize(Client client, Predicate entryFilter) + { + Objects.requireNonNull(client); + Objects.requireNonNull(entryFilter); + + for (var entry : client.getLedger().getEntries()) + { + if (!entryFilter.test(entry)) + continue; + + for (var transaction : factory.createProjections(entry)) + { + if (transaction instanceof LedgerBackedAccountTransaction accountTransaction) + addAccountProjection(accountTransaction); + else if (transaction instanceof LedgerBackedPortfolioTransaction portfolioTransaction) + addPortfolioProjection(portfolioTransaction); + } + } + } + + private void addAccountProjection(LedgerBackedAccountTransaction transaction) + { + var account = transaction.getLedgerProjectionRef().getAccount(); + + if (account.getTransactions().stream().noneMatch(existing -> isSameLedgerProjection(existing, transaction))) + account.getTransactions().add(transaction); + } + + private void addPortfolioProjection(LedgerBackedPortfolioTransaction transaction) + { + var portfolio = transaction.getLedgerProjectionRef().getPortfolio(); + + if (portfolio.getTransactions().stream().noneMatch(existing -> isSameLedgerProjection(existing, transaction))) + portfolio.getTransactions().add(transaction); + } + + private boolean isSameLedgerProjection(AccountTransaction existing, LedgerBackedTransaction transaction) + { + return existing instanceof LedgerBackedTransaction && existing.getUUID().equals( + transaction.getLedgerProjectionRef().getUUID()); + } + + private boolean isSameLedgerProjection(PortfolioTransaction existing, LedgerBackedTransaction transaction) + { + return existing instanceof LedgerBackedTransaction && existing.getUUID().equals( + transaction.getLedgerProjectionRef().getUUID()); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionService.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionService.java new file mode 100644 index 0000000000..3ea3d0cf88 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionService.java @@ -0,0 +1,78 @@ +package name.abuchen.portfolio.model.ledger.projection; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.Transaction; +import name.abuchen.portfolio.model.ledger.Ledger; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; +import name.abuchen.portfolio.model.ledger.ProjectionMembershipRole; + +/** + * Coordinates materialization and refresh of runtime projections for Ledger entries. + * This is projection infrastructure. Contributor code should use it only when restoring or + * refreshing views from Ledger truth. + */ +public final class LedgerProjectionService +{ + private LedgerProjectionService() + { + } + + public static void materialize(Client client) + { + new LedgerProjectionMaterializer().materialize(client); + } + + public static Transaction createProjection(LedgerEntry entry, LedgerProjectionRef projectionRef) + { + return new LedgerProjectionFactory().createProjection(entry, projectionRef); + } + + public static List createProjections(LedgerEntry entry) + { + return new LedgerProjectionFactory().createProjections(entry); + } + + public static LedgerStructuralValidator.ValidationResult restoreIfValid(Client client) + { + return new LedgerRuntimeProjectionRestorer().restoreIfValid(client); + } + + public static void adaptLegacyScalarMemberships(Client client) + { + for (var entry : client.getLedger().getEntries()) + { + var postingUUIDs = entry.getPostings().stream().map(LedgerPosting::getUUID).collect(Collectors.toSet()); + + for (var projectionRef : entry.getProjectionRefs()) + adaptLegacyScalarMemberships(projectionRef, postingUUIDs); + } + } + + private static void adaptLegacyScalarMemberships(LedgerProjectionRef projectionRef, Set postingUUIDs) + { + if (projectionRef.getPrimaryMembership().isEmpty() + && postingUUIDs.contains(projectionRef.getPrimaryPostingUUID())) + projectionRef.addMembership(projectionRef.getPrimaryPostingUUID(), ProjectionMembershipRole.PRIMARY); + + if (projectionRef.getMembershipsByRole(ProjectionMembershipRole.GROUP_ANCHOR).isEmpty() + && postingUUIDs.contains(projectionRef.getPostingGroupUUID())) + projectionRef.addMembership(projectionRef.getPostingGroupUUID(), ProjectionMembershipRole.GROUP_ANCHOR); + } + + public static void logSkipped(LedgerStructuralValidator.ValidationResult result) + { + LedgerRuntimeProjectionRestorer.logSkipped(result); + } + + public static void logSkipped(Ledger ledger, LedgerStructuralValidator.ValidationResult result) + { + LedgerRuntimeProjectionRestorer.logSkipped(ledger, result); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionSupport.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionSupport.java new file mode 100644 index 0000000000..64f3216217 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionSupport.java @@ -0,0 +1,281 @@ +package name.abuchen.portfolio.model.ledger.projection; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Stream; + +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.PortfolioTransaction; +import name.abuchen.portfolio.model.Transaction.Unit; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerParameter; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.ProjectionMembershipRole; +import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; +import name.abuchen.portfolio.money.Money; + +/** + * Supports runtime projection behavior for ledger-backed legacy transactions. + * This is projection infrastructure. Projections are views rebuilt from Ledger entries, not + * independent transaction truth. + */ +public final class LedgerProjectionSupport +{ + record PostingForex(Money amount, BigDecimal exchangeRate) + { + PostingForex + { + Objects.requireNonNull(amount); + Objects.requireNonNull(exchangeRate); + } + } + + private LedgerProjectionSupport() + { + } + + public static LedgerPosting primaryPosting(LedgerEntry entry, LedgerProjectionRef projectionRef) + { + Objects.requireNonNull(entry); + Objects.requireNonNull(projectionRef); + + var primaryMembership = projectionRef.getPrimaryMembership(); + if (primaryMembership.isPresent()) + return requirePostingInEntry(entry, primaryMembership.get().getPostingUUID()); + + if (projectionRef.getPrimaryPostingUUID() != null) + return requirePostingInEntry(entry, projectionRef.getPrimaryPostingUUID()); + + return switch (projectionRef.getRole()) + { + case SOURCE_ACCOUNT -> firstAccountPosting(entry, projectionRef); + case TARGET_ACCOUNT -> lastAccountPosting(entry, projectionRef); + case SOURCE_PORTFOLIO -> firstPortfolioPosting(entry, projectionRef); + case TARGET_PORTFOLIO -> lastPortfolioPosting(entry, projectionRef); + case ACCOUNT, CASH_COMPENSATION -> firstAccountPosting(entry, projectionRef); + case PORTFOLIO, DELIVERY, DELIVERY_INBOUND, DELIVERY_OUTBOUND, OLD_SECURITY_LEG, NEW_SECURITY_LEG -> + firstPortfolioPosting(entry, projectionRef); + }; + } + + static Optional primaryPostingForex(LedgerEntry entry, LedgerProjectionRef projectionRef) + { + return postingForex(primaryPosting(entry, projectionRef)); + } + + private static Optional postingForex(LedgerPosting posting) + { + Objects.requireNonNull(posting); + + if (posting.getForexAmount() == null || posting.getForexCurrency() == null + || posting.getExchangeRate() == null) + return Optional.empty(); + + return Optional.of(new PostingForex(Money.of(posting.getForexCurrency(), posting.getForexAmount()), + posting.getExchangeRate())); + } + + static Stream units(LedgerEntry entry, LedgerProjectionRef projectionRef, LedgerPosting primaryPosting) + { + if (entry.getType().isLedgerNativeTargeted()) + return targetedUnits(entry, projectionRef, primaryPosting).map(LedgerProjectionSupport::unit); + + return entry.getPostings().stream() // + .filter(posting -> posting != primaryPosting) // + .filter(LedgerProjectionSupport::isUnitPosting) // + .map(LedgerProjectionSupport::unit); + } + + static AccountTransaction.Type targetedAccountType(LedgerProjectionRef projectionRef) + { + return switch (projectionRef.getRole()) + { + case CASH_COMPENSATION -> AccountTransaction.Type.DEPOSIT; + default -> throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PROJ_072 + .message("Unsupported targeted account role " + projectionRef.getRole())); //$NON-NLS-1$ + }; + } + + static PortfolioTransaction.Type targetedPortfolioType(LedgerProjectionRef projectionRef) + { + return switch (projectionRef.getRole()) + { + case DELIVERY_OUTBOUND -> PortfolioTransaction.Type.DELIVERY_OUTBOUND; + case DELIVERY_INBOUND -> PortfolioTransaction.Type.DELIVERY_INBOUND; + case OLD_SECURITY_LEG -> PortfolioTransaction.Type.DELIVERY_OUTBOUND; + case NEW_SECURITY_LEG -> PortfolioTransaction.Type.DELIVERY_INBOUND; + default -> throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PROJ_073 + .message("Unsupported targeted portfolio role " + projectionRef.getRole())); //$NON-NLS-1$ + }; + } + + static Optional exDate(LedgerPosting posting) + { + return posting.getParameters().stream() // + .filter(parameter -> parameter.getType() == LedgerParameterType.EX_DATE) // + .filter(parameter -> parameter.getValueKind() == LedgerParameter.ValueKind.LOCAL_DATE_TIME) // + .map(LedgerParameter::getValue) // + .filter(LocalDateTime.class::isInstance) // + .map(LocalDateTime.class::cast) // + .findFirst(); + } + + static UnsupportedOperationException unsupportedMutation() + { + return new UnsupportedOperationException("Ledger-backed projections are read-only"); //$NON-NLS-1$ + } + + static boolean isAccountProjection(LedgerProjectionRef projectionRef) + { + return switch (projectionRef.getRole()) + { + case ACCOUNT, SOURCE_ACCOUNT, TARGET_ACCOUNT, CASH_COMPENSATION -> true; + default -> false; + }; + } + + static boolean isPortfolioProjection(LedgerProjectionRef projectionRef) + { + return switch (projectionRef.getRole()) + { + case PORTFOLIO, SOURCE_PORTFOLIO, TARGET_PORTFOLIO, DELIVERY, DELIVERY_INBOUND, DELIVERY_OUTBOUND, + OLD_SECURITY_LEG, NEW_SECURITY_LEG -> true; + default -> false; + }; + } + + private static LedgerPosting firstAccountPosting(LedgerEntry entry, LedgerProjectionRef projectionRef) + { + return accountPostings(entry, projectionRef).findFirst().orElseThrow(() -> new IllegalArgumentException( + "No account posting for projection " + projectionRef.getUUID())); //$NON-NLS-1$ + } + + private static LedgerPosting requirePostingInEntry(LedgerEntry entry, String uuid) + { + return entry.getPostings().stream() // + .filter(posting -> uuid.equals(posting.getUUID())) // + .findFirst() + .orElseThrow(() -> new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_001 + .message("Primary posting does not exist in entry: " + uuid))); //$NON-NLS-1$ + } + + private static LedgerPosting lastAccountPosting(LedgerEntry entry, LedgerProjectionRef projectionRef) + { + var postings = accountPostings(entry, projectionRef).toList(); + + if (postings.isEmpty()) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_074 + .message("No account posting for projection " + projectionRef.getUUID())); //$NON-NLS-1$ + + return postings.get(postings.size() - 1); + } + + private static Stream accountPostings(LedgerEntry entry, LedgerProjectionRef projectionRef) + { + return entry.getPostings().stream() // + .filter(posting -> posting.getAccount() == projectionRef.getAccount()); + } + + private static LedgerPosting firstPortfolioPosting(LedgerEntry entry, LedgerProjectionRef projectionRef) + { + return portfolioPostings(entry, projectionRef).findFirst().orElseThrow(() -> new IllegalArgumentException( + "No portfolio posting for projection " + projectionRef.getUUID())); //$NON-NLS-1$ + } + + private static LedgerPosting lastPortfolioPosting(LedgerEntry entry, LedgerProjectionRef projectionRef) + { + List postings = portfolioPostings(entry, projectionRef).toList(); + + if (postings.isEmpty()) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_075 + .message("No portfolio posting for projection " + projectionRef.getUUID())); //$NON-NLS-1$ + + return postings.get(postings.size() - 1); + } + + private static Stream portfolioPostings(LedgerEntry entry, LedgerProjectionRef projectionRef) + { + return entry.getPostings().stream() // + .filter(posting -> posting.getPortfolio() == projectionRef.getPortfolio()); + } + + private static boolean isUnitPosting(LedgerPosting posting) + { + return switch (posting.getType()) + { + case FEE, TAX, GROSS_VALUE -> true; + default -> false; + }; + } + + private static Stream targetedUnits(LedgerEntry entry, LedgerProjectionRef projectionRef, + LedgerPosting primaryPosting) + { + var unitMemberships = projectionRef.getMemberships().stream() // + .filter(membership -> isUnitMembershipRole(membership.getRole())) // + .toList(); + + if (!unitMemberships.isEmpty()) + return unitMemberships.stream() // + .map(membership -> requirePostingInEntry(entry, membership.getPostingUUID())) // + .filter(posting -> posting != primaryPosting) // + .filter(LedgerProjectionSupport::isUnitPosting); + + var groupAnchorUUID = projectionRef.getMembershipsByRole(ProjectionMembershipRole.GROUP_ANCHOR).stream() + .findFirst() // + .map(membership -> membership.getPostingUUID()) // + .orElse(projectionRef.getPostingGroupUUID()); + + if (groupAnchorUUID == null || !groupAnchorUUID.equals(primaryPosting.getUUID())) + return Stream.empty(); + + return entry.getPostings().stream() // + .filter(posting -> posting != primaryPosting) // + .filter(LedgerProjectionSupport::isUnitPosting) // + .filter(posting -> hasSameProjectionOwner(primaryPosting, posting)); + } + + private static boolean isUnitMembershipRole(ProjectionMembershipRole role) + { + return switch (role) + { + case FEE_UNIT, TAX_UNIT, GROSS_VALUE_UNIT -> true; + default -> false; + }; + } + + private static boolean hasSameProjectionOwner(LedgerPosting primaryPosting, LedgerPosting posting) + { + if (primaryPosting.getAccount() != null) + return primaryPosting.getAccount() == posting.getAccount(); + + if (primaryPosting.getPortfolio() != null) + return primaryPosting.getPortfolio() == posting.getPortfolio(); + + return false; + } + + private static Unit unit(LedgerPosting posting) + { + var type = switch (posting.getType()) + { + case FEE -> Unit.Type.FEE; + case TAX -> Unit.Type.TAX; + case GROSS_VALUE -> Unit.Type.GROSS_VALUE; + default -> throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_076 + .message("Posting is not a unit posting: " + posting.getType())); //$NON-NLS-1$ + }; + var amount = Money.of(posting.getCurrency(), posting.getAmount()); + + if (posting.getForexAmount() != null && posting.getForexCurrency() != null && posting.getExchangeRate() != null) + return new Unit(type, amount, Money.of(posting.getForexCurrency(), posting.getForexAmount()), + posting.getExchangeRate()); + + return new Unit(type, amount); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerRuntimeProjectionRestorer.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerRuntimeProjectionRestorer.java new file mode 100644 index 0000000000..f5aec0193c --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerRuntimeProjectionRestorer.java @@ -0,0 +1,423 @@ +package name.abuchen.portfolio.model.ledger.projection; + +import java.text.MessageFormat; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.StringJoiner; +import java.util.function.Consumer; + +import name.abuchen.portfolio.Messages; +import name.abuchen.portfolio.PortfolioLog; +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.PortfolioTransaction; +import name.abuchen.portfolio.model.ledger.Ledger; +import name.abuchen.portfolio.model.ledger.LedgerDiagnosticMessageFormatter; +import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; + +/** + * Restores runtime account and portfolio transactions from persisted Ledger entries after load. + * The restored legacy transactions are views. They must not be treated as a second persisted + * source of transaction truth. + */ +final class LedgerRuntimeProjectionRestorer +{ + private static final String ENTRY_UUID_DETAIL = "entryUUID"; //$NON-NLS-1$ + + @FunctionalInterface + interface Logger + { + void log(Severity severity, String message); + } + + enum Severity + { + INFO, + WARNING + } + + private static final class ProjectionKey + { + private final String ownerType; + private final Object owner; + private final String projectionUUID; + + private ProjectionKey(String ownerType, Object owner, String projectionUUID) + { + this.ownerType = ownerType; + this.owner = owner; + this.projectionUUID = projectionUUID; + } + + @Override + public int hashCode() + { + return Objects.hash(ownerType, System.identityHashCode(owner), projectionUUID); + } + + @Override + public boolean equals(Object obj) + { + if (this == obj) + return true; + + if (!(obj instanceof ProjectionKey other)) + return false; + + return owner == other.owner && Objects.equals(ownerType, other.ownerType) + && Objects.equals(projectionUUID, other.projectionUUID); + } + } + + private static final class RestorationDiagnostics + { + private final Set affectedOwners = new LinkedHashSet<>(); + private final Set missingProjectionUUIDs = new LinkedHashSet<>(); + private final Set duplicateProjectionUUIDs = new LinkedHashSet<>(); + private final Set staleProjectionUUIDs = new LinkedHashSet<>(); + + private boolean hasChanges() + { + return !affectedOwners.isEmpty(); + } + + private boolean hasUnexpectedOwnerListState() + { + return !duplicateProjectionUUIDs.isEmpty() || !staleProjectionUUIDs.isEmpty(); + } + } + + private final LedgerProjectionMaterializer materializer; + private final Logger logger; + + LedgerRuntimeProjectionRestorer() + { + this(new LedgerProjectionMaterializer(), LedgerRuntimeProjectionRestorer::logToPortfolioLog); + } + + LedgerRuntimeProjectionRestorer(LedgerProjectionMaterializer materializer) + { + this(materializer, LedgerRuntimeProjectionRestorer::logToPortfolioLog); + } + + LedgerRuntimeProjectionRestorer(LedgerProjectionMaterializer materializer, Logger logger) + { + this.materializer = Objects.requireNonNull(materializer); + this.logger = Objects.requireNonNull(logger); + } + + LedgerStructuralValidator.ValidationResult restoreIfValid(Client client) + { + Objects.requireNonNull(client); + + var result = LedgerStructuralValidator.validate(client.getLedger()); + + if (!result.isOK()) + { + var invalidEntryUUIDs = invalidEntryUUIDs(result); + if (invalidEntryUUIDs == null) + { + logSkippedInvalidLedger(client.getLedger(), result); + return result; + } + + var expectedProjections = expectedProjectionCounts(client); + var existingProjections = existingProjectionCounts(client); + var diagnostics = restorationDiagnostics(expectedProjections, existingProjections); + + var removedProjections = removeLedgerBackedProjections(client); + materializer.materialize(client, entry -> !invalidEntryUUIDs.contains(entry.getUUID())); + var postRestoreDiagnostics = restorationDiagnostics(expectedProjections, existingProjectionCounts(client)); + + logPartiallyRestored(client.getLedger(), result, removedProjections, countLedgerBackedProjections(client), + invalidEntryUUIDs.size(), + diagnostics.hasUnexpectedOwnerListState() ? diagnostics : postRestoreDiagnostics); + return result; + } + + var expectedProjections = expectedProjectionCounts(client); + var existingProjections = existingProjectionCounts(client); + var diagnostics = restorationDiagnostics(expectedProjections, existingProjections); + + var removedProjections = removeLedgerBackedProjections(client); + materializer.materialize(client); + var postRestoreDiagnostics = restorationDiagnostics(expectedProjections, existingProjectionCounts(client)); + + if (diagnostics.hasUnexpectedOwnerListState() || postRestoreDiagnostics.hasChanges()) + logRestored(removedProjections, countLedgerBackedProjections(client), diagnostics); + + return result; + } + + static void logSkipped(LedgerStructuralValidator.ValidationResult result) + { + logSkipped(result, LedgerRuntimeProjectionRestorer::warning); + } + + static void logSkipped(LedgerStructuralValidator.ValidationResult result, Consumer warningLogger) + { + warningLogger.accept(invalidLedgerMessage(result)); + } + + static void logSkipped(Ledger ledger, LedgerStructuralValidator.ValidationResult result) + { + logSkipped(ledger, result, LedgerRuntimeProjectionRestorer::warning); + } + + static void logSkipped(Ledger ledger, LedgerStructuralValidator.ValidationResult result, + Consumer warningLogger) + { + warningLogger.accept(invalidLedgerMessage(ledger, result)); + } + + static String invalidLedgerMessage(LedgerStructuralValidator.ValidationResult result) + { + return MessageFormat.format(Messages.LedgerRuntimeProjectionRestorerInvalidLedger, result.format()); + } + + static String invalidLedgerMessage(Ledger ledger, LedgerStructuralValidator.ValidationResult result) + { + return MessageFormat.format(Messages.LedgerRuntimeProjectionRestorerInvalidLedger, + LedgerDiagnosticMessageFormatter.formatValidationResult(ledger, result)); + } + + static String restoredLedgerMessage(int removedProjections, int materializedProjections, + int affectedOwnerCount, Set missingProjectionUUIDs, Set duplicateProjectionUUIDs, + Set staleProjectionUUIDs) + { + return MessageFormat.format(Messages.LedgerRuntimeProjectionRestorerRestored, removedProjections, + materializedProjections, affectedOwnerCount, formatUUIDs(missingProjectionUUIDs), + formatUUIDs(duplicateProjectionUUIDs), formatUUIDs(staleProjectionUUIDs)); + } + + static String partiallyRestoredLedgerMessage(int removedProjections, int materializedProjections, + int skippedEntryCount, int affectedOwnerCount, Set missingProjectionUUIDs, + Set duplicateProjectionUUIDs, Set staleProjectionUUIDs, + LedgerStructuralValidator.ValidationResult result) + { + return MessageFormat.format(Messages.LedgerRuntimeProjectionRestorerPartiallyRestored, removedProjections, + materializedProjections, skippedEntryCount, affectedOwnerCount, + formatUUIDs(missingProjectionUUIDs), formatUUIDs(duplicateProjectionUUIDs), + formatUUIDs(staleProjectionUUIDs), result.format()); + } + + static String partiallyRestoredLedgerMessage(int removedProjections, int materializedProjections, + int skippedEntryCount, int affectedOwnerCount, Set missingProjectionUUIDs, + Set duplicateProjectionUUIDs, Set staleProjectionUUIDs, Ledger ledger, + LedgerStructuralValidator.ValidationResult result) + { + return MessageFormat.format(Messages.LedgerRuntimeProjectionRestorerPartiallyRestored, removedProjections, + materializedProjections, skippedEntryCount, affectedOwnerCount, + formatUUIDs(missingProjectionUUIDs), formatUUIDs(duplicateProjectionUUIDs), + formatUUIDs(staleProjectionUUIDs), + LedgerDiagnosticMessageFormatter.formatValidationResult(ledger, result)); + } + + private void logRestored(int removedProjections, int materializedProjections, RestorationDiagnostics diagnostics) + { + logger.log(Severity.INFO, restoredLedgerMessage(removedProjections, materializedProjections, + diagnostics.affectedOwners.size(), diagnostics.missingProjectionUUIDs, + diagnostics.duplicateProjectionUUIDs, diagnostics.staleProjectionUUIDs)); + } + + private void logPartiallyRestored(Ledger ledger, LedgerStructuralValidator.ValidationResult result, + int removedProjections, int materializedProjections, int skippedEntryCount, + RestorationDiagnostics diagnostics) + { + logger.log(Severity.WARNING, + partiallyRestoredLedgerMessage(removedProjections, materializedProjections, skippedEntryCount, + diagnostics.affectedOwners.size(), diagnostics.missingProjectionUUIDs, + diagnostics.duplicateProjectionUUIDs, diagnostics.staleProjectionUUIDs, ledger, + result)); + } + + private void logSkippedInvalidLedger(Ledger ledger, LedgerStructuralValidator.ValidationResult result) + { + logger.log(Severity.WARNING, invalidLedgerMessage(ledger, result)); + } + + + private static void logToPortfolioLog(Severity severity, String message) + { + switch (severity) + { + case INFO -> PortfolioLog.info(message); + case WARNING -> PortfolioLog.warning(message); + default -> throw new IllegalArgumentException( + LedgerDiagnosticCode.LEDGER_PROJ_077.message("Unsupported log severity: " + severity)); //$NON-NLS-1$ + } + } + + private static void warning(String message) + { + PortfolioLog.warning(message); + } + + private Set invalidEntryUUIDs(LedgerStructuralValidator.ValidationResult result) + { + var invalidEntryUUIDs = new LinkedHashSet(); + + for (var issue : result.getIssues()) + { + var entryUUID = issue.getDetails().get(ENTRY_UUID_DETAIL); + if (entryUUID == null || entryUUID.isBlank() || "".equals(entryUUID)) //$NON-NLS-1$ + return null; + + invalidEntryUUIDs.add(entryUUID); + } + + return invalidEntryUUIDs; + } + + private int removeLedgerBackedProjections(Client client) + { + var removedProjections = 0; + + for (var account : client.getAccounts()) + removedProjections += removeAccountProjections(account.getTransactions()); + + for (var portfolio : client.getPortfolios()) + removedProjections += removePortfolioProjections(portfolio.getTransactions()); + + return removedProjections; + } + + private int removeAccountProjections(List transactions) + { + var size = transactions.size(); + transactions.removeIf(LedgerBackedTransaction.class::isInstance); + return size - transactions.size(); + } + + private int removePortfolioProjections(List transactions) + { + var size = transactions.size(); + transactions.removeIf(LedgerBackedTransaction.class::isInstance); + return size - transactions.size(); + } + + private Map expectedProjectionCounts(Client client) + { + Map projections = new HashMap<>(); + + for (var entry : client.getLedger().getEntries()) + { + for (var projection : entry.getProjectionRefs()) + { + if (projection.getAccount() != null) + addProjection(projections, + new ProjectionKey("account", projection.getAccount(), projection.getUUID())); //$NON-NLS-1$ + else if (projection.getPortfolio() != null) + addProjection(projections, + new ProjectionKey("portfolio", projection.getPortfolio(), projection.getUUID())); //$NON-NLS-1$ + } + } + + return projections; + } + + private Map existingProjectionCounts(Client client) + { + Map projections = new HashMap<>(); + + for (var account : client.getAccounts()) + { + for (var transaction : account.getTransactions()) + { + if (transaction instanceof LedgerBackedTransaction ledgerBacked) + addProjection(projections, new ProjectionKey("account", account, //$NON-NLS-1$ + ledgerBacked.getLedgerProjectionRef().getUUID())); + } + } + + for (var portfolio : client.getPortfolios()) + { + for (var transaction : portfolio.getTransactions()) + { + if (transaction instanceof LedgerBackedTransaction ledgerBacked) + addProjection(projections, new ProjectionKey("portfolio", portfolio, //$NON-NLS-1$ + ledgerBacked.getLedgerProjectionRef().getUUID())); + } + } + + return projections; + } + + private void addProjection(Map projections, ProjectionKey key) + { + projections.merge(key, 1, Integer::sum); + } + + private RestorationDiagnostics restorationDiagnostics(Map expectedProjections, + Map existingProjections) + { + var diagnostics = new RestorationDiagnostics(); + var keys = new LinkedHashSet(); + + keys.addAll(expectedProjections.keySet()); + keys.addAll(existingProjections.keySet()); + + for (var key : keys) + { + var expectedCount = expectedProjections.getOrDefault(key, 0); + var existingCount = existingProjections.getOrDefault(key, 0); + + if (expectedCount == existingCount) + continue; + + diagnostics.affectedOwners.add(key.owner); + + if (expectedCount > existingCount) + diagnostics.missingProjectionUUIDs.add(key.projectionUUID); + else if (expectedCount == 0) + diagnostics.staleProjectionUUIDs.add(key.projectionUUID); + else + diagnostics.duplicateProjectionUUIDs.add(key.projectionUUID); + } + + return diagnostics; + } + + private int countLedgerBackedProjections(Client client) + { + var projections = 0; + + for (var account : client.getAccounts()) + projections += countAccountProjections(account.getTransactions()); + + for (var portfolio : client.getPortfolios()) + projections += countPortfolioProjections(portfolio.getTransactions()); + + return projections; + } + + private int countAccountProjections(List transactions) + { + return (int) transactions.stream().filter(LedgerBackedTransaction.class::isInstance).count(); + } + + private int countPortfolioProjections(List transactions) + { + return (int) transactions.stream().filter(LedgerBackedTransaction.class::isInstance).count(); + } + + private static String formatUUIDs(Set uuids) + { + if (uuids.isEmpty()) + return "[]"; //$NON-NLS-1$ + + var joiner = new StringJoiner(", ", "[", "]"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ + + uuids.stream().limit(10).forEach(joiner::add); + + if (uuids.size() > 10) + joiner.add("... +" + (uuids.size() - 10)); //$NON-NLS-1$ + + return joiner.toString(); + } +} From 3f58e32d9278dfa12b8775c0e6437388df026f9d Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Fri, 26 Jun 2026 14:29:13 +0200 Subject: [PATCH 04/68] Add Ledger XML and Protobuf persistence Add Ledger XML and Protobuf persistence support, schema/generated output, and save/load tests. This makes persisted Ledger truth reviewable as one file-level slice. UI wiring, import guardrails, diagnostics/NLS, and documentation are intentionally left out. --- .../model/LedgerProtobufPersistenceTest.java | 1074 ++++++++ .../model/LedgerSaveLoadParityTest.java | 1542 +++++++++++ .../model/ProtobufTestUtilities.java | 24 + .../ledger/LedgerXmlPersistenceTest.java | 878 +++++++ name.abuchen.portfolio/META-INF/MANIFEST.MF | 1 + .../model/proto/v1/ClientProtos.java | 407 ++- .../portfolio/model/proto/v1/PClient.java | 199 +- .../model/proto/v1/PClientOrBuilder.java | 15 + .../model/proto/v1/PInvestmentPlan.java | 350 +++ .../v1/PInvestmentPlanLedgerExecutionRef.java | 781 ++++++ ...stmentPlanLedgerExecutionRefOrBuilder.java | 54 + .../proto/v1/PInvestmentPlanOrBuilder.java | 24 + .../portfolio/model/proto/v1/PLedger.java | 725 +++++ .../model/proto/v1/PLedgerEntry.java | 2327 +++++++++++++++++ .../model/proto/v1/PLedgerEntryOrBuilder.java | 168 ++ .../model/proto/v1/PLedgerOrBuilder.java | 33 + .../model/proto/v1/PLedgerParameter.java | 2160 +++++++++++++++ .../proto/v1/PLedgerParameterOrBuilder.java | 204 ++ .../proto/v1/PLedgerParameterValueKind.java | 193 ++ .../model/proto/v1/PLedgerPosting.java | 2217 ++++++++++++++++ .../proto/v1/PLedgerPostingOrBuilder.java | 185 ++ .../proto/v1/PLedgerProjectionMembership.java | 600 +++++ .../PLedgerProjectionMembershipOrBuilder.java | 32 + .../v1/PLedgerProjectionMembershipRole.java | 157 ++ .../model/proto/v1/PLedgerProjectionRef.java | 1271 +++++++++ .../v1/PLedgerProjectionRefOrBuilder.java | 90 + .../model/proto/v1/PLedgerProjectionRole.java | 212 ++ .../portfolio/model/ClientFactory.java | 853 +++++- .../model/LedgerModelLoadSupport.java | 167 ++ .../portfolio/model/ProtobufWriter.java | 623 ++++- .../name/abuchen/portfolio/model/client.proto | 169 +- 31 files changed, 17549 insertions(+), 186 deletions(-) create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceTest.java create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerSaveLoadParityTest.java create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ProtobufTestUtilities.java create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerXmlPersistenceTest.java create mode 100644 name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PInvestmentPlanLedgerExecutionRef.java create mode 100644 name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PInvestmentPlanLedgerExecutionRefOrBuilder.java create mode 100644 name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedger.java create mode 100644 name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerEntry.java create mode 100644 name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerEntryOrBuilder.java create mode 100644 name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerOrBuilder.java create mode 100644 name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerParameter.java create mode 100644 name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerParameterOrBuilder.java create mode 100644 name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerParameterValueKind.java create mode 100644 name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerPosting.java create mode 100644 name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerPostingOrBuilder.java create mode 100644 name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionMembership.java create mode 100644 name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionMembershipOrBuilder.java create mode 100644 name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionMembershipRole.java create mode 100644 name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionRef.java create mode 100644 name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionRefOrBuilder.java create mode 100644 name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionRole.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerModelLoadSupport.java diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceTest.java new file mode 100644 index 0000000000..4530e31b3c --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceTest.java @@ -0,0 +1,1074 @@ +package name.abuchen.portfolio.model; + +import static org.hamcrest.CoreMatchers.instanceOf; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.Test; + +import name.abuchen.portfolio.Messages; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerParameter; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; +import name.abuchen.portfolio.model.ledger.LedgerTransactionMetadata; +import name.abuchen.portfolio.model.ledger.ProjectionMembershipRole; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountCashLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerCashTransferLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerCreationUnit; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerCreationUnits; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerDeliveryLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerDividend; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerForexAmount; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerOptionalSecurity; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioSecurityLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferSecurity; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerSecurityQuantity; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerTransactionCreator; +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.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionService; +import name.abuchen.portfolio.model.proto.v1.PClient; +import name.abuchen.portfolio.model.proto.v1.PLedgerEntry; +import name.abuchen.portfolio.model.proto.v1.PLedgerParameter; +import name.abuchen.portfolio.model.proto.v1.PLedgerParameterValueKind; +import name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef; +import name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRole; +import name.abuchen.portfolio.model.proto.v1.PTransaction; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.Values; + +/** + * Tests protobuf persistence for ledger-backed transactions. + * These tests make sure loaded rows are rebuilt from ledger truth instead of derived compatibility shadows. + */ +@SuppressWarnings("nls") +public class LedgerProtobufPersistenceTest +{ + private static final byte[] SIGNATURE = new byte[] { 'P', 'P', 'P', 'B', 'V', '1' }; + private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 1, 2, 3, 4); + private static final LocalDateTime EX_DATE = LocalDateTime.of(2025, 12, 31, 0, 0); + private static final Instant UPDATED_AT = Instant.parse("2026-01-02T03:04:05Z"); + + /** + * Verifies that protobuf save writes ledger truth and the account shadow row. + * The shadow row is derived compatibility data, not a second booking source. + */ + @Test + public void testSaveWritesLedgerTruthOnField13AndAccountShadow() throws IOException + { + var fixture = fixture(); + var created = new LedgerTransactionCreator(fixture.client()).createDeposit(metadata(), + cashLeg(fixture.account(), 100)); + created.getEntry().setUpdatedAt(UPDATED_AT); + + var proto = saveProto(fixture.client()); + var projectionUUID = created.getEntry().getProjectionRefs().get(0).getUUID(); + + assertThat(PClient.getDescriptor().findFieldByName("ledger").getNumber(), is(13)); + assertThat(PLedgerProjectionRef.getDescriptor().findFieldByName("primaryPostingUUID"), nullValue()); + assertThat(PLedgerProjectionRef.getDescriptor().findFieldByName("postingGroupUUID"), nullValue()); + assertTrue(proto.hasLedger()); + assertThat(proto.getLedger().getEntriesCount(), is(1)); + assertThat(proto.getLedger().getEntries(0).getUuid(), is(created.getEntry().getUUID())); + assertThat(proto.getTransactionsCount(), is(1)); + assertThat(proto.getTransactions(0).getUuid(), is(projectionUUID)); + assertThat(proto.getTransactions(0).getType(), is(PTransaction.Type.DEPOSIT)); + + var loaded = load(saveBytes(fixture.client())); + + assertThat(loaded.getLedger().getEntries().size(), is(1)); + assertThat(loaded.getAccounts().get(0).getTransactions().size(), is(1)); + assertThat(loaded.getAccounts().get(0).getTransactions().get(0), instanceOf(LedgerBackedTransaction.class)); + assertThat(loaded.getAccounts().get(0).getTransactions().get(0).getUUID(), is(projectionUUID)); + assertThat(loaded.getAllTransactions().size(), is(1)); + assertValid(loaded); + } + + /** + * Verifies that dividend protobuf roundtrip preserves ex-date, units, forex, and UUIDs. + * The restored ledger-backed projection must represent the same dividend booking. + */ + @Test + public void testDividendRoundtripPreservesExDateUnitsForexAndUUIDs() throws IOException + { + var fixture = fixture(); + var creator = new LedgerTransactionCreator(fixture.client()); + var units = LedgerCreationUnits.of( + LedgerCreationUnit.fee(money(2), + LedgerForexAmount.of(Money.of(CurrencyUnit.USD, Values.Amount.factorize(2)), + BigDecimal.ONE)), + LedgerCreationUnit.tax(money(4), + LedgerForexAmount.of(Money.of(CurrencyUnit.USD, Values.Amount.factorize(4)), + BigDecimal.ONE)), + LedgerCreationUnit.grossValue(money(120), + LedgerForexAmount.of(Money.of(CurrencyUnit.USD, Values.Amount.factorize(120)), + BigDecimal.ONE))); + var created = creator.createDividend(metadata(), + LedgerDividend.withExDate( + LedgerAccountCashLeg.of(fixture.account(), money(100), + LedgerForexAmount.of( + Money.of(CurrencyUnit.USD, + Values.Amount.factorize(110)), + new BigDecimal("1.1000"))), + LedgerOptionalSecurity.of(fixture.security()), units, EX_DATE)); + var entry = created.getEntry(); + var postingUUIDs = entry.getPostings().stream().map(LedgerPosting::getUUID).toList(); + var projectionUUID = entry.getProjectionRefs().get(0).getUUID(); + entry.setUpdatedAt(UPDATED_AT); + + var loaded = load(saveBytes(fixture.client())); + var reloadedEntry = loaded.getLedger().getEntries().get(0); + var reloadedProjection = loaded.getAccounts().get(0).getTransactions().get(0); + var cashPosting = reloadedEntry.getPostings().get(0); + + assertThat(reloadedEntry.getUUID(), is(entry.getUUID())); + assertThat(reloadedEntry.getType(), is(LedgerEntryType.DIVIDENDS)); + assertThat(reloadedEntry.getDateTime(), is(DATE_TIME)); + assertThat(reloadedEntry.getNote(), is("note")); + assertThat(reloadedEntry.getSource(), is("source")); + assertThat(reloadedEntry.getUpdatedAt(), is(UPDATED_AT)); + assertThat(reloadedEntry.getPostings().stream().map(LedgerPosting::getUUID).toList(), is(postingUUIDs)); + assertThat(reloadedEntry.getProjectionRefs().get(0).getUUID(), is(projectionUUID)); + assertThat(reloadedEntry.getProjectionRefs().get(0).getPrimaryMembership().orElseThrow().getPostingUUID(), + is(postingUUIDs.get(0))); + assertTrue(reloadedEntry.getProjectionRefs().get(0) + .hasMembershipRole(ProjectionMembershipRole.FEE_UNIT)); + assertTrue(reloadedEntry.getProjectionRefs().get(0) + .hasMembershipRole(ProjectionMembershipRole.TAX_UNIT)); + assertTrue(reloadedEntry.getProjectionRefs().get(0) + .hasMembershipRole(ProjectionMembershipRole.GROSS_VALUE_UNIT)); + assertThat(cashPosting.getForexAmount(), is(Long.valueOf(Values.Amount.factorize(110)))); + assertThat(cashPosting.getForexCurrency(), is(CurrencyUnit.USD)); + assertThat(cashPosting.getExchangeRate(), is(new BigDecimal("1.1000"))); + assertThat(reloadedProjection.getExDate(), is(EX_DATE)); + assertThat(reloadedProjection.getUnits().count(), is(3L)); + assertTrue(cashPosting.getParameters().stream() + .anyMatch(parameter -> parameter.getType() == LedgerParameterType.EX_DATE)); + assertValid(loaded); + } + + /** + * Verifies that protobuf load preserves ledger graph identity, order, and parameters. + * Runtime projections must be rebuilt from the same persisted ledger facts. + */ + @Test + public void testProtobufLoadPreservesLedgerGraphIdentityOrderAndParameters() throws IOException + { + var fixture = fixture(); + var entry = new LedgerEntry("entry-protobuf-load-graph"); + var cashPosting = new LedgerPosting("posting-protobuf-load-a"); + var feePosting = new LedgerPosting("posting-protobuf-load-b"); + var projection = new LedgerProjectionRef("projection-protobuf-load"); + + entry.setType(LedgerEntryType.DIVIDENDS); + entry.setDateTime(DATE_TIME); + entry.setNote("protobuf load note"); + entry.setSource("protobuf load source"); + entry.addParameter(LedgerParameter.ofString(LedgerParameterType.EVENT_REFERENCE, "event-reference")); + entry.addParameter(LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_KIND, "SPIN_OFF")); + + cashPosting.setType(LedgerPostingType.CASH); + cashPosting.setAccount(fixture.account()); + cashPosting.setSecurity(fixture.security()); + cashPosting.setAmount(Values.Amount.factorize(100)); + cashPosting.setCurrency(CurrencyUnit.EUR); + cashPosting.addParameter(LedgerParameter.ofLocalDateTime(LedgerParameterType.EX_DATE, EX_DATE)); + cashPosting.addParameter(LedgerParameter.ofMoney(LedgerParameterType.FAIR_MARKET_VALUE, money(100))); + + feePosting.setType(LedgerPostingType.FEE); + feePosting.setAccount(fixture.account()); + feePosting.setAmount(Values.Amount.factorize(1)); + feePosting.setCurrency(CurrencyUnit.EUR); + feePosting.addParameter(LedgerParameter.ofSecurity(LedgerParameterType.TARGET_SECURITY, fixture.security())); + + projection.setRole(LedgerProjectionRole.ACCOUNT); + projection.setAccount(fixture.account()); + projection.setPrimaryPostingTargetUUID(cashPosting.getUUID()); + projection.setPostingGroupTargetUUID(feePosting.getUUID()); + + entry.addPosting(cashPosting); + entry.addPosting(feePosting); + entry.addProjectionRef(projection); + entry.setUpdatedAt(UPDATED_AT); + fixture.client().getLedger().addEntry(entry); + + var proto = saveProto(fixture.client()); + var loaded = load(wrap(proto)); + var reloadedEntry = loaded.getLedger().getEntries().get(0); + + assertThat(reloadedEntry.getUUID(), is(entry.getUUID())); + assertThat(reloadedEntry.getType(), is(LedgerEntryType.DIVIDENDS)); + assertThat(reloadedEntry.getDateTime(), is(DATE_TIME)); + assertThat(reloadedEntry.getNote(), is("protobuf load note")); + assertThat(reloadedEntry.getSource(), is("protobuf load source")); + assertThat(reloadedEntry.getUpdatedAt(), is(UPDATED_AT)); + assertThat(reloadedEntry.getParameters().stream().map(parameter -> parameter.getType()).toList(), + is(List.of(LedgerParameterType.EVENT_REFERENCE, LedgerParameterType.CORPORATE_ACTION_KIND))); + assertThat(reloadedEntry.getParameters().stream().map(parameter -> parameter.getValue()).toList(), + is(List.of("event-reference", "SPIN_OFF"))); + assertThat(reloadedEntry.getPostings().stream().map(LedgerPosting::getUUID).toList(), + is(List.of(cashPosting.getUUID(), feePosting.getUUID()))); + assertThat(reloadedEntry.getPostings().get(0).getParameters().stream() + .map(parameter -> parameter.getType()).toList(), + is(List.of(LedgerParameterType.EX_DATE, LedgerParameterType.FAIR_MARKET_VALUE))); + assertThat(reloadedEntry.getPostings().get(1).getParameters().get(0).getType(), + is(LedgerParameterType.TARGET_SECURITY)); + assertSame(loaded.getSecurities().get(0), reloadedEntry.getPostings().get(1).getParameters().get(0).getValue()); + assertThat(reloadedEntry.getProjectionRefs().get(0).getUUID(), is(projection.getUUID())); + assertThat(reloadedEntry.getProjectionRefs().get(0).getPrimaryMembership().orElseThrow().getPostingUUID(), + is(cashPosting.getUUID())); + assertThat(reloadedEntry.getProjectionRefs().get(0) + .getMembershipsByRole(ProjectionMembershipRole.GROUP_ANCHOR).get(0).getPostingUUID(), + is(feePosting.getUUID())); + assertThat(saveProto(loaded).getLedger(), is(proto.getLedger())); + assertValid(loaded); + } + + /** + * Verifies that protobuf save writes derived shadows for cross-entry families. + * Those shadows must mirror the ledger without becoming independent transaction truth. + */ + @Test + public void testSaveWritesDerivedShadowsForCrossEntryFamilies() throws IOException + { + var fixture = fixture(); + var creator = new LedgerTransactionCreator(fixture.client()); + var units = LedgerCreationUnits.of(LedgerCreationUnit.fee(money(3), + LedgerForexAmount.of(Money.of(CurrencyUnit.USD, Values.Amount.factorize(6)), + new BigDecimal("0.5000")))); + + var buy = creator.createBuy(metadata(), cashLeg(fixture.account(), 100), + securityLeg(fixture.portfolio(), fixture.security(), 5, 100), units) + .getEntry(); + var sell = creator.createSell(metadata(), cashLeg(fixture.account(), 50), + securityLeg(fixture.portfolio(), fixture.security(), 2, 50), LedgerCreationUnits.none()) + .getEntry(); + var cashTransfer = creator.createAccountTransfer(metadata(), + LedgerCashTransferLeg.of(fixture.account(), money(10)), + LedgerCashTransferLeg.of(fixture.otherAccount(), money(10))) + .getEntry(); + var securityTransfer = creator.createPortfolioTransfer(metadata(), + LedgerPortfolioTransferSecurity.of(fixture.security(), Values.Share.factorize(3)), + LedgerPortfolioTransferLeg.of(fixture.portfolio(), money(30)), + LedgerPortfolioTransferLeg.of(fixture.otherPortfolio(), money(30))).getEntry(); + var delivery = creator.createOutboundDelivery(metadata(), + LedgerDeliveryLeg.of(fixture.portfolio(), + LedgerSecurityQuantity.of(fixture.security(), Values.Share.factorize(1)), + money(20))) + .getEntry(); + var inboundDelivery = creator.createInboundDelivery(metadata(), + LedgerDeliveryLeg.of(fixture.otherPortfolio(), + LedgerSecurityQuantity.of(fixture.security(), Values.Share.factorize(4)), + money(80))) + .getEntry(); + + var proto = saveProto(fixture.client()); + var buyShadow = transaction(proto, PTransaction.Type.PURCHASE); + var sellShadow = transaction(proto, PTransaction.Type.SALE); + var cashTransferShadow = transaction(proto, PTransaction.Type.CASH_TRANSFER); + var securityTransferShadow = transaction(proto, PTransaction.Type.SECURITY_TRANSFER); + var outboundDeliveryShadow = transaction(proto, PTransaction.Type.OUTBOUND_DELIVERY); + var inboundDeliveryShadow = transaction(proto, PTransaction.Type.INBOUND_DELIVERY); + + assertThat(proto.getLedger().getEntriesCount(), is(6)); + assertThat(proto.getTransactionsCount(), is(6)); + + assertCommonShadowFields(buyShadow, projectionUUID(buy, LedgerProjectionRole.PORTFOLIO), + PTransaction.Type.PURCHASE, 100); + assertThat(buyShadow.getPortfolio(), is(fixture.portfolio().getUUID())); + assertThat(buyShadow.getAccount(), is(fixture.account().getUUID())); + assertThat(buyShadow.getOtherUuid(), is(projectionUUID(buy, LedgerProjectionRole.ACCOUNT))); + assertThat(buyShadow.getSecurity(), is(fixture.security().getUUID())); + assertThat(buyShadow.getShares(), is(Values.Share.factorize(5))); + assertThat(buyShadow.getUnitsCount(), is(1)); + assertUnit(buyShadow.getUnits(0), Transaction.Unit.Type.FEE, 3, CurrencyUnit.USD, 6, new BigDecimal("0.5000")); + + assertCommonShadowFields(sellShadow, projectionUUID(sell, LedgerProjectionRole.PORTFOLIO), + PTransaction.Type.SALE, 50); + assertThat(sellShadow.getPortfolio(), is(fixture.portfolio().getUUID())); + assertThat(sellShadow.getAccount(), is(fixture.account().getUUID())); + assertThat(sellShadow.getOtherUuid(), is(projectionUUID(sell, LedgerProjectionRole.ACCOUNT))); + assertThat(sellShadow.getSecurity(), is(fixture.security().getUUID())); + assertThat(sellShadow.getShares(), is(Values.Share.factorize(2))); + + assertCommonShadowFields(cashTransferShadow, projectionUUID(cashTransfer, LedgerProjectionRole.SOURCE_ACCOUNT), + PTransaction.Type.CASH_TRANSFER, 10); + assertThat(cashTransferShadow.getAccount(), is(fixture.account().getUUID())); + assertThat(cashTransferShadow.getOtherAccount(), is(fixture.otherAccount().getUUID())); + assertThat(cashTransferShadow.getOtherUuid(), + is(projectionUUID(cashTransfer, LedgerProjectionRole.TARGET_ACCOUNT))); + + assertCommonShadowFields(securityTransferShadow, + projectionUUID(securityTransfer, LedgerProjectionRole.SOURCE_PORTFOLIO), + PTransaction.Type.SECURITY_TRANSFER, 30); + assertThat(securityTransferShadow.getPortfolio(), is(fixture.portfolio().getUUID())); + assertThat(securityTransferShadow.getOtherPortfolio(), is(fixture.otherPortfolio().getUUID())); + assertThat(securityTransferShadow.getOtherUuid(), + is(projectionUUID(securityTransfer, LedgerProjectionRole.TARGET_PORTFOLIO))); + assertThat(securityTransferShadow.getSecurity(), is(fixture.security().getUUID())); + assertThat(securityTransferShadow.getShares(), is(Values.Share.factorize(3))); + + assertCommonShadowFields(outboundDeliveryShadow, projectionUUID(delivery, LedgerProjectionRole.DELIVERY_OUTBOUND), + PTransaction.Type.OUTBOUND_DELIVERY, 20); + assertThat(outboundDeliveryShadow.getPortfolio(), is(fixture.portfolio().getUUID())); + assertThat(outboundDeliveryShadow.getSecurity(), is(fixture.security().getUUID())); + assertThat(outboundDeliveryShadow.getShares(), is(Values.Share.factorize(1))); + + assertCommonShadowFields(inboundDeliveryShadow, + projectionUUID(inboundDelivery, LedgerProjectionRole.DELIVERY_INBOUND), + PTransaction.Type.INBOUND_DELIVERY, 80); + assertThat(inboundDeliveryShadow.getPortfolio(), is(fixture.otherPortfolio().getUUID())); + assertThat(inboundDeliveryShadow.getSecurity(), is(fixture.security().getUUID())); + assertThat(inboundDeliveryShadow.getShares(), is(Values.Share.factorize(4))); + + var loaded = load(saveBytes(fixture.client())); + + assertThat(loaded.getLedger().getEntries().size(), is(6)); + assertThat(loaded.getAllTransactions().size(), is(6)); + assertThat(loaded.getAccounts().get(0).getTransactions().size(), is(3)); + assertThat(loaded.getPortfolios().get(0).getTransactions().size(), is(4)); + assertValid(loaded); + } + + /** + * Verifies that protobuf files without ledger truth migrate legacy rows on load. + * The resulting transactions must be materialized from newly created ledger entries. + */ + @Test + public void testLoadWithoutLedgerMigratesLegacyRows() throws IOException + { + var fixture = fixture(); + var transaction = new AccountTransaction(AccountTransaction.Type.DEPOSIT); + transaction.setDateTime(DATE_TIME); + transaction.setCurrencyCode(CurrencyUnit.EUR); + transaction.setAmount(Values.Amount.factorize(11)); + transaction.setUpdatedAt(UPDATED_AT); + fixture.account().addTransaction(transaction); + + var oldProto = saveProto(fixture.client()).toBuilder().clearLedger().build(); + var loaded = load(wrap(oldProto)); + + assertThat(loaded.getLedger().getEntries().size(), is(1)); + assertThat(loaded.getLedger().getEntries().get(0).getType(), is(LedgerEntryType.DEPOSIT)); + assertThat(loaded.getAccounts().get(0).getTransactions().size(), is(1)); + assertThat(loaded.getAccounts().get(0).getTransactions().get(0), instanceOf(LedgerBackedTransaction.class)); + assertThat(loaded.getAccounts().get(0).getTransactions().get(0).getUUID(), is(transaction.getUUID())); + assertValid(loaded); + } + + /** + * Verifies that legacy cross-entry families migrate into ledger entries when protobuf has no ledger truth. + * Each business event must become one ledger entry with derived projections. + */ + @Test + public void testLoadWithoutLedgerMigratesLegacyCrossEntryFamilies() throws IOException + { + var fixture = fixture(); + var creator = new LedgerTransactionCreator(fixture.client()); + var buy = creator.createBuy(metadata(), cashLeg(fixture.account(), 100), + securityLeg(fixture.portfolio(), fixture.security(), 5, 100), LedgerCreationUnits.none()) + .getEntry(); + var cashTransfer = creator.createAccountTransfer(metadata(), + LedgerCashTransferLeg.of(fixture.account(), money(10)), + LedgerCashTransferLeg.of(fixture.otherAccount(), money(10))).getEntry(); + var securityTransfer = creator.createPortfolioTransfer(metadata(), + LedgerPortfolioTransferSecurity.of(fixture.security(), Values.Share.factorize(3)), + LedgerPortfolioTransferLeg.of(fixture.portfolio(), money(30)), + LedgerPortfolioTransferLeg.of(fixture.otherPortfolio(), money(30))).getEntry(); + var delivery = creator.createInboundDelivery(metadata(), + LedgerDeliveryLeg.of(fixture.otherPortfolio(), + LedgerSecurityQuantity.of(fixture.security(), Values.Share.factorize(4)), + money(80))) + .getEntry(); + + var oldProto = saveProto(fixture.client()).toBuilder().clearLedger().build(); + var loaded = load(wrap(oldProto)); + + assertThat(loaded.getLedger().getEntries().size(), is(4)); + assertThat(loaded.getAllTransactions().size(), is(4)); + assertProjectionUUIDs(loaded, LedgerEntryType.BUY, projectionUUID(buy, LedgerProjectionRole.ACCOUNT), + projectionUUID(buy, LedgerProjectionRole.PORTFOLIO)); + assertProjectionUUIDs(loaded, LedgerEntryType.CASH_TRANSFER, + projectionUUID(cashTransfer, LedgerProjectionRole.SOURCE_ACCOUNT), + projectionUUID(cashTransfer, LedgerProjectionRole.TARGET_ACCOUNT)); + assertProjectionUUIDs(loaded, LedgerEntryType.SECURITY_TRANSFER, + projectionUUID(securityTransfer, LedgerProjectionRole.SOURCE_PORTFOLIO), + projectionUUID(securityTransfer, LedgerProjectionRole.TARGET_PORTFOLIO)); + assertProjectionUUIDs(loaded, LedgerEntryType.DELIVERY_INBOUND, + projectionUUID(delivery, LedgerProjectionRole.DELIVERY_INBOUND)); + + var loadedAccountBuy = ledgerBacked(loaded.getAccounts().get(0).getTransactions(), + projectionUUID(buy, LedgerProjectionRole.ACCOUNT)); + var loadedPortfolioBuy = ledgerBacked(loaded.getPortfolios().get(0).getTransactions(), + projectionUUID(buy, LedgerProjectionRole.PORTFOLIO)); + assertSame(loadedPortfolioBuy, loadedAccountBuy.getCrossEntry().getCrossTransaction(loadedAccountBuy)); + assertSame(loaded.getPortfolios().get(0), loadedAccountBuy.getCrossEntry().getCrossOwner(loadedAccountBuy)); + + var loadedCashTransferOut = ledgerBacked(loaded.getAccounts().get(0).getTransactions(), + projectionUUID(cashTransfer, LedgerProjectionRole.SOURCE_ACCOUNT)); + var loadedCashTransferIn = ledgerBacked(loaded.getAccounts().get(1).getTransactions(), + projectionUUID(cashTransfer, LedgerProjectionRole.TARGET_ACCOUNT)); + assertThat(((AccountTransaction) loadedCashTransferOut).getType(), is(AccountTransaction.Type.TRANSFER_OUT)); + assertThat(((AccountTransaction) loadedCashTransferIn).getType(), is(AccountTransaction.Type.TRANSFER_IN)); + assertSame(loadedCashTransferIn, loadedCashTransferOut.getCrossEntry().getCrossTransaction(loadedCashTransferOut)); + assertSame(loaded.getAccounts().get(1), loadedCashTransferOut.getCrossEntry().getCrossOwner(loadedCashTransferOut)); + + var loadedSecurityTransferOut = ledgerBacked(loaded.getPortfolios().get(0).getTransactions(), + projectionUUID(securityTransfer, LedgerProjectionRole.SOURCE_PORTFOLIO)); + var loadedSecurityTransferIn = ledgerBacked(loaded.getPortfolios().get(1).getTransactions(), + projectionUUID(securityTransfer, LedgerProjectionRole.TARGET_PORTFOLIO)); + assertThat(((PortfolioTransaction) loadedSecurityTransferOut).getType(), is(PortfolioTransaction.Type.TRANSFER_OUT)); + assertThat(((PortfolioTransaction) loadedSecurityTransferIn).getType(), is(PortfolioTransaction.Type.TRANSFER_IN)); + assertSame(loadedSecurityTransferIn, + loadedSecurityTransferOut.getCrossEntry().getCrossTransaction(loadedSecurityTransferOut)); + assertSame(loaded.getPortfolios().get(1), + loadedSecurityTransferOut.getCrossEntry().getCrossOwner(loadedSecurityTransferOut)); + assertValid(loaded); + } + + /** + * Verifies that a legacy dividend with ex-date, units, and forex migrates from protobuf. + * The loaded ledger-backed dividend must preserve those facts. + */ + @Test + public void testLoadWithoutLedgerMigratesLegacyDividendWithExDateUnitsAndForex() throws IOException + { + var fixture = fixture(); + var units = LedgerCreationUnits.of(LedgerCreationUnit.fee(money(2)), + LedgerCreationUnit.tax(money(3), + LedgerForexAmount.of(Money.of(CurrencyUnit.USD, Values.Amount.factorize(6)), + new BigDecimal("0.5000"))), + LedgerCreationUnit.grossValue(money(120), + LedgerForexAmount.of(Money.of(CurrencyUnit.USD, Values.Amount.factorize(240)), + new BigDecimal("0.5000")))); + var dividend = new LedgerTransactionCreator(fixture.client()) + .createDividend(metadata(), + LedgerDividend.withExDate( + LedgerAccountCashLeg.of(fixture.account(), money(100), + LedgerForexAmount.of( + Money.of(CurrencyUnit.USD, + Values.Amount + .factorize(200)), + new BigDecimal("0.5000"))), + LedgerOptionalSecurity.of(fixture.security()), units, EX_DATE)) + .getEntry(); + + var oldProto = saveProto(fixture.client()).toBuilder().clearLedger().build(); + var loaded = load(wrap(oldProto)); + var entry = onlyEntry(loaded, LedgerEntryType.DIVIDENDS); + var projection = (AccountTransaction) ledgerBacked(loaded.getAccounts().get(0).getTransactions(), + projectionUUID(dividend, LedgerProjectionRole.ACCOUNT)); + + assertThat(loaded.getLedger().getEntries().size(), is(1)); + assertThat(loaded.getAllTransactions().size(), is(1)); + assertSame(loaded.getSecurities().get(0), projection.getSecurity()); + assertThat(projection.getExDate(), is(EX_DATE)); + assertThat(projection.getUnits().count(), is(3L)); + assertTrue(entry.getPostings().stream().anyMatch(posting -> posting.getType() == LedgerPostingType.TAX + && Long.valueOf(Values.Amount.factorize(6)).equals(posting.getForexAmount()) + && CurrencyUnit.USD.equals(posting.getForexCurrency()) + && new BigDecimal("0.5000").compareTo(posting.getExchangeRate()) == 0)); + assertTrue(entry.getPostings().stream().anyMatch(posting -> posting.getType() == LedgerPostingType.GROSS_VALUE + && Long.valueOf(Values.Amount.factorize(240)).equals(posting.getForexAmount()) + && CurrencyUnit.USD.equals(posting.getForexCurrency()) + && new BigDecimal("0.5000").compareTo(posting.getExchangeRate()) == 0)); + assertValid(loaded); + } + + /** + * Verifies that protobuf load with ledger truth ignores shadow rows as source truth. + * Compatibility shadows must not remigrate into duplicate ledger entries. + */ + @Test + public void testLoadWithLedgerIgnoresShadowsAsSourceTruth() throws IOException + { + var fixture = fixture(); + new LedgerTransactionCreator(fixture.client()).createDeposit(metadata(), cashLeg(fixture.account(), 100)); + var loaded = load(saveBytes(fixture.client())); + + assertThat(loaded.getLedger().getEntries().size(), is(1)); + assertThat(loaded.getAccounts().get(0).getTransactions().size(), is(1)); + assertThat(loaded.getAccounts().get(0).getTransactions().get(0), instanceOf(LedgerBackedTransaction.class)); + assertThat(loaded.getAllTransactions().size(), is(1)); + assertValid(loaded); + } + + /** + * Verifies that ledger configuration identifiers are written to protobuf. + * The serialized file must carry the vocabulary needed to load ledger entries. + */ + @Test + public void testLedgerConfigIdentifiersAreWritten() throws IOException + { + var fixture = fixtureWithDividendAndExDate(); + var proto = saveProto(fixture.client()); + var entry = proto.getLedger().getEntries(0); + var posting = entry.getPostings(0); + var parameter = posting.getParametersList().stream() + .filter(candidate -> candidate.getTypeCode().equals(LedgerParameterType.EX_DATE.getCode())) + .findFirst().orElseThrow(); + + assertTrue(entry.hasTypeId()); + assertThat(entry.getTypeId(), is(LedgerEntryType.DIVIDENDS.getProtobufId())); + assertTrue(posting.hasTypeCode()); + assertThat(posting.getTypeCode(), is(LedgerPostingType.CASH.getCode())); + assertTrue(parameter.hasTypeCode()); + assertThat(parameter.getTypeCode(), is(LedgerParameterType.EX_DATE.getCode())); + } + + /** + * Verifies that entry-level parameters are stored under the ledger entry in protobuf. + * Parameters must not be misplaced into posting or shadow data. + */ + @Test + public void testEntryLevelParametersAreWrittenUnderLedgerEntry() throws IOException + { + var fixture = fixtureWithDividendAndExDate(); + + fixture.client().getLedger().getEntries().get(0) + .addParameter(LedgerParameter.ofString( + LedgerParameterType.CORPORATE_ACTION_KIND, "SPIN_OFF")); + + var proto = saveProto(fixture.client()); + var entry = proto.getLedger().getEntries(0); + var parameter = entry.getParameters(0); + + assertThat(PLedgerEntry.getDescriptor().findFieldByName("parameters").getNumber(), is(10)); + assertThat(entry.getParametersCount(), is(1)); + assertThat(parameter.getTypeCode(), is(LedgerParameterType.CORPORATE_ACTION_KIND.getCode())); + assertThat(parameter.getValueKind(), + is(PLedgerParameterValueKind.LEDGER_PARAMETER_VALUE_KIND_STRING)); + assertThat(parameter.getStringValue(), is("SPIN_OFF")); + } + + /** + * Verifies that an unknown ledger entry type id fails with a clear protobuf load error. + * Unsupported persisted ledger vocabulary must not be interpreted silently. + */ + @Test + public void testUnknownLedgerEntryTypeIdFailsClearly() throws IOException + { + var fixture = fixtureWithDividendAndExDate(); + var proto = saveProto(fixture.client()).toBuilder(); + + proto.getLedgerBuilder().getEntriesBuilder(0).setTypeId(999999); + + assertUnknownTypeIdFailure(proto.build(), "LedgerEntryType", 999999); + } + + /** + * Verifies that an unknown ledger posting type code fails with a clear protobuf load error. + * Unsupported posting vocabulary must not be guessed during load. + */ + @Test + public void testUnknownLedgerPostingTypeCodeFailsClearly() throws IOException + { + var fixture = fixtureWithDividendAndExDate(); + var proto = saveProto(fixture.client()).toBuilder(); + + proto.getLedgerBuilder().getEntriesBuilder(0).getPostingsBuilder(0).setTypeCode("UNKNOWN_POSTING_TYPE"); + + assertUnknownTypeCodeFailure(proto.build(), "LedgerPostingType", "UNKNOWN_POSTING_TYPE"); + } + + /** + * Verifies that a persisted ledger posting without a type code fails during protobuf load. + * A missing posting type must not be accepted as a default posting shape. + */ + @Test + public void testMissingLedgerPostingTypeCodeFailsClearly() throws IOException + { + var fixture = fixtureWithDividendAndExDate(); + var proto = saveProto(fixture.client()).toBuilder(); + + proto.getLedgerBuilder().getEntriesBuilder(0).getPostingsBuilder(0).clearTypeCode(); + + assertMissingTypeCodeFailure(proto.build(), "LedgerPostingType"); + } + + /** + * Verifies that an unknown ledger parameter type code fails with a clear protobuf load error. + * Unsupported parameter vocabulary must not be guessed during load. + */ + @Test + public void testUnknownLedgerParameterTypeCodeFailsClearly() throws IOException + { + var fixture = fixtureWithDividendAndExDate(); + var proto = saveProto(fixture.client()).toBuilder(); + var parameter = proto.getLedgerBuilder().getEntriesBuilder(0).getPostingsBuilder(0).getParametersBuilder(0); + + parameter.setTypeCode("UNKNOWN_PARAMETER_TYPE"); + + assertUnknownTypeCodeFailure(proto.build(), "LedgerParameterType", "UNKNOWN_PARAMETER_TYPE"); + } + + /** + * Verifies that a persisted ledger parameter without a type code fails during protobuf load. + * A missing parameter type must not be accepted as a default parameter shape. + */ + @Test + public void testMissingLedgerParameterTypeCodeFailsClearly() throws IOException + { + var fixture = fixtureWithDividendAndExDate(); + var proto = saveProto(fixture.client()).toBuilder(); + var parameter = proto.getLedgerBuilder().getEntriesBuilder(0).getPostingsBuilder(0).getParametersBuilder(0); + + parameter.clearTypeCode(); + + assertMissingTypeCodeFailure(proto.build(), "LedgerParameterType"); + } + + /** + * Verifies that boolean and local-date ledger parameter fields exist in the protobuf model. + * These fields are part of the persisted ledger fact vocabulary. + */ + @Test + public void testLedgerParameterBooleanAndLocalDateProtobufFieldsAreAvailable() + { + assertThat(PLedgerParameterValueKind.forNumber(11), + is(PLedgerParameterValueKind.LEDGER_PARAMETER_VALUE_KIND_BOOLEAN)); + assertThat(PLedgerParameterValueKind.forNumber(12), + is(PLedgerParameterValueKind.LEDGER_PARAMETER_VALUE_KIND_LOCAL_DATE)); + assertThat(PLedgerParameter.getDescriptor().findFieldByName("booleanValue").getNumber(), is(14)); + assertThat(PLedgerParameter.getDescriptor().findFieldByName("localDateValue").getNumber(), is(15)); + } + + /** + * Verifies that a boolean ledger parameter must use the matching protobuf value field. + * Loading must reject a mismatched value shape instead of coercing it. + */ + @Test + public void testBooleanLedgerParameterProtobufRequiresMatchingValueField() throws IOException + { + var proto = fixtureWithDividendAndExDateValueKind( + PLedgerParameterValueKind.LEDGER_PARAMETER_VALUE_KIND_BOOLEAN); + + assertLedgerParameterValueKindFailure(proto.build(), LedgerDiagnosticCode.LEDGER_PERSIST_009 + .message(Messages.LedgerProtobufBooleanParameterMissingBooleanValue)); + } + + /** + * Verifies that a local-date ledger parameter must use the matching protobuf value field. + * Loading must reject a mismatched value shape instead of coercing it. + */ + @Test + public void testLocalDateLedgerParameterProtobufRequiresMatchingValueField() throws IOException + { + var proto = fixtureWithDividendAndExDateValueKind( + PLedgerParameterValueKind.LEDGER_PARAMETER_VALUE_KIND_LOCAL_DATE); + + assertLedgerParameterValueKindFailure(proto.build(), LedgerDiagnosticCode.LEDGER_PERSIST_010 + .message(Messages.LedgerProtobufLocalDateParameterMissingLocalDateValue)); + } + + /** + * Verifies that boolean parameter serialization follows the ledger type policy. + * The persisted value must roundtrip through the boolean-specific field. + */ + @Test + public void testBooleanLedgerParameterProtobufUsesTypePolicy() throws IOException + { + var proto = fixtureWithDividendAndExDateValueKind( + PLedgerParameterValueKind.LEDGER_PARAMETER_VALUE_KIND_BOOLEAN); + + proto.getLedgerBuilder().getEntriesBuilder(0).getPostingsBuilder(0).getParametersBuilder(0) + .setBooleanValue(true); + + assertLedgerParameterValueKindFailure(proto.build(), LedgerDiagnosticCode.LEDGER_CORE_021 + .message("EX_DATE does not support BOOLEAN; expected LOCAL_DATE_TIME")); + } + + /** + * Verifies that local-date parameter serialization follows the ledger type policy. + * The persisted value must roundtrip through the local-date-specific field. + */ + @Test + public void testLocalDateLedgerParameterProtobufUsesTypePolicy() throws IOException + { + var proto = fixtureWithDividendAndExDateValueKind( + PLedgerParameterValueKind.LEDGER_PARAMETER_VALUE_KIND_LOCAL_DATE); + + proto.getLedgerBuilder().getEntriesBuilder(0).getPostingsBuilder(0).getParametersBuilder(0) + .setLocalDateValue(LocalDate.of(2026, 1, 2).toEpochDay()); + + assertLedgerParameterValueKindFailure(proto.build(), LedgerDiagnosticCode.LEDGER_CORE_021 + .message("EX_DATE does not support LOCAL_DATE; expected LOCAL_DATE_TIME")); + } + + /** + * Verifies that plan execution refs roundtrip through protobuf. + * The saved refs must still identify the generated ledger-backed booking after load. + */ + @Test + public void testInvestmentPlanLedgerExecutionRefsRoundtrip() throws IOException + { + var fixture = fixture(); + var buy = new LedgerTransactionCreator(fixture.client()).createBuy(metadata(), cashLeg(fixture.account(), 100), + securityLeg(fixture.portfolio(), fixture.security(), 5, 100), LedgerCreationUnits.none()) + .getEntry(); + LedgerProjectionService.materialize(fixture.client()); + var portfolioProjection = fixture.portfolio().getTransactions().stream() + .filter(LedgerBackedTransaction.class::isInstance).findFirst().orElseThrow(); + var plan = plan(fixture); + + plan.getTransactions().add(portfolioProjection); + fixture.client().addPlan(plan); + + var proto = saveProto(fixture.client()); + + assertThat(proto.getPlans(0).getTransactionsCount(), is(0)); + assertThat(proto.getPlans(0).getLedgerExecutionRefsCount(), is(1)); + assertThat(proto.getPlans(0).getLedgerExecutionRefs(0).getLedgerEntryUUID(), is(buy.getUUID())); + assertThat(proto.getPlans(0).getLedgerExecutionRefs(0).getProjectionUUID(), is(portfolioProjection.getUUID())); + assertThat(proto.getPlans(0).getLedgerExecutionRefs(0).getProjectionRole(), + is(PLedgerProjectionRole.LEDGER_PROJECTION_ROLE_PORTFOLIO)); + + var loaded = load(wrap(proto)); + var loadedPlan = loaded.getPlans().get(0); + + assertThat(loadedPlan.getTransactions().size(), is(0)); + assertThat(loadedPlan.getLedgerExecutionRefs().size(), is(1)); + assertThat(loadedPlan.getTransactions(loaded).size(), is(1)); + assertThat(loadedPlan.getTransactions(loaded).get(0).getOwner(), is(loaded.getPortfolios().get(0))); + assertThat(loadedPlan.getTransactions(loaded).get(0).getTransaction().getUUID(), + is(portfolioProjection.getUUID())); + } + + /** + * Verifies that ambiguous plan execution refs are rejected during protobuf load. + * A plan must not silently pick one of several possible projections. + */ + @Test + public void testAmbiguousInvestmentPlanLedgerExecutionRefIsRejected() throws IOException + { + var fixture = fixture(); + var buy = new LedgerTransactionCreator(fixture.client()).createBuy(metadata(), cashLeg(fixture.account(), 100), + securityLeg(fixture.portfolio(), fixture.security(), 5, 100), LedgerCreationUnits.none()) + .getEntry(); + var plan = plan(fixture); + + plan.addLedgerExecutionRef(new InvestmentPlan.LedgerExecutionRef(buy.getUUID(), null, null)); + fixture.client().addPlan(plan); + + var loaded = load(saveBytes(fixture.client())); + + assertThrows(IllegalArgumentException.class, () -> loaded.getPlans().get(0).getTransactions(loaded)); + } + + /** + * Verifies that invalid ledger truth loads without protobuf shadow remigration. + * Invalid persisted ledger entries must not be masked by compatibility rows. + */ + @Test + public void testInvalidLedgerTruthLoadsWithoutShadowRemigration() throws IOException + { + var fixture = fixture(); + new LedgerTransactionCreator(fixture.client()).createDeposit(metadata(), cashLeg(fixture.account(), 100)); + + var proto = saveProto(fixture.client()).toBuilder(); + var entry = proto.getLedgerBuilder().getEntriesBuilder(0); + entry.addPostings(entry.getPostings(0)); + var loaded = load(wrap(proto.build())); + var result = LedgerStructuralValidator.validate(loaded.getLedger()); + + assertFalse(result.isOK()); + assertTrue(result.hasIssue(LedgerStructuralValidator.IssueCode.DUPLICATE_POSTING_UUID)); + assertThat(loaded.getLedger().getEntries().size(), is(fixture.client().getLedger().getEntries().size())); + assertTrue(loaded.getAllTransactions().isEmpty()); + } + + /** + * Verifies that invalid ledger protobuf save failures include formatted diagnostics. + * The caller must see the concrete validation issue. + */ + @Test + public void testInvalidLedgerProtobufSaveExceptionUsesFormattedDiagnostics() throws IOException + { + var fixture = fixture(); + var created = new LedgerTransactionCreator(fixture.client()).createDeposit(metadata(), + cashLeg(fixture.account(), 100)); + + created.getEntry().getPostings().get(0).setCurrency(null); + + var exception = assertThrows(UnsupportedOperationException.class, () -> saveBytes(fixture.client())); + + assertTrue(exception.getMessage(), exception.getMessage().contains("[LEDGER-PERSIST-002] ")); + assertTrue(exception.getMessage(), exception.getMessage().contains("[POSTING_CURRENCY_REQUIRED] ")); + assertTrue(exception.getMessage(), exception.getMessage().contains("\n Posting:\n")); + assertTrue(exception.getMessage(), exception.getMessage().contains("Currency: ")); + assertTrue(exception.getMessage(), exception.getMessage() + .contains(Messages.LedgerDiagnosticMessageFormatterTransactionContext + ":\n")); + assertTrue(exception.getMessage(), exception.getMessage() + .contains(Messages.LedgerDiagnosticMessageFormatterDate + ": 2026-01-02T03:04")); + assertTrue(exception.getMessage(), exception.getMessage() + .contains(Messages.LedgerDiagnosticMessageFormatterType + ": DEPOSIT")); + assertTrue(exception.getMessage(), exception.getMessage() + .contains(Messages.LedgerDiagnosticMessageFormatterAccount + ": Account")); + } + + /** + * Verifies that unsupported legacy rows stay compatible when ledger truth exists. + * Existing shadows must not override or duplicate the persisted ledger facts. + */ + @Test + public void testUnsupportedLegacyRowsRemainCompatibleWhenLedgerTruthExists() throws IOException + { + var fixture = fixture(); + new LedgerTransactionCreator(fixture.client()).createDeposit(metadata(), cashLeg(fixture.account(), 100)); + + var legacyTransaction = new AccountTransaction(AccountTransaction.Type.FEES); + legacyTransaction.setDateTime(DATE_TIME); + legacyTransaction.setCurrencyCode(CurrencyUnit.EUR); + legacyTransaction.setAmount(Values.Amount.factorize(7)); + legacyTransaction.setUpdatedAt(UPDATED_AT); + fixture.account().addTransaction(legacyTransaction); + + var loaded = load(saveBytes(fixture.client())); + + assertThat(loaded.getLedger().getEntries().size(), is(1)); + assertThat(loaded.getAccounts().get(0).getTransactions().size(), is(2)); + assertThat(loaded.getAccounts().get(0).getTransactions().stream() + .filter(LedgerBackedTransaction.class::isInstance).count(), is(1L)); + assertThat(loaded.getAccounts().get(0).getTransactions().stream() + .filter(t -> legacyTransaction.getUUID().equals(t.getUUID())).count(), is(1L)); + } + + private ClientFixture fixture() + { + var client = new Client(); + var account = new Account(); + account.setName("Account"); + account.setCurrencyCode(CurrencyUnit.EUR); + account.setUpdatedAt(UPDATED_AT); + var otherAccount = new Account(); + otherAccount.setName("Other Account"); + otherAccount.setCurrencyCode(CurrencyUnit.EUR); + otherAccount.setUpdatedAt(UPDATED_AT); + var portfolio = new Portfolio(); + portfolio.setName("Portfolio"); + portfolio.setUpdatedAt(UPDATED_AT); + var otherPortfolio = new Portfolio(); + otherPortfolio.setName("Other Portfolio"); + otherPortfolio.setUpdatedAt(UPDATED_AT); + var security = new Security("Security", CurrencyUnit.EUR); + security.setUpdatedAt(UPDATED_AT); + + client.addAccount(account); + client.addAccount(otherAccount); + client.addPortfolio(portfolio); + client.addPortfolio(otherPortfolio); + client.addSecurity(security); + + return new ClientFixture(client, account, otherAccount, portfolio, otherPortfolio, security); + } + + private ClientFixture fixtureWithDividendAndExDate() + { + var fixture = fixture(); + + new LedgerTransactionCreator(fixture.client()).createDividend(metadata(), + LedgerDividend.withExDate(cashLeg(fixture.account(), 100), + LedgerOptionalSecurity.of(fixture.security()), LedgerCreationUnits.none(), + EX_DATE)); + + return fixture; + } + + private LedgerTransactionMetadata metadata() + { + return LedgerTransactionMetadata.of(DATE_TIME).withNote("note").withSource("source"); + } + + private LedgerAccountCashLeg cashLeg(Account account, int amount) + { + return LedgerAccountCashLeg.of(account, money(amount)); + } + + private LedgerPortfolioSecurityLeg securityLeg(Portfolio portfolio, Security security, int shares, int amount) + { + return LedgerPortfolioSecurityLeg.of(portfolio, + LedgerSecurityQuantity.of(security, Values.Share.factorize(shares)), money(amount)); + } + + private Money money(int amount) + { + return Money.of(CurrencyUnit.EUR, Values.Amount.factorize(amount)); + } + + private InvestmentPlan plan(ClientFixture fixture) + { + var plan = new InvestmentPlan("Plan"); + + plan.setType(InvestmentPlan.Type.PURCHASE_OR_DELIVERY); + plan.setSecurity(fixture.security()); + plan.setPortfolio(fixture.portfolio()); + plan.setAccount(fixture.account()); + plan.setStart(LocalDate.of(2026, 1, 1)); + plan.setInterval(1); + plan.setAmount(Values.Amount.factorize(100)); + + return plan; + } + + private PTransaction transaction(PClient client, PTransaction.Type type) + { + return client.getTransactionsList().stream().filter(transaction -> transaction.getType() == type).findFirst() + .orElseThrow(); + } + + private void assertCommonShadowFields(PTransaction transaction, String uuid, PTransaction.Type type, int amount) + { + assertThat(transaction.getUuid(), is(uuid)); + assertThat(transaction.getType(), is(type)); + assertThat(transaction.getDate(), is(name.abuchen.portfolio.util.ProtobufUtil.asTimestamp(DATE_TIME))); + assertThat(transaction.getCurrencyCode(), is(CurrencyUnit.EUR)); + assertThat(transaction.getAmount(), is(Values.Amount.factorize(amount))); + assertThat(transaction.getNote(), is("note")); + assertThat(transaction.getSource(), is("source")); + } + + private void assertUnit(name.abuchen.portfolio.model.proto.v1.PTransactionUnit unit, Transaction.Unit.Type type, + int amount, String forexCurrency, int forexAmount, BigDecimal exchangeRate) + { + assertThat(unit.getAmount(), is(Values.Amount.factorize(amount))); + assertThat(unit.getCurrencyCode(), is(CurrencyUnit.EUR)); + assertThat(unit.getFxAmount(), is(Values.Amount.factorize(forexAmount))); + assertThat(unit.getFxCurrencyCode(), is(forexCurrency)); + assertThat(name.abuchen.portfolio.util.ProtobufUtil.fromDecimalValue(unit.getFxRateToBase()), + is(exchangeRate)); + assertThat(unit.getType().name(), is(type.name())); + } + + private void assertProjectionUUIDs(Client client, LedgerEntryType type, String... projectionUUIDs) + { + var actual = onlyEntry(client, type).getProjectionRefs().stream().map(ref -> ref.getUUID()).toList(); + + assertThat(actual.size(), is(projectionUUIDs.length)); + for (String projectionUUID : projectionUUIDs) + assertTrue(actual.contains(projectionUUID)); + } + + private name.abuchen.portfolio.model.ledger.LedgerEntry onlyEntry(Client client, LedgerEntryType type) + { + return client.getLedger().getEntries().stream().filter(entry -> entry.getType() == type).findFirst() + .orElseThrow(); + } + + private Transaction ledgerBacked(List transactions, String uuid) + { + return transactions.stream().filter(LedgerBackedTransaction.class::isInstance) + .filter(transaction -> uuid.equals(transaction.getUUID())).findFirst().orElseThrow(); + } + + private String projectionUUID(name.abuchen.portfolio.model.ledger.LedgerEntry entry, LedgerProjectionRole role) + { + return entry.getProjectionRefs().stream().filter(ref -> ref.getRole() == role).findFirst().orElseThrow() + .getUUID(); + } + + private byte[] saveBytes(Client client) throws IOException + { + var stream = new ByteArrayOutputStream(); + + new ProtobufWriter().save(client, stream); + + return stream.toByteArray(); + } + + private PClient saveProto(Client client) throws IOException + { + return parse(saveBytes(client)); + } + + private PClient parse(byte[] bytes) throws IOException + { + return PClient.parseFrom(new ByteArrayInputStream(bytes, SIGNATURE.length, bytes.length - SIGNATURE.length)); + } + + private byte[] wrap(PClient client) throws IOException + { + var stream = new ByteArrayOutputStream(); + + stream.write(SIGNATURE); + client.writeTo(stream); + + return stream.toByteArray(); + } + + private Client load(byte[] bytes) throws IOException + { + return new ProtobufWriter().load(new ByteArrayInputStream(bytes)); + } + + private void assertUnknownTypeIdFailure(PClient client, String typeName, int id) throws IOException + { + var exception = assertThrows(IllegalArgumentException.class, () -> load(wrap(client))); + + assertTrue(exception.getMessage(), exception.getMessage().contains(typeName)); + assertTrue(exception.getMessage(), exception.getMessage().contains(Integer.toString(id))); + } + + private void assertUnknownTypeCodeFailure(PClient client, String typeName, String code) throws IOException + { + var exception = assertThrows(IllegalArgumentException.class, () -> load(wrap(client))); + + assertTrue(exception.getMessage(), exception.getMessage().contains(typeName)); + assertTrue(exception.getMessage(), exception.getMessage().contains(code)); + } + + private void assertMissingTypeCodeFailure(PClient client, String typeName) throws IOException + { + var exception = assertThrows(IllegalArgumentException.class, () -> load(wrap(client))); + + assertTrue(exception.getMessage(), exception.getMessage().contains("Unknown " + typeName + " code:")); + } + + private void assertLedgerParameterValueKindFailure(PClient client, String message) throws IOException + { + var exception = assertThrows(RuntimeException.class, () -> load(wrap(client))); + + assertTrue(exception.getMessage(), exception.getMessage().contains(message)); + } + + private PClient.Builder fixtureWithDividendAndExDateValueKind(PLedgerParameterValueKind valueKind) + throws IOException + { + var proto = saveProto(fixtureWithDividendAndExDate().client()).toBuilder(); + + proto.getLedgerBuilder().getEntriesBuilder(0).getPostingsBuilder(0).getParametersBuilder(0) + .setValueKind(valueKind); + + return proto; + } + + private void assertValid(Client client) + { + assertTrue(LedgerStructuralValidator.validate(client.getLedger()).isOK()); + } + + private record ClientFixture(Client client, Account account, Account otherAccount, Portfolio portfolio, + Portfolio otherPortfolio, Security security) + { + } +} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerSaveLoadParityTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerSaveLoadParityTest.java new file mode 100644 index 0000000000..d76896ffb9 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerSaveLoadParityTest.java @@ -0,0 +1,1542 @@ +package name.abuchen.portfolio.model; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.EnumSet; +import java.util.List; + +import org.junit.Test; + +import name.abuchen.portfolio.model.Transaction.Unit; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerParameter; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; +import name.abuchen.portfolio.model.ledger.LedgerTransactionMetadata; +import name.abuchen.portfolio.model.ledger.ProjectionMembership; +import name.abuchen.portfolio.model.ledger.ProjectionMembershipRole; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountCashLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerCashTransferLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerCreationUnit; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerCreationUnits; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerDeliveryLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerDividend; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerForexAmount; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerOptionalSecurity; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioSecurityLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferSecurity; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerSecurityQuantity; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerUnitPostingEdit; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerUnitPostingPatch; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerUnitPostingUpdater; +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.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionService; +import name.abuchen.portfolio.model.proto.v1.PClient; +import name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.Values; + +/** + * Tests persistence compatibility for ledger-backed transactions. + * These tests make sure save/load rebuilds runtime rows from ledger truth and keeps compatibility data stable. + */ +@SuppressWarnings("nls") +public class LedgerSaveLoadParityTest +{ + private static final byte[] PROTOBUF_SIGNATURE = new byte[] { 'P', 'P', 'P', 'B', 'V', '1' }; + private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 3, 4, 5, 6); + private static final LocalDateTime EX_DATE = LocalDateTime.of(2026, 2, 27, 0, 0); + private static final Instant UPDATED_AT = Instant.parse("2026-03-04T05:06:07Z"); + + /** + * Verifies that XML save-load-save keeps ledger truth and runtime projections equivalent. + * Repeated persistence must not drift owner lists away from the ledger. + */ + @Test + public void testXmlSaveLoadSavePreservesLedgerTruthAndRuntimeProjectionParity() throws Exception + { + var fixture = parityFixture(); + var expectedLedger = ledgerSnapshot(fixture.client()); + var expectedTransactions = transactionSnapshots(fixture.client()); + var expectedPlans = planSnapshots(fixture.client()); + + var firstXml = saveXml(fixture.client()); + var loaded = loadXml(firstXml); + var secondXml = saveXml(loaded); + var reloaded = loadXml(secondXml); + + assertXmlContainsLedgerTruthOnly(secondXml); + assertParity(reloaded, expectedLedger, expectedTransactions, expectedPlans); + } + + /** + * Verifies that protobuf save-load-save keeps ledger truth and runtime projections equivalent. + * Repeated persistence must not drift owner lists away from the ledger. + */ + @Test + public void testProtobufSaveLoadSavePreservesLedgerTruthAndRuntimeProjectionParity() throws Exception + { + var fixture = parityFixture(); + var expectedLedger = ledgerSnapshot(fixture.client()); + var expectedTransactions = transactionSnapshots(fixture.client()); + var expectedPlans = planSnapshots(fixture.client()); + var expectedMemberships = membershipSnapshots(fixture.client()); + + var loaded = loadProtobuf(saveProtobuf(fixture.client())); + var secondBytes = saveProtobuf(loaded); + var reloaded = loadProtobuf(secondBytes); + var secondProto = parseProtobuf(secondBytes); + + assertThat(secondProto.getLedger().getEntriesCount(), is(8)); + assertThat(secondProto.getTransactionsCount(), is(8)); + assertThat(membershipSnapshots(loaded), is(expectedMemberships)); + assertThat(membershipSnapshots(reloaded), is(expectedMemberships)); + assertParity(reloaded, expectedLedger, expectedTransactions, expectedPlans); + } + + /** + * Verifies that XML and protobuf restore equivalent ledger truth and projections. + * Both formats must materialize the same owner-list views from the persisted ledger. + */ + @Test + public void testXmlAndProtobufRoundtripsRestoreEquivalentLedgerTruthAndProjections() throws Exception + { + var fixture = parityFixture(); + var xmlLoaded = loadXml(saveXml(fixture.client())); + var protobufLoaded = loadProtobuf(saveProtobuf(fixture.client())); + + assertThat(ledgerSnapshot(protobufLoaded), is(ledgerSnapshot(xmlLoaded))); + assertThat(transactionSnapshots(protobufLoaded), is(transactionSnapshots(xmlLoaded))); + assertThat(planSnapshots(protobufLoaded), is(planSnapshots(xmlLoaded))); + } + + /** + * Verifies that ledger parameters remain owned by their entry or posting after both roundtrips. + * Persistence must not move business facts between ledger levels. + */ + @Test + public void testLedgerParametersRemainOwnedByEntryOrPostingAfterXmlAndProtobufRoundtrip() throws Exception + { + var client = new Client(); + var account = register(client, account("Account")); + var security = register(client, security()); + var targetSecurity = register(client, security()); + var entry = new LedgerEntry("entry-parameter-ownership"); + var cashPosting = new LedgerPosting("posting-a"); + var targetedPosting = new LedgerPosting("posting-b"); + var projection = new LedgerProjectionRef("projection-parameter-ownership"); + + entry.setType(LedgerEntryType.DIVIDENDS); + entry.setDateTime(DATE_TIME); + entry.setUpdatedAt(UPDATED_AT); + entry.addParameter(LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_KIND, + "SPIN_OFF")); + entry.addParameter(LedgerParameter.ofBoolean(LedgerParameterType.CASH_IN_LIEU_APPLIED, + Boolean.TRUE)); + + cashPosting.setType(LedgerPostingType.CASH); + cashPosting.setAccount(account); + cashPosting.setSecurity(security); + cashPosting.setAmount(Values.Amount.factorize(10)); + cashPosting.setCurrency(CurrencyUnit.EUR); + cashPosting.addParameter(LedgerParameter.ofLocalDateTime(LedgerParameterType.EX_DATE, + EX_DATE)); + + targetedPosting.setType(LedgerPostingType.FEE); + targetedPosting.setAccount(account); + targetedPosting.setAmount(Values.Amount.factorize(1)); + targetedPosting.setCurrency(CurrencyUnit.EUR); + targetedPosting.addParameter(LedgerParameter.ofSecurity(LedgerParameterType.TARGET_SECURITY, + targetSecurity)); + + entry.addPosting(cashPosting); + entry.addPosting(targetedPosting); + + projection.setRole(LedgerProjectionRole.ACCOUNT); + projection.setAccount(account); + projection.setPrimaryPosting(cashPosting); + entry.addProjectionRef(projection); + + client.getLedger().addEntry(entry); + + assertLedgerParameterOwnership(client, targetSecurity.getUUID()); + + var xmlLoaded = loadXml(saveXml(client)); + + assertLedgerParameterOwnership(xmlLoaded, targetSecurity.getUUID()); + + var protobufLoaded = loadProtobuf(saveProtobuf(client)); + + assertLedgerParameterOwnership(protobufLoaded, targetSecurity.getUUID()); + } + + /** + * Verifies that the new ledger parameter vocabulary roundtrips through XML and protobuf. + * Boolean and local-date facts must survive in both persistence formats. + */ + @Test + public void testNewLedgerParameterVocabularyRoundtripsThroughXmlAndProtobuf() throws Exception + { + var client = new Client(); + var account = register(client, account("Account")); + var portfolio = register(client, portfolio("Portfolio")); + var security = register(client, security()); + var rightSecurity = register(client, security()); + var entry = new LedgerEntry("entry-new-parameter-vocabulary"); + var cashPosting = new LedgerPosting("posting-new-vocabulary-a"); + var feePosting = new LedgerPosting("posting-new-vocabulary-b"); + var projection = new LedgerProjectionRef("projection-new-parameter-vocabulary"); + var recordDate = LocalDate.of(2026, 3, 1); + var nominalValue = money(42); + + entry.setType(LedgerEntryType.DIVIDENDS); + entry.setDateTime(DATE_TIME); + entry.setUpdatedAt(UPDATED_AT); + + cashPosting.setType(LedgerPostingType.CASH); + cashPosting.setAccount(account); + cashPosting.setSecurity(security); + cashPosting.setAmount(Values.Amount.factorize(10)); + cashPosting.setCurrency(CurrencyUnit.EUR); + cashPosting.addParameter(LedgerParameter.ofLocalDate(LedgerParameterType.RECORD_DATE, + recordDate)); + cashPosting.addParameter(LedgerParameter.ofLocalDateTime(LedgerParameterType.EX_DATE, + EX_DATE)); + cashPosting.addParameter(LedgerParameter.ofDecimal(LedgerParameterType.RATIO_NUMERATOR, + new BigDecimal("1.25"))); + cashPosting.addParameter(LedgerParameter.ofBoolean(LedgerParameterType.CASH_IN_LIEU_APPLIED, + Boolean.TRUE)); + cashPosting.addParameter(LedgerParameter.ofMoney(LedgerParameterType.NOMINAL_VALUE, + nominalValue)); + + feePosting.setType(LedgerPostingType.FEE); + feePosting.setAccount(account); + feePosting.setAmount(Values.Amount.factorize(1)); + feePosting.setCurrency(CurrencyUnit.EUR); + feePosting.addParameter(LedgerParameter.ofSecurity(LedgerParameterType.RIGHT_SECURITY, + rightSecurity)); + feePosting.addParameter(LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_KIND, + "SPIN_OFF")); + feePosting.addParameter(LedgerParameter.ofAccount(LedgerParameterType.SOURCE_ACCOUNT, + account)); + feePosting.addParameter(LedgerParameter.ofPortfolio(LedgerParameterType.SOURCE_PORTFOLIO, + portfolio)); + + entry.addPosting(cashPosting); + entry.addPosting(feePosting); + + projection.setRole(LedgerProjectionRole.ACCOUNT); + projection.setAccount(account); + projection.setPrimaryPosting(cashPosting); + entry.addProjectionRef(projection); + + client.getLedger().addEntry(entry); + + assertNewLedgerParameterVocabulary(client, recordDate, nominalValue, rightSecurity.getUUID(), account.getUUID(), + portfolio.getUUID()); + + var xml = saveXml(client); + + assertCompactLedgerParameterXml(xml, nominalValue); + + var xmlLoaded = loadXml(xml); + + assertNewLedgerParameterVocabulary(xmlLoaded, recordDate, nominalValue, rightSecurity.getUUID(), + account.getUUID(), portfolio.getUUID()); + + var protobufLoaded = loadProtobuf(saveProtobuf(client)); + + assertNewLedgerParameterVocabulary(protobufLoaded, recordDate, nominalValue, rightSecurity.getUUID(), + account.getUUID(), portfolio.getUUID()); + } + + /** + * Verifies that hidden transfer units and primary forex facts survive both formats. + * Transfer persistence must not lose facts that are not visible as normal runtime projections. + */ + @Test + public void testXmlAndProtobufRoundtripsPreserveHiddenTransferUnitsAndPrimaryForex() throws Exception + { + var fixture = hiddenTransferFactsFixture(); + var expectedLedger = ledgerSnapshot(fixture.client()); + var expectedTransactions = transactionSnapshots(fixture.client()); + + var xmlLoaded = loadXml(saveXml(fixture.client())); + var protobufLoaded = loadProtobuf(saveProtobuf(fixture.client())); + + assertValid(xmlLoaded); + assertThat(ledgerSnapshot(xmlLoaded), is(expectedLedger)); + assertThat(transactionSnapshots(xmlLoaded), is(expectedTransactions)); + + assertValid(protobufLoaded); + assertThat(ledgerSnapshot(protobufLoaded), is(expectedLedger)); + assertThat(transactionSnapshots(protobufLoaded), is(expectedTransactions)); + } + + /** + * Verifies that deposit and removal units survive XML and protobuf roundtrips. + * Splitting or standalone cash bookings must keep their unit facts. + */ + @Test + public void testXmlAndProtobufRoundtripsPreserveDepositAndRemovalUnits() throws Exception + { + var fixture = depositRemovalUnitFactsFixture(); + var expectedLedger = ledgerSnapshot(fixture.client()); + var expectedTransactions = transactionSnapshots(fixture.client()); + + var xmlLoaded = loadXml(saveXml(fixture.client())); + var protobufLoaded = loadProtobuf(saveProtobuf(fixture.client())); + + assertValid(xmlLoaded); + assertThat(ledgerSnapshot(xmlLoaded), is(expectedLedger)); + assertThat(transactionSnapshots(xmlLoaded), is(expectedTransactions)); + + assertValid(protobufLoaded); + assertThat(ledgerSnapshot(protobufLoaded), is(expectedLedger)); + assertThat(transactionSnapshots(protobufLoaded), is(expectedTransactions)); + } + + /** + * Verifies that newly created standard ledger transactions rematerialize and roundtrip. + * Creator output must remain stable through both persistence formats. + */ + @Test + public void testNewlyCreatedStandardTransactionsRematerializeAndRoundtrip() throws Exception + { + var fixture = standardCreationFixture(); + var expectedLedger = ledgerSnapshot(fixture.client()); + var expectedProjectionUUIDs = projectionUUIDs(fixture.client()); + + clearLedgerBackedRuntimeProjections(fixture.client()); + var result = LedgerProjectionService.restoreIfValid(fixture.client()); + + assertTrue(result.format(), result.isOK()); + assertThat(ledgerSnapshot(fixture.client()), is(expectedLedger)); + assertMaterializedProjectionUUIDs(fixture.client(), expectedProjectionUUIDs); + assertThat(fixture.client().getAllTransactions().size(), is(fixture.client().getLedger().getEntries().size())); + + var xmlLoaded = loadXml(saveXml(fixture.client())); + assertThat(ledgerSnapshot(xmlLoaded), is(expectedLedger)); + assertMaterializedProjectionUUIDs(xmlLoaded, expectedProjectionUUIDs); + assertThat(xmlLoaded.getAllTransactions().size(), is(xmlLoaded.getLedger().getEntries().size())); + + var protobufLoaded = loadProtobuf(saveProtobuf(fixture.client())); + assertThat(ledgerSnapshot(protobufLoaded), is(expectedLedger)); + assertMaterializedProjectionUUIDs(protobufLoaded, expectedProjectionUUIDs); + assertThat(protobufLoaded.getAllTransactions().size(), is(protobufLoaded.getLedger().getEntries().size())); + } + + /** + * Verifies that old scalar-only XML remains loadable and is adapted in memory. + * Backward compatibility must not require persisted projection memberships. + */ + @Test + public void testOldScalarOnlyXmlLoadsAndSavesMemberships() throws Exception + { + var fixture = standardCreationFixture(); + var scalarOnlyXml = stripMemberships(addScalarProjectionTargets(saveXml(fixture.client()), fixture.client())); + var loaded = loadXml(scalarOnlyXml); + var resavedXml = saveXml(loaded); + + assertValid(loaded); + assertMaterializedProjectionUUIDs(loaded, projectionUUIDs(fixture.client())); + assertTrue(membershipSnapshots(loaded).stream() + .anyMatch(membership -> membership.role() == ProjectionMembershipRole.PRIMARY)); + assertTrue(resavedXml.contains("")); + assertFalse(resavedXml.contains("")); + } + + /** + * Verifies that XML persists projection memberships on new files. + * Membership roles and posting targets must survive save/load/save. + */ + @Test + public void testXmlSaveLoadSavePreservesProjectionMemberships() throws Exception + { + var fixture = standardCreationFixture(); + var expectedMemberships = membershipSnapshots(fixture.client()); + var firstXml = saveXml(fixture.client()); + var loaded = loadXml(firstXml); + var secondXml = saveXml(loaded); + var reloaded = loadXml(secondXml); + var legacyEmptyParametersLoaded = loadXml(addEmptyLedgerParameterCollections(firstXml)); + + assertTrue(firstXml.contains("")); + assertFalse(firstXml.contains("")); + assertFalse(firstXml.contains("")); + assertTrue(firstXml.matches("(?s).*]* uuid=\")(?=[^>]* type=\")" //$NON-NLS-1$ + + "(?=[^>]* dateTime=\")(?=[^>]* updatedAt=\")[^>]*>.*")); //$NON-NLS-1$ + assertTrue(firstXml.matches("(?s).*]* uuid=\")(?=[^>]* type=\")" //$NON-NLS-1$ + + "(?=[^>]* amount=\")(?=[^>]* currency=\")(?=[^>]* shares=\")[^>]*>.*")); //$NON-NLS-1$ + assertTrue(firstXml.matches("(?s).*]* uuid=\")(?=[^>]* role=\")[^>]*>.*")); //$NON-NLS-1$ + assertFalse(firstXml.matches("(?s).*]*>(?:(?!).)*.*")); //$NON-NLS-1$ + assertFalse(firstXml.matches("(?s).*]*>(?:(?!).)*.*")); //$NON-NLS-1$ + assertTrue(firstXml.contains(" entry.getProjectionRefsList().stream()) + .anyMatch(projection -> projection.getMembershipsCount() > 0)); + assertThat(PLedgerProjectionRef.getDescriptor().findFieldByName("primaryPostingUUID"), nullValue()); + assertThat(PLedgerProjectionRef.getDescriptor().findFieldByName("postingGroupUUID"), nullValue()); + assertTrue(expectedMemberships.stream() + .anyMatch(membership -> membership.role() == ProjectionMembershipRole.FEE_UNIT)); + assertTrue(expectedMemberships.stream() + .anyMatch(membership -> membership.role() == ProjectionMembershipRole.TAX_UNIT)); + assertTrue(expectedMemberships.stream() + .anyMatch(membership -> membership.role() == ProjectionMembershipRole.GROSS_VALUE_UNIT)); + assertThat(membershipSnapshots(loaded), is(expectedMemberships)); + assertThat(membershipSnapshots(reloaded), is(expectedMemberships)); + } + + /** + * Verifies that XML scalar and membership target conflicts are diagnosed. + * Load recovery may return the client, but validation must keep the conflict visible. + */ + @Test + public void testXmlScalarMembershipConflictIsDiagnosed() throws Exception + { + var fixture = standardCreationFixture(); + var entry = fixture.client().getLedger().getEntries().stream() + .filter(candidate -> candidate.getPostings().size() > 1).findFirst().orElseThrow(); + var projection = entry.getProjectionRefs().get(0); + var alternatePostingUUID = entry.getPostings().stream() + .map(LedgerPosting::getUUID) + .filter(uuid -> !uuid.equals(projection.getPrimaryPostingUUID())) + .findFirst().orElseThrow(); + var conflictingXml = addScalarProjectionTargets(saveXml(fixture.client()), fixture.client()).replaceFirst( + "(?s)()", //$NON-NLS-1$ + "$1" + alternatePostingUUID + "$2"); //$NON-NLS-1$ //$NON-NLS-2$ + var loaded = loadXml(conflictingXml); + var result = LedgerStructuralValidator.validate(loaded.getLedger()); + + assertFalse(result.isOK()); + assertTrue(result.hasIssue(LedgerStructuralValidator.IssueCode.PROJECTION_PRIMARY_TARGET_CONFLICT)); + } + + /** + * Verifies that invalid XML projection memberships do not make a parseable file unloadable. + * The invalid Ledger entry remains available and is not materialized. + */ + @Test + public void testInvalidXmlProjectionMembershipLoadsWithRecovery() throws Exception + { + var fixture = standardCreationFixture(); + var brokenProjectionUUID = fixture.client().getLedger().getEntries().get(0).getProjectionRefs().get(0) + .getUUID(); + var projection = fixture.client().getLedger().getEntries().get(0).getProjectionRefs().get(0); + var invalidXml = saveXml(fixture.client()).replaceFirst( + "[^<]+", + "missing-primary-posting"); + var loaded = loadXml(invalidXml); + var result = LedgerStructuralValidator.validate(loaded.getLedger()); + + assertFalse(result.isOK()); + assertTrue(result.hasIssue(LedgerStructuralValidator.IssueCode.PRIMARY_POSTING_REF_NOT_FOUND)); + assertInvalidLedgerLoadInvariants(loaded, fixture, brokenProjectionUUID); + } + + /** + * Verifies that invalid protobuf ledger truth loads without shadow remigration. + * Compatibility rows must not hide an invalid persisted ledger entry. + */ + @Test + public void testInvalidProtobufLedgerLoadsWithoutShadowRemigration() throws Exception + { + var fixture = parityFixture(); + var brokenProjectionUUID = fixture.client().getLedger().getEntries().get(0).getProjectionRefs().get(0) + .getUUID(); + var proto = parseProtobuf(saveProtobuf(fixture.client())).toBuilder(); + var entry = proto.getLedgerBuilder().getEntriesBuilder(0); + + entry.addPostings(entry.getPostings(0)); + var loaded = loadProtobuf(wrapProtobuf(proto.build())); + var result = LedgerStructuralValidator.validate(loaded.getLedger()); + + assertFalse(result.isOK()); + assertTrue(result.hasIssue(LedgerStructuralValidator.IssueCode.DUPLICATE_POSTING_UUID)); + assertInvalidLedgerLoadInvariants(loaded, fixture, brokenProjectionUUID); + } + + /** + * Verifies that strict XML save validation does not create or truncate the Save As target. + */ + @Test + public void testInvalidLedgerXmlSaveAsDoesNotCreateOrTruncateTarget() throws Exception + { + assertInvalidLedgerSaveAsDoesNotCreateOrTruncateTarget(EnumSet.of(SaveFlag.XML)); + } + + /** + * Verifies that strict protobuf save validation does not create or truncate the Save As target. + */ + @Test + public void testInvalidLedgerProtobufSaveAsDoesNotCreateOrTruncateTarget() throws Exception + { + assertInvalidLedgerSaveAsDoesNotCreateOrTruncateTarget(EnumSet.of(SaveFlag.BINARY)); + } + + /** + * Verifies that ambiguous multi-projection plan refs stay rejected after roundtrips. + * Persistence must not turn an unresolved generated booking into a guessed match. + */ + @Test + public void testAmbiguousMultiProjectionInvestmentPlanRefStaysRejectedAfterRoundtrips() throws Exception + { + var ambiguousXml = ambiguousPlanFixture(); + var xmlLoaded = loadXml(saveXml(ambiguousXml.client())); + var protobufLoaded = loadProtobuf(saveProtobuf(ambiguousXml.client())); + + assertThrows(IllegalArgumentException.class, () -> plan(xmlLoaded, "Ambiguous Plan").getTransactions(xmlLoaded)); + assertThrows(IllegalArgumentException.class, + () -> plan(protobufLoaded, "Ambiguous Plan").getTransactions(protobufLoaded)); + } + + private void assertParity(Client client, LedgerSnapshot expectedLedger, List expectedTransactions, + List expectedPlans) + { + assertValid(client); + assertThat(ledgerSnapshot(client), is(expectedLedger)); + assertThat(transactionSnapshots(client), is(expectedTransactions)); + assertThat(planSnapshots(client), is(expectedPlans)); + assertPlanExecutionRefsRestored(client); + assertNoDuplicates(client); + } + + private void assertNoDuplicates(Client client) + { + var projectionUUIDs = client.getLedger().getEntries().stream() + .flatMap(entry -> entry.getProjectionRefs().stream()).map(LedgerProjectionRef::getUUID) + .toList(); + var materializedUUIDs = client.getAccounts().stream().flatMap(account -> account.getTransactions().stream()) + .filter(LedgerBackedTransaction.class::isInstance).map(Transaction::getUUID) + .toList(); + materializedUUIDs = java.util.stream.Stream + .concat(materializedUUIDs.stream(), + client.getPortfolios().stream() + .flatMap(portfolio -> portfolio.getTransactions().stream()) + .filter(LedgerBackedTransaction.class::isInstance) + .map(Transaction::getUUID)) + .toList(); + + assertThat(client.getLedger().getEntries().size(), is(8)); + assertThat(client.getAllTransactions().size(), is(8)); + assertThat(projectionUUIDs.stream().distinct().count(), is((long) projectionUUIDs.size())); + assertThat(materializedUUIDs.stream().distinct().count(), is((long) materializedUUIDs.size())); + assertThat(materializedUUIDs.stream().sorted().toList(), is(projectionUUIDs.stream().sorted().toList())); + } + + private void assertMaterializedProjectionUUIDs(Client client, List expectedProjectionUUIDs) + { + assertValid(client); + assertThat(materializedProjectionUUIDs(client), is(expectedProjectionUUIDs)); + assertTrue(client.getAllTransactions().stream().allMatch(pair -> pair.getTransaction() + instanceof LedgerBackedTransaction)); + } + + private List projectionUUIDs(Client client) + { + return client.getLedger().getEntries().stream() // + .flatMap(entry -> entry.getProjectionRefs().stream()) // + .map(LedgerProjectionRef::getUUID) // + .sorted() // + .toList(); + } + + private List membershipSnapshots(Client client) + { + return client.getLedger().getEntries().stream() // + .flatMap(entry -> entry.getProjectionRefs().stream()) // + .flatMap(projection -> projection.getMemberships().stream() + .map(membership -> new MembershipSnapshot(projection.getUUID(), + membership.getPostingUUID(), membership.getRole()))) // + .sorted(Comparator.comparing(MembershipSnapshot::projectionUUID) + .thenComparing(MembershipSnapshot::postingUUID) + .thenComparing(MembershipSnapshot::role)) // + .toList(); + } + + private String stripMemberships(String xml) + { + return xml.replaceAll("(?s)\\s*.*?", ""); //$NON-NLS-1$ //$NON-NLS-2$ + } + + private String addScalarProjectionTargets(String xml, Client client) + { + var updatedXml = xml; + + for (var entry : client.getLedger().getEntries()) + { + for (var projection : entry.getProjectionRefs()) + { + var scalarTargets = new StringBuilder(); + + if (projection.getPrimaryPostingUUID() != null) + scalarTargets.append("").append(projection.getPrimaryPostingUUID()) + .append(""); //$NON-NLS-1$ //$NON-NLS-2$ + + if (projection.getPostingGroupUUID() != null) + scalarTargets.append("").append(projection.getPostingGroupUUID()) + .append(""); //$NON-NLS-1$ //$NON-NLS-2$ + + if (scalarTargets.length() == 0) + continue; + + updatedXml = updatedXml.replaceFirst( + "(?s)(]*\\buuid=\"" //$NON-NLS-1$ + + java.util.regex.Pattern.quote(projection.getUUID()) + + "\"[^>]*>)", //$NON-NLS-1$ + "$1" + java.util.regex.Matcher.quoteReplacement(scalarTargets.toString())); //$NON-NLS-1$ + } + } + + return updatedXml; + } + + private String addEmptyLedgerParameterCollections(String xml) + { + return xml.replaceFirst("(]*>)", "$1") //$NON-NLS-1$ //$NON-NLS-2$ + .replaceFirst("(]*>)", "$1"); //$NON-NLS-1$ //$NON-NLS-2$ + } + + private List materializedProjectionUUIDs(Client client) + { + return java.util.stream.Stream + .concat(client.getAccounts().stream().flatMap(account -> account.getTransactions().stream()), + client.getPortfolios().stream() + .flatMap(portfolio -> portfolio.getTransactions().stream())) + .filter(LedgerBackedTransaction.class::isInstance) // + .map(Transaction::getUUID) // + .sorted() // + .toList(); + } + + private void clearLedgerBackedRuntimeProjections(Client client) + { + for (var account : client.getAccounts()) + account.getTransactions().removeIf(LedgerBackedTransaction.class::isInstance); + + for (var portfolio : client.getPortfolios()) + portfolio.getTransactions().removeIf(LedgerBackedTransaction.class::isInstance); + } + + private void assertXmlContainsLedgerTruthOnly(String xml) + { + assertTrue(xml.contains("")); + assertThat(xml.contains("LedgerBacked"), is(false)); + assertThat(xml.contains(" parameter) + { + return new ParameterSnapshot(parameter.getType(), parameter.getValueKind(), String.valueOf(parameter.getValue())); + } + + private ProjectionSnapshot projectionSnapshot(LedgerProjectionRef projectionRef) + { + return new ProjectionSnapshot(projectionRef.getUUID(), projectionRef.getRole(), uuid(projectionRef.getAccount()), + uuid(projectionRef.getPortfolio()), effectivePrimaryPostingUUID(projectionRef), + effectivePostingGroupUUID(projectionRef), + projectionRef.getMemberships().stream() + .map(membership -> new ProjectionMembershipSnapshot(membership.getPostingUUID(), + membership.getRole())) + .sorted(Comparator.comparing(ProjectionMembershipSnapshot::postingUUID) + .thenComparing(ProjectionMembershipSnapshot::role)) + .toList()); + } + + private String effectivePrimaryPostingUUID(LedgerProjectionRef projectionRef) + { + if (projectionRef.getPrimaryPostingUUID() != null) + return projectionRef.getPrimaryPostingUUID(); + + return projectionRef.getPrimaryMembership().map(ProjectionMembership::getPostingUUID).orElse(null); + } + + private String effectivePostingGroupUUID(LedgerProjectionRef projectionRef) + { + return projectionRef.getMembershipsByRole(ProjectionMembershipRole.GROUP_ANCHOR).stream().findFirst() + .map(ProjectionMembership::getPostingUUID).orElse(null); + } + + private List transactionSnapshots(Client client) + { + return client.getAllTransactions().stream().map(pair -> transactionSnapshot(pair.getOwner(), pair.getTransaction())) + .sorted(Comparator.comparing(TransactionSnapshot::uuid)).toList(); + } + + private TransactionSnapshot transactionSnapshot(TransactionOwner owner, Transaction transaction) + { + var crossEntry = transaction.getCrossEntry(); + + return new TransactionSnapshot(owner.getUUID(), transaction.getUUID(), transaction.getClass().getSimpleName(), + typeName(transaction), transaction.getDateTime(), transaction.getAmount(), + transaction.getCurrencyCode(), uuid(transaction.getSecurity()), transaction.getShares(), + transaction.getNote(), transaction.getSource(), exDate(transaction), unitSnapshots(transaction), + crossEntry != null ? crossEntry.getCrossTransaction(transaction).getUUID() : null, + crossEntry != null ? crossEntry.getCrossOwner(transaction).getUUID() : null); + } + + private String typeName(Transaction transaction) + { + if (transaction instanceof AccountTransaction accountTransaction) + return accountTransaction.getType().name(); + if (transaction instanceof PortfolioTransaction portfolioTransaction) + return portfolioTransaction.getType().name(); + throw new AssertionError(transaction.getClass().getName()); + } + + private LocalDateTime exDate(Transaction transaction) + { + if (transaction instanceof AccountTransaction accountTransaction) + return accountTransaction.getExDate(); + return null; + } + + private List unitSnapshots(Transaction transaction) + { + return transaction.getUnits().map(this::unitSnapshot) + .sorted(Comparator.comparing(UnitSnapshot::type).thenComparing(UnitSnapshot::amount) + .thenComparing(UnitSnapshot::forexAmount, + Comparator.nullsFirst(Comparator.naturalOrder()))) + .toList(); + } + + private UnitSnapshot unitSnapshot(Unit unit) + { + return new UnitSnapshot(unit.getType(), unit.getAmount().getAmount(), unit.getAmount().getCurrencyCode(), + unit.getForex() != null ? unit.getForex().getAmount() : null, + unit.getForex() != null ? unit.getForex().getCurrencyCode() : null, unit.getExchangeRate()); + } + + private List planSnapshots(Client client) + { + return client.getPlans().stream() + .map(plan -> new PlanSnapshot(plan.getName(), + plan.getTransactions(client).stream() + .map(pair -> new PlanTransactionSnapshot(pair.getOwner().getUUID(), + pair.getTransaction().getUUID())) + .toList())) + .toList(); + } + + private void assertPlanExecutionRefsRestored(Client client) + { + assertThat(client.getPlans().stream().flatMap(plan -> plan.getTransactions().stream()).count(), is(0L)); + assertThat(client.getPlans().stream().flatMap(plan -> plan.getLedgerExecutionRefs().stream()).count(), is(3L)); + assertTrue(client.getPlans().stream().flatMap(plan -> plan.getLedgerExecutionRefs().stream()) + .allMatch(ref -> ref.getLedgerEntryUUID() != null && ref.getProjectionUUID() != null + && ref.getProjectionRole() != null)); + } + + private ParityFixture parityFixture() + { + var client = new Client(); + var account = register(client, account("Account")); + var otherAccount = register(client, account("Other Account")); + var portfolio = register(client, portfolio("Portfolio")); + var otherPortfolio = register(client, portfolio("Other Portfolio")); + var security = register(client, security()); + var creator = new LedgerTransactionCreator(client); + + var deposit = creator.createDeposit(metadata("deposit"), LedgerAccountCashLeg.of(account, money(11))).getEntry(); + var dividend = creator.createDividend(metadata("dividend"), + LedgerDividend.withExDate( + LedgerAccountCashLeg.of(account, money(120), + LedgerForexAmount.of( + Money.of(CurrencyUnit.USD, + Values.Amount.factorize(132)), + new BigDecimal("1.1000"))), + LedgerOptionalSecurity.of(security), + LedgerCreationUnits.of( + LedgerCreationUnit.fee(money(2), + LedgerForexAmount.of( + Money.of(CurrencyUnit.USD, + Values.Amount + .factorize(4)), + new BigDecimal("0.5000"))), + LedgerCreationUnit.tax(money(3)), + LedgerCreationUnit.grossValue(money(125), + LedgerForexAmount.of( + Money.of(CurrencyUnit.USD, + Values.Amount + .factorize(250)), + new BigDecimal("0.5000")))), + EX_DATE)) + .getEntry(); + var buy = creator.createBuy(metadata("buy"), + LedgerAccountCashLeg.of(account, money(100), + LedgerForexAmount.of( + Money.of(CurrencyUnit.USD, Values.Amount.factorize(80)), + new BigDecimal("1.2500"))), + LedgerPortfolioSecurityLeg.of(portfolio, + LedgerSecurityQuantity.of(security, Values.Share.factorize(5)), money(100), + LedgerForexAmount.of( + Money.of(CurrencyUnit.USD, Values.Amount.factorize(125)), + new BigDecimal("0.8000"))), + LedgerCreationUnits.of(LedgerCreationUnit.fee(money(4)))).getEntry(); + creator.createSell(metadata("sell"), LedgerAccountCashLeg.of(account, money(50)), + securityLeg(portfolio, security, 2, 50), LedgerCreationUnits.none()); + creator.createAccountTransfer(metadata("account transfer"), LedgerCashTransferLeg.of(account, money(15)), + LedgerCashTransferLeg.of(otherAccount, money(15))); + creator.createPortfolioTransfer(metadata("portfolio transfer"), + LedgerPortfolioTransferSecurity.of(security, Values.Share.factorize(3)), + LedgerPortfolioTransferLeg.of(portfolio, money(30)), + LedgerPortfolioTransferLeg.of(otherPortfolio, money(30))); + var inboundDelivery = creator.createInboundDelivery(metadata("inbound delivery"), + LedgerDeliveryLeg.of(otherPortfolio, + LedgerSecurityQuantity.of(security, Values.Share.factorize(4)), money(80))) + .getEntry(); + creator.createOutboundDelivery(metadata("outbound delivery"), + LedgerDeliveryLeg.of(portfolio, + LedgerSecurityQuantity.of(security, Values.Share.factorize(1)), money(20))); + + dividend.getProjectionRefs().get(0).setPrimaryPostingUUID(dividend.getPostings().get(0).getUUID()); + dividend.getProjectionRefs().get(0).setPostingGroupUUID("dividend-posting-group"); + client.getLedger().getEntries().forEach(entry -> entry.setUpdatedAt(UPDATED_AT)); + + LedgerProjectionService.materialize(client); + + addPlan(client, "Deposit Plan", account, portfolio, security, + ledgerBacked(account.getTransactions(), projectionUUID(deposit, LedgerProjectionRole.ACCOUNT))); + addPlan(client, "Buy Plan", account, portfolio, security, + ledgerBacked(portfolio.getTransactions(), projectionUUID(buy, LedgerProjectionRole.PORTFOLIO))); + addPlan(client, "Delivery Plan", account, otherPortfolio, security, + ledgerBacked(otherPortfolio.getTransactions(), + projectionUUID(inboundDelivery, LedgerProjectionRole.DELIVERY_INBOUND))); + + assertValid(client); + + return new ParityFixture(client); + } + + private ParityFixture standardCreationFixture() + { + var client = new Client(); + var account = register(client, account("Account")); + var otherAccount = register(client, account("Other Account")); + var portfolio = register(client, portfolio("Portfolio")); + var otherPortfolio = register(client, portfolio("Other Portfolio")); + var security = register(client, security()); + var creator = new LedgerTransactionCreator(client); + + creator.createDeposit(metadata("deposit"), LedgerAccountCashLeg.of(account, money(11))); + creator.createBuy(metadata("buy"), LedgerAccountCashLeg.of(account, money(100)), + securityLeg(portfolio, security, 5, 100), LedgerCreationUnits.none()); + creator.createSell(metadata("sell"), LedgerAccountCashLeg.of(account, money(50)), + securityLeg(portfolio, security, 2, 50), LedgerCreationUnits.none()); + creator.createInboundDelivery(metadata("inbound delivery"), + LedgerDeliveryLeg.of(otherPortfolio, + LedgerSecurityQuantity.of(security, Values.Share.factorize(4)), money(80))); + creator.createOutboundDelivery(metadata("outbound delivery"), + LedgerDeliveryLeg.of(portfolio, + LedgerSecurityQuantity.of(security, Values.Share.factorize(1)), money(20))); + creator.createAccountTransfer(metadata("account transfer"), LedgerCashTransferLeg.of(account, money(15)), + LedgerCashTransferLeg.of(otherAccount, money(15))); + creator.createPortfolioTransfer(metadata("portfolio transfer"), + LedgerPortfolioTransferSecurity.of(security, Values.Share.factorize(3)), + LedgerPortfolioTransferLeg.of(portfolio, money(30)), + LedgerPortfolioTransferLeg.of(otherPortfolio, money(30))); + + LedgerProjectionService.materialize(client); + + assertValid(client); + assertMaterializedProjectionUUIDs(client, projectionUUIDs(client)); + + return new ParityFixture(client); + } + + private ParityFixture hiddenTransferFactsFixture() + { + var client = new Client(); + var account = register(client, account("Account")); + var otherAccount = register(client, account("Other Account")); + var portfolio = register(client, portfolio("Portfolio")); + var otherPortfolio = register(client, portfolio("Other Portfolio")); + var security = register(client, security()); + var creator = new LedgerTransactionCreator(client); + var accountTransfer = creator.createAccountTransfer(metadata("account transfer"), + LedgerCashTransferLeg.of(account, money(15), + LedgerForexAmount.of(Money.of(CurrencyUnit.USD, Values.Amount.factorize(12)), + new BigDecimal("1.2500"))), + LedgerCashTransferLeg.of(otherAccount, money(16), + LedgerForexAmount.of(Money.of(CurrencyUnit.USD, Values.Amount.factorize(20)), + new BigDecimal("0.8000")))) + .getEntry(); + var portfolioTransfer = creator.createPortfolioTransfer(metadata("portfolio transfer"), + LedgerPortfolioTransferSecurity.of(security, Values.Share.factorize(3)), + LedgerPortfolioTransferLeg.of(portfolio, money(30), + LedgerForexAmount.of( + Money.of(CurrencyUnit.USD, Values.Amount.factorize(24)), + new BigDecimal("1.2500"))), + LedgerPortfolioTransferLeg.of(otherPortfolio, money(30), + LedgerForexAmount.of( + Money.of(CurrencyUnit.USD, Values.Amount.factorize(40)), + new BigDecimal("0.7500")))) + .getEntry(); + + addHiddenUnits(accountTransfer); + addHiddenUnits(portfolioTransfer); + + client.getLedger().getEntries().forEach(entry -> entry.setUpdatedAt(UPDATED_AT)); + LedgerProjectionService.materialize(client); + + assertValid(client); + + return new ParityFixture(client); + } + + private ParityFixture depositRemovalUnitFactsFixture() + { + var client = new Client(); + var account = register(client, account("Account")); + var creator = new LedgerTransactionCreator(client); + var units = LedgerCreationUnits.of( + LedgerCreationUnit.grossValue(money(30), + LedgerForexAmount.of( + Money.of(CurrencyUnit.USD, Values.Amount.factorize(24)), + new BigDecimal("1.2500"))), + LedgerCreationUnit.fee(money(3), + LedgerForexAmount.of( + Money.of(CurrencyUnit.USD, Values.Amount.factorize(6)), + new BigDecimal("0.5000"))), + LedgerCreationUnit.tax(money(4), + LedgerForexAmount.of( + Money.of(CurrencyUnit.USD, Values.Amount.factorize(5)), + new BigDecimal("0.8000")))); + + creator.createDeposit(metadata("deposit"), LedgerAccountCashLeg.of(account, money(11)), units); + creator.createRemoval(metadata("removal"), LedgerAccountCashLeg.of(account, money(12)), units); + + client.getLedger().getEntries().forEach(entry -> entry.setUpdatedAt(UPDATED_AT)); + LedgerProjectionService.materialize(client); + + assertValid(client); + + return new ParityFixture(client); + } + + private void addHiddenUnits(LedgerEntry entry) + { + new LedgerUnitPostingUpdater().apply(entry, LedgerUnitPostingPatch.of( + LedgerUnitPostingEdit.add(LedgerPostingType.GROSS_VALUE, money(30), + LedgerForexAmount.of( + Money.of(CurrencyUnit.USD, Values.Amount.factorize(24)), + new BigDecimal("1.2500"))), + LedgerUnitPostingEdit.add(LedgerPostingType.FEE, money(3), + LedgerForexAmount.of( + Money.of(CurrencyUnit.USD, Values.Amount.factorize(6)), + new BigDecimal("0.5000"))), + LedgerUnitPostingEdit.add(LedgerPostingType.TAX, money(4)))); + } + + private ParityFixture ambiguousPlanFixture() + { + var fixture = parityFixture(); + var buy = fixture.client().getLedger().getEntries().stream().filter(entry -> entry.getType() == LedgerEntryType.BUY) + .findFirst().orElseThrow(); + var plan = plan("Ambiguous Plan", fixture.client().getAccounts().get(0), fixture.client().getPortfolios().get(0), + fixture.client().getSecurities().get(0)); + + plan.addLedgerExecutionRef(new InvestmentPlan.LedgerExecutionRef(buy.getUUID(), null, null)); + fixture.client().addPlan(plan); + + return fixture; + } + + private void addPlan(Client client, String name, Account account, Portfolio portfolio, Security security, + Transaction transaction) + { + var plan = plan(name, account, portfolio, security); + + plan.getTransactions().add(transaction); + client.addPlan(plan); + } + + private InvestmentPlan plan(Client client, String name) + { + return client.getPlans().stream().filter(plan -> name.equals(plan.getName())).findFirst().orElseThrow(); + } + + private InvestmentPlan plan(String name, Account account, Portfolio portfolio, Security security) + { + var plan = new InvestmentPlan(name); + + plan.setType(InvestmentPlan.Type.PURCHASE_OR_DELIVERY); + plan.setAccount(account); + plan.setPortfolio(portfolio); + plan.setSecurity(security); + plan.setStart(LocalDate.of(2026, 3, 1)); + plan.setInterval(1); + plan.setAmount(Values.Amount.factorize(100)); + + return plan; + } + + private LedgerTransactionMetadata metadata(String note) + { + return LedgerTransactionMetadata.of(DATE_TIME).withNote(note).withSource("source-" + note); + } + + private LedgerPortfolioSecurityLeg securityLeg(Portfolio portfolio, Security security, int shares, int amount) + { + return LedgerPortfolioSecurityLeg.of(portfolio, + LedgerSecurityQuantity.of(security, Values.Share.factorize(shares)), money(amount)); + } + + private Transaction ledgerBacked(List transactions, String uuid) + { + return transactions.stream().filter(LedgerBackedTransaction.class::isInstance) + .filter(transaction -> uuid.equals(transaction.getUUID())).findFirst().orElseThrow(); + } + + private String projectionUUID(LedgerEntry entry, LedgerProjectionRole role) + { + return entry.getProjectionRefs().stream().filter(ref -> ref.getRole() == role).findFirst().orElseThrow() + .getUUID(); + } + + private String saveXml(Client client) throws IOException + { + var file = File.createTempFile("ledger-parity", ".xml"); + + try + { + ClientFactory.save(client, file); + return Files.readString(file.toPath(), StandardCharsets.UTF_8); + } + finally + { + Files.deleteIfExists(file.toPath()); + } + } + + private Client loadXml(String xml) throws IOException + { + return ClientFactory.load(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); + } + + private byte[] saveProtobuf(Client client) throws IOException + { + var stream = new ByteArrayOutputStream(); + + new ProtobufWriter().save(client, stream); + + return stream.toByteArray(); + } + + private Client loadProtobuf(byte[] bytes) throws IOException + { + return new ProtobufWriter().load(new ByteArrayInputStream(bytes)); + } + + private PClient parseProtobuf(byte[] bytes) throws IOException + { + return PClient.parseFrom(new ByteArrayInputStream(bytes, PROTOBUF_SIGNATURE.length, + bytes.length - PROTOBUF_SIGNATURE.length)); + } + + private byte[] wrapProtobuf(PClient client) throws IOException + { + var stream = new ByteArrayOutputStream(); + + stream.write(PROTOBUF_SIGNATURE); + client.writeTo(stream); + + return stream.toByteArray(); + } + + private void assertValid(Client client) + { + assertTrue(LedgerStructuralValidator.validate(client.getLedger()).isOK()); + } + + private void assertInvalidLedgerLoadInvariants(Client loaded, ParityFixture fixture, String brokenProjectionUUID) + { + assertThat(loaded.getAccounts().size(), is(fixture.client().getAccounts().size())); + assertThat(loaded.getPortfolios().size(), is(fixture.client().getPortfolios().size())); + assertThat(loaded.getSecurities().size(), is(fixture.client().getSecurities().size())); + assertThat(loaded.getLedger().getEntries().size(), is(fixture.client().getLedger().getEntries().size())); + assertTrue(loaded.getLedger().getEntries().stream() + .flatMap(entry -> entry.getProjectionRefs().stream()) + .anyMatch(projectionRef -> brokenProjectionUUID.equals(projectionRef.getUUID()))); + + var expectedProjectionUUIDs = projectionUUIDs(fixture.client()).stream() + .filter(uuid -> !brokenProjectionUUID.equals(uuid)).toList(); + + assertThat(materializedProjectionUUIDs(loaded), is(expectedProjectionUUIDs)); + assertTrue(loaded.getAllTransactions().stream().allMatch(pair -> pair.getTransaction() + instanceof LedgerBackedTransaction)); + assertTrue(materializedProjectionUUIDs(loaded).stream().noneMatch(brokenProjectionUUID::equals)); + } + + private void assertInvalidLedgerSaveAsDoesNotCreateOrTruncateTarget(EnumSet flags) throws Exception + { + var client = parityFixture().client(); + client.getLedger().getEntries().get(0).getProjectionRefs().get(0) + .setPrimaryPostingUUID("missing-primary-posting"); + var directory = Files.createTempDirectory("ledger-save-failure"); + var missingTarget = directory.resolve("missing.portfolio"); + var existingTarget = directory.resolve("existing.portfolio"); + var previousContent = "previous content"; + + try + { + var newTargetException = assertThrows(Exception.class, + () -> ClientFactory.saveAs(client, missingTarget.toFile(), null, EnumSet.copyOf(flags))); + + assertInvalidLedgerSaveMessage(flags, newTargetException); + assertFalse(Files.exists(missingTarget)); + + Files.writeString(existingTarget, previousContent, StandardCharsets.UTF_8); + + var existingTargetException = assertThrows(Exception.class, + () -> ClientFactory.saveAs(client, existingTarget.toFile(), null, EnumSet.copyOf(flags))); + + assertInvalidLedgerSaveMessage(flags, existingTargetException); + assertThat(Files.readString(existingTarget, StandardCharsets.UTF_8), is(previousContent)); + } + finally + { + Files.deleteIfExists(missingTarget); + Files.deleteIfExists(existingTarget); + Files.deleteIfExists(directory); + } + } + + private void assertInvalidLedgerSaveMessage(EnumSet flags, Exception exception) + { + var message = exception.getMessage(); + var expectedCode = flags.contains(SaveFlag.BINARY) ? LedgerDiagnosticCode.LEDGER_PERSIST_002 + : LedgerDiagnosticCode.LEDGER_PERSIST_001; + + assertTrue(message, message.contains(expectedCode.prefix())); + assertTrue(message, message.contains(LedgerDiagnosticCode.LEDGER_STRUCT_025.prefix())); + assertTrue(message, message.contains("PROJECTION_PRIMARY_TARGET_CONFLICT")); + assertTrue(message, message.contains("missing-primary-posting")); + } + + private void assertLedgerParameterOwnership(Client client, String targetSecurityUUID) + { + assertValid(client); + + var entry = client.getLedger().getEntries().stream() + .filter(candidate -> "entry-parameter-ownership".equals(candidate.getUUID())) + .findFirst().orElseThrow(); + var cashPosting = posting(entry, "posting-a"); + var feePosting = posting(entry, "posting-b"); + + assertThat(entry.getParameters().size(), is(2)); + assertEntryParameter(entry, LedgerParameterType.CORPORATE_ACTION_KIND, + LedgerParameter.ValueKind.STRING, "SPIN_OFF"); + assertEntryParameter(entry, LedgerParameterType.CASH_IN_LIEU_APPLIED, + LedgerParameter.ValueKind.BOOLEAN, Boolean.TRUE); + assertThat(entry.getParameters().stream() + .noneMatch(parameter -> parameter.getType() == LedgerParameterType.EX_DATE), is(true)); + assertThat(entry.getParameters().stream() + .noneMatch(parameter -> parameter.getType() == LedgerParameterType.TARGET_SECURITY), + is(true)); + assertThat(entry.getPostings().size(), is(2)); + assertOnlyParameter(cashPosting, LedgerParameterType.EX_DATE, + LedgerParameter.ValueKind.LOCAL_DATE_TIME, EX_DATE); + assertOnlySecurityParameter(feePosting, LedgerParameterType.TARGET_SECURITY, targetSecurityUUID); + assertThat(cashPosting.getParameters().stream() + .noneMatch(parameter -> parameter.getType() == LedgerParameterType.CORPORATE_ACTION_KIND), + is(true)); + assertThat(feePosting.getParameters().stream() + .noneMatch(parameter -> parameter.getType() == LedgerParameterType.CASH_IN_LIEU_APPLIED), + is(true)); + } + + private void assertCompactLedgerParameterXml(String xml, Money nominalValue) + { + assertTrue(xml.contains("")); + assertTrue(xml.contains("")); + assertTrue(xml.contains("")); + assertTrue(xml.contains("")); + assertTrue(xml.contains("")); + assertTrue(xml.contains("")); + assertTrue(xml.matches("(?s).*\\s*" //$NON-NLS-1$ + + "]*reference=\"[^\"]+\"[^>]*/>\\s*.*")); //$NON-NLS-1$ + assertTrue(xml.matches("(?s).*\\s*" //$NON-NLS-1$ + + "]*reference=\"[^\"]+\"[^>]*/>\\s*.*")); //$NON-NLS-1$ + assertTrue(xml.matches("(?s).*\\s*" //$NON-NLS-1$ + + "]*reference=\"[^\"]+\"[^>]*/>\\s*.*")); //$NON-NLS-1$ + assertFalse(xml.contains("")); + assertFalse(xml.contains("")); + assertFalse(xml.contains("")); + assertFalse(xml.contains("")); + assertFalse(xml.contains("")); + assertFalse(xml.contains("")); + } + + private void assertNewLedgerParameterVocabulary(Client client, LocalDate recordDate, Money nominalValue, + String rightSecurityUUID, String accountUUID, String portfolioUUID) + { + assertValid(client); + + var entry = client.getLedger().getEntries().stream() + .filter(candidate -> "entry-new-parameter-vocabulary".equals(candidate.getUUID())) + .findFirst().orElseThrow(); + var cashPosting = posting(entry, "posting-new-vocabulary-a"); + var feePosting = posting(entry, "posting-new-vocabulary-b"); + + assertThat(entry.getParameters().isEmpty(), is(true)); + assertThat(entry.getPostings().size(), is(2)); + assertParameter(cashPosting, LedgerParameterType.RECORD_DATE, LedgerParameter.ValueKind.LOCAL_DATE, + recordDate); + assertParameter(cashPosting, LedgerParameterType.EX_DATE, LedgerParameter.ValueKind.LOCAL_DATE_TIME, + EX_DATE); + assertParameter(cashPosting, LedgerParameterType.RATIO_NUMERATOR, LedgerParameter.ValueKind.DECIMAL, + new BigDecimal("1.25")); + assertParameter(cashPosting, LedgerParameterType.CASH_IN_LIEU_APPLIED, + LedgerParameter.ValueKind.BOOLEAN, Boolean.TRUE); + assertParameter(cashPosting, LedgerParameterType.NOMINAL_VALUE, LedgerParameter.ValueKind.MONEY, + nominalValue); + assertThat(cashPosting.getParameters().size(), is(5)); + + assertSecurityParameter(feePosting, LedgerParameterType.RIGHT_SECURITY, rightSecurityUUID); + assertParameter(feePosting, LedgerParameterType.CORPORATE_ACTION_KIND, + LedgerParameter.ValueKind.STRING, "SPIN_OFF"); + assertAccountParameter(feePosting, LedgerParameterType.SOURCE_ACCOUNT, accountUUID); + assertPortfolioParameter(feePosting, LedgerParameterType.SOURCE_PORTFOLIO, portfolioUUID); + assertThat(feePosting.getParameters().size(), is(4)); + } + + private LedgerPosting posting(LedgerEntry entry, String uuid) + { + return entry.getPostings().stream().filter(candidate -> uuid.equals(candidate.getUUID())).findFirst() + .orElseThrow(); + } + + private void assertOnlyParameter(LedgerPosting posting, LedgerParameterType type, + LedgerParameter.ValueKind valueKind, Object value) + { + assertThat(posting.getParameters().size(), is(1)); + + var parameter = posting.getParameters().get(0); + + assertThat(parameter.getType(), is(type)); + assertThat(parameter.getValueKind(), is(valueKind)); + assertThat(parameter.getValue(), is(value)); + } + + private void assertOnlySecurityParameter(LedgerPosting posting, LedgerParameterType type, + String securityUUID) + { + assertThat(posting.getParameters().size(), is(1)); + + var parameter = posting.getParameters().get(0); + var security = (Security) parameter.getValue(); + + assertThat(parameter.getType(), is(type)); + assertThat(parameter.getValueKind(), is(LedgerParameter.ValueKind.SECURITY)); + assertThat(security.getUUID(), is(securityUUID)); + } + + private void assertEntryParameter(LedgerEntry entry, LedgerParameterType type, + LedgerParameter.ValueKind valueKind, Object value) + { + var parameter = entry.getParameters().stream().filter(candidate -> candidate.getType() == type).findFirst() + .orElseThrow(); + + assertThat(parameter.getValueKind(), is(valueKind)); + assertThat(parameter.getValue(), is(value)); + } + + private void assertParameter(LedgerPosting posting, LedgerParameterType type, + LedgerParameter.ValueKind valueKind, Object value) + { + var parameter = posting.getParameters().stream().filter(candidate -> candidate.getType() == type).findFirst() + .orElseThrow(); + + assertThat(parameter.getValueKind(), is(valueKind)); + assertThat(parameter.getValue(), is(value)); + } + + private void assertSecurityParameter(LedgerPosting posting, LedgerParameterType type, String securityUUID) + { + var parameter = posting.getParameters().stream().filter(candidate -> candidate.getType() == type).findFirst() + .orElseThrow(); + var security = (Security) parameter.getValue(); + + assertThat(parameter.getValueKind(), is(LedgerParameter.ValueKind.SECURITY)); + assertThat(security.getUUID(), is(securityUUID)); + } + + private void assertAccountParameter(LedgerPosting posting, LedgerParameterType type, String accountUUID) + { + var parameter = posting.getParameters().stream().filter(candidate -> candidate.getType() == type).findFirst() + .orElseThrow(); + var account = (Account) parameter.getValue(); + + assertThat(parameter.getValueKind(), is(LedgerParameter.ValueKind.ACCOUNT)); + assertThat(account.getUUID(), is(accountUUID)); + } + + private void assertPortfolioParameter(LedgerPosting posting, LedgerParameterType type, String portfolioUUID) + { + var parameter = posting.getParameters().stream().filter(candidate -> candidate.getType() == type).findFirst() + .orElseThrow(); + var portfolio = (Portfolio) parameter.getValue(); + + assertThat(parameter.getValueKind(), is(LedgerParameter.ValueKind.PORTFOLIO)); + assertThat(portfolio.getUUID(), is(portfolioUUID)); + } + + private String uuid(Object object) + { + if (object instanceof Account account) + return account.getUUID(); + if (object instanceof Portfolio portfolio) + return portfolio.getUUID(); + if (object instanceof Security security) + return security.getUUID(); + return null; + } + + private Account register(Client client, Account account) + { + client.addAccount(account); + return account; + } + + private Portfolio register(Client client, Portfolio portfolio) + { + client.addPortfolio(portfolio); + return portfolio; + } + + private Security register(Client client, Security security) + { + client.addSecurity(security); + return security; + } + + private Account account(String name) + { + var account = new Account(); + + account.setName(name); + account.setCurrencyCode(CurrencyUnit.EUR); + account.setUpdatedAt(UPDATED_AT); + + return account; + } + + private Portfolio portfolio(String name) + { + var portfolio = new Portfolio(); + + portfolio.setName(name); + portfolio.setUpdatedAt(UPDATED_AT); + + return portfolio; + } + + private Security security() + { + var security = new Security("Security", CurrencyUnit.EUR); + + security.setUpdatedAt(UPDATED_AT); + + return security; + } + + private Money money(int amount) + { + return Money.of(CurrencyUnit.EUR, Values.Amount.factorize(amount)); + } + + private record ParityFixture(Client client) + { + } + + private record LedgerSnapshot(List entries) + { + } + + private record EntrySnapshot(String uuid, LedgerEntryType type, LocalDateTime dateTime, String note, String source, + Instant updatedAt, List parameters, List postings, + List projectionRefs) + { + } + + private record PostingSnapshot(String uuid, LedgerPostingType type, long amount, String currency, Long forexAmount, + String forexCurrency, BigDecimal exchangeRate, String securityUUID, long shares, String accountUUID, + String portfolioUUID, List parameters) + { + } + + private record ParameterSnapshot(LedgerParameterType type, LedgerParameter.ValueKind valueKind, + String value) + { + } + + private record ProjectionSnapshot(String uuid, LedgerProjectionRole role, String accountUUID, String portfolioUUID, + String primaryPostingUUID, String postingGroupUUID, List memberships) + { + } + + private record ProjectionMembershipSnapshot(String postingUUID, ProjectionMembershipRole role) + { + } + + private record MembershipSnapshot(String projectionUUID, String postingUUID, ProjectionMembershipRole role) + { + } + + private record TransactionSnapshot(String ownerUUID, String uuid, String transactionClass, String type, + LocalDateTime dateTime, long amount, String currency, String securityUUID, long shares, String note, + String source, LocalDateTime exDate, List units, String crossTransactionUUID, + String crossOwnerUUID) + { + } + + private record UnitSnapshot(Unit.Type type, long amount, String currency, Long forexAmount, String forexCurrency, + BigDecimal exchangeRate) + { + } + + private record PlanSnapshot(String name, List transactions) + { + } + + private record PlanTransactionSnapshot(String ownerUUID, String transactionUUID) + { + } + +} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ProtobufTestUtilities.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ProtobufTestUtilities.java new file mode 100644 index 0000000000..ad24251103 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ProtobufTestUtilities.java @@ -0,0 +1,24 @@ +package name.abuchen.portfolio.model; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +public final class ProtobufTestUtilities +{ + private ProtobufTestUtilities() + { + } + + public static byte[] save(Client client) throws IOException + { + var stream = new ByteArrayOutputStream(); + new ProtobufWriter().save(client, stream); + return stream.toByteArray(); + } + + public static Client load(byte[] bytes) throws IOException + { + return new ProtobufWriter().load(new ByteArrayInputStream(bytes)); + } +} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerXmlPersistenceTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerXmlPersistenceTest.java new file mode 100644 index 0000000000..b0db7ea086 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerXmlPersistenceTest.java @@ -0,0 +1,878 @@ +package name.abuchen.portfolio.model.ledger; + +import static org.hamcrest.CoreMatchers.instanceOf; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.lang.reflect.InvocationTargetException; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import org.junit.Test; + +import name.abuchen.portfolio.Messages; +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.AccountTransferEntry; +import name.abuchen.portfolio.model.BuySellEntry; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.ClientFactory; +import name.abuchen.portfolio.model.InvestmentPlan; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.PortfolioTransaction; +import name.abuchen.portfolio.model.PortfolioTransferEntry; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.Transaction.Unit; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountCashLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerCreationUnit; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerCreationUnits; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerDividend; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerForexAmount; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerOptionalSecurity; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioSecurityLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerSecurityQuantity; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerTransactionCreator; +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.EventStage; +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.model.ledger.legacy.LegacyTransactionToLedgerMigrator; +import name.abuchen.portfolio.model.ledger.nativeentry.LedgerNativeEntryAssembler; +import name.abuchen.portfolio.model.ledger.nativeentry.NativeCashCompensation; +import name.abuchen.portfolio.model.ledger.nativeentry.NativeCorporateActionEvent; +import name.abuchen.portfolio.model.ledger.nativeentry.NativeEntryMetadata; +import name.abuchen.portfolio.model.ledger.nativeentry.NativeSecurityLeg; +import name.abuchen.portfolio.model.ledger.nativeentry.Ratio; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionService; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.Values; + +/** + * Tests persistence compatibility for ledger-backed transactions. + * These tests make sure save/load rebuilds runtime rows from ledger truth and keeps compatibility data stable. + */ +@SuppressWarnings("nls") +public class LedgerXmlPersistenceTest +{ + private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 2, 3, 0, 0); + private static final LocalDateTime EX_DATE = LocalDateTime.of(2026, 1, 30, 0, 0); + + /** + * Verifies that old XML account transactions are migrated into ledger truth during load. + * The loaded client must expose the same account booking through a ledger-backed projection. + */ + @Test + public void testOldXmlAccountOnlyTransactionLoadsAndMigratesIntoLedger() throws Exception + { + var client = new Client(); + var account = register(client, account()); + var transaction = accountTransaction(AccountTransaction.Type.DEPOSIT, 100); + + account.addTransaction(transaction); + + var loaded = load(oldXmlWithoutLedger(client)); + + assertThat(loaded.getLedger().getEntries().size(), is(1)); + assertThat(loaded.getLedger().getEntries().get(0).getType(), is(LedgerEntryType.DEPOSIT)); + assertThat(loaded.getAccounts().get(0).getTransactions().size(), is(1)); + assertThat(loaded.getAccounts().get(0).getTransactions().get(0), instanceOf(LedgerBackedTransaction.class)); + assertThat(loaded.getAccounts().get(0).getTransactions().get(0).getUUID(), is(transaction.getUUID())); + assertValid(loaded); + } + + /** + * Verifies that old XML dividend rows keep ex-date, units, and forex facts when migrated. + * The ledger-backed projection must show the same business values after load. + */ + @Test + public void testOldXmlDividendLoadsWithExDateUnitsAndForex() throws Exception + { + var client = new Client(); + var account = register(client, account()); + var security = register(client, security()); + var dividend = accountTransaction(AccountTransaction.Type.DIVIDENDS, 120); + + dividend.setSecurity(security); + dividend.setShares(Values.Share.factorize(4)); + dividend.setExDate(EX_DATE); + dividend.addUnit(new Unit(Unit.Type.FEE, money(2))); + dividend.addUnit(new Unit(Unit.Type.TAX, money(3), + Money.of(CurrencyUnit.USD, Values.Amount.factorize(250)), BigDecimal.valueOf(0.012))); + dividend.addUnit(new Unit(Unit.Type.GROSS_VALUE, money(12), + Money.of(CurrencyUnit.USD, Values.Amount.factorize(1000)), BigDecimal.valueOf(0.012))); + account.addTransaction(dividend); + + var loaded = load(oldXmlWithoutLedger(client)); + var projection = loaded.getAccounts().get(0).getTransactions().get(0); + var entry = loaded.getLedger().getEntries().get(0); + var cashPosting = entry.getPostings().stream().filter(posting -> posting.getType() == LedgerPostingType.CASH) + .findFirst().orElseThrow(); + + assertThat(entry.getType(), is(LedgerEntryType.DIVIDENDS)); + assertThat(projection, instanceOf(LedgerBackedTransaction.class)); + assertThat(projection.getSecurity().getName(), is(security.getName())); + assertThat(projection.getExDate(), is(EX_DATE)); + assertThat(projection.getUnits().count(), is(3L)); + assertThat(entry.getPostings().stream().filter(posting -> posting.getType() == LedgerPostingType.TAX) + .findFirst().orElseThrow().getForexCurrency(), is(CurrencyUnit.USD)); + assertThat(cashPosting.getSecurity().getName(), is(security.getName())); + assertValid(loaded); + } + + /** + * Verifies that old XML cross-entry families load into one ledger entry per business event. + * Buy/sell and transfers must not become separate persisted truths during migration. + */ + @Test + public void testOldXmlCrossEntryFamiliesLoadIntoSingleLedgerEntries() throws Exception + { + var client = new Client(); + var account = register(client, account()); + var targetAccount = register(client, account()); + var portfolio = register(client, portfolio()); + var targetPortfolio = register(client, portfolio()); + var deliveryPortfolio = register(client, portfolio()); + var security = register(client, security()); + + var buy = new BuySellEntry(portfolio, account); + buy.setType(PortfolioTransaction.Type.BUY); + buy.setDate(DATE_TIME); + buy.setSecurity(security); + buy.setShares(Values.Share.factorize(5)); + buy.setAmount(Values.Amount.factorize(100)); + buy.setCurrencyCode(CurrencyUnit.EUR); + buy.insert(); + + var accountTransfer = new AccountTransferEntry(account, targetAccount); + accountTransfer.setDate(DATE_TIME); + accountTransfer.setAmount(Values.Amount.factorize(25)); + accountTransfer.setCurrencyCode(CurrencyUnit.EUR); + accountTransfer.insert(); + + var portfolioTransfer = new PortfolioTransferEntry(portfolio, targetPortfolio); + portfolioTransfer.setDate(DATE_TIME); + portfolioTransfer.setSecurity(security); + portfolioTransfer.setShares(Values.Share.factorize(2)); + portfolioTransfer.setAmount(Values.Amount.factorize(40)); + portfolioTransfer.setCurrencyCode(CurrencyUnit.EUR); + portfolioTransfer.insert(); + + deliveryPortfolio.addTransaction(portfolioTransaction(PortfolioTransaction.Type.DELIVERY_INBOUND, security, 30)); + + var loaded = load(oldXmlWithoutLedger(client)); + + assertThat(loaded.getLedger().getEntries().stream().filter(entry -> entry.getType() == LedgerEntryType.BUY) + .count(), is(1L)); + assertThat(loaded.getLedger().getEntries().stream() + .filter(entry -> entry.getType() == LedgerEntryType.CASH_TRANSFER).count(), is(1L)); + assertThat(loaded.getLedger().getEntries().stream() + .filter(entry -> entry.getType() == LedgerEntryType.SECURITY_TRANSFER).count(), is(1L)); + assertThat(loaded.getLedger().getEntries().stream() + .filter(entry -> entry.getType() == LedgerEntryType.DELIVERY_INBOUND).count(), is(1L)); + assertValid(loaded); + } + + /** + * Verifies that XML roundtrip persists the ledger as truth, not runtime projections. + * Owner lists must be restored from the ledger after load. + */ + @Test + public void testLedgerXmlRoundtripPreservesTruthAndDoesNotPersistRuntimeProjections() throws Exception + { + var client = new Client(); + var account = register(client, account()); + var security = register(client, security()); + var metadata = LedgerTransactionMetadata.of(DATE_TIME).withNote("note").withSource("source"); + var units = LedgerCreationUnits.of(LedgerCreationUnit.fee(money(2)), + LedgerCreationUnit.tax(money(3), LedgerForexAmount.of( + Money.of(CurrencyUnit.USD, Values.Amount.factorize(250)), + BigDecimal.valueOf(0.012))), + LedgerCreationUnit.grossValue(money(12), LedgerForexAmount.of( + Money.of(CurrencyUnit.USD, Values.Amount.factorize(1000)), + BigDecimal.valueOf(0.012)))); + var dividend = LedgerDividend.withExDate( + LedgerAccountCashLeg.of(account, money(120), + LedgerForexAmount.of(Money.of(CurrencyUnit.USD, Values.Amount.factorize(100)), + BigDecimal.valueOf(1.2))), + LedgerOptionalSecurity.of(security), units, EX_DATE); + + new LedgerTransactionCreator(client).createDividend(metadata, dividend); + client.getLedger().getEntries().get(0) + .addParameter(LedgerParameter.ofString( + LedgerParameterType.CORPORATE_ACTION_KIND, + CorporateActionKind.SPIN_OFF.getCode())); + LedgerProjectionService.materialize(client); + + var entryUUID = client.getLedger().getEntries().get(0).getUUID(); + var postingUUIDs = client.getLedger().getEntries().get(0).getPostings().stream().map(LedgerPosting::getUUID) + .toList(); + var projectionUUID = client.getLedger().getEntries().get(0).getProjectionRefs().get(0).getUUID(); + var xml = save(client); + + assertTrue(xml.contains("")); + assertFalse(xml.contains("")); + assertTrue(legacyXml.contains("class=\"ledger-posting-parameter-type\"")); + + var loaded = load(legacyXml); + var loadedEntry = loaded.getLedger().getEntries().get(0); + var loadedSourceProjection = projection(loadedEntry, LedgerProjectionRole.OLD_SECURITY_LEG); + var loadedCashProjection = projection(loadedEntry, LedgerProjectionRole.CASH_COMPENSATION); + var loadedSourcePosting = posting(loadedEntry, expectedSourcePostingUUID); + var loadedCashPosting = posting(loadedEntry, expectedCashPostingUUID); + var loadedAccount = loaded.getAccounts().get(0); + var loadedPortfolio = loaded.getPortfolios().get(0); + var loadedSourceSecurity = loaded.getSecurities().get(0); + + assertValid(loaded); + assertThat(parameterCount(loadedEntry), is(expectedParameterCount)); + assertThat(parameter(loadedSourcePosting.getParameters(), LedgerParameterType.CORPORATE_ACTION_LEG).getValue(), + is(CorporateActionLeg.SOURCE_SECURITY.getCode())); + assertThat(parameter(loadedCashPosting.getParameters(), LedgerParameterType.CASH_COMPENSATION_KIND).getValue(), + is(CashCompensationKind.CASH_IN_LIEU.getCode())); + assertSame(loadedPortfolio, loadedSourcePosting.getPortfolio()); + assertSame(loadedSourceSecurity, loadedSourcePosting.getSecurity()); + assertSame(loadedPortfolio, loadedSourceProjection.getPortfolio()); + assertSame(loadedAccount, loadedCashPosting.getAccount()); + assertSame(loadedAccount, loadedCashProjection.getAccount()); + assertThat(primaryPostingUUID(loadedSourceProjection), is(expectedSourcePostingUUID)); + assertThat(primaryPostingUUID(loadedCashProjection), is(expectedCashPostingUUID)); + assertThat(postingGroupUUID(loadedCashProjection), is(expectedCashGroupUUID)); + assertThat(loaded.getAccounts().get(0).getTransactions().get(0), instanceOf(LedgerBackedTransaction.class)); + assertThat(loaded.getPortfolios().get(0).getTransactions().stream() + .filter(LedgerBackedTransaction.class::isInstance).count(), is(2L)); + + var currentXml = save(loaded); + + assertFalse(currentXml.contains("")); + assertFalse(currentXml.contains("ledger-posting-parameter-type")); + assertFalse(currentXml.contains("LedgerBacked")); + assertTrue(currentXml.contains("")); + } + + /** + * Verifies that XML LedgerParameter diagnostics have stable persistence codes. + * Malformed persisted parameters must fail clearly without changing load semantics. + */ + @Test + public void testLedgerParameterXmlMissingTypeHasPersistCode() throws Exception + { + var xml = save(legacyLedgerParameterCompatibilityClient()); + var brokenXml = replaceRequired(xml, + "", + ""); + + assertLedgerParameterXmlFailure(brokenXml, LedgerDiagnosticCode.LEDGER_PERSIST_004); + } + + /** + * Verifies that XML LedgerParameter diagnostics have stable persistence codes. + * Malformed persisted parameters must fail clearly without changing load semantics. + */ + @Test + public void testLedgerParameterXmlMissingValueKindHasPersistCode() throws Exception + { + var xml = save(legacyLedgerParameterCompatibilityClient()); + var brokenXml = replaceRequired(xml, + "", + ""); + + assertLedgerParameterXmlFailure(brokenXml, LedgerDiagnosticCode.LEDGER_PERSIST_005); + } + + /** + * Verifies that XML LedgerParameter diagnostics have stable persistence codes. + * Malformed persisted parameters must fail clearly without changing load semantics. + */ + @Test + public void testLedgerParameterXmlReferenceValueMissingNodeHasPersistCode() throws Exception + { + var xml = save(legacyLedgerParameterCompatibilityClient()); + var brokenXml = replaceRequiredPattern(xml, + "(?s)\\s*]*/>\\s*", + ""); + + assertLedgerParameterXmlFailure(brokenXml, LedgerDiagnosticCode.LEDGER_PERSIST_006); + } + + /** + * Verifies that XML LedgerParameter diagnostics have stable persistence codes. + * Malformed persisted parameters must fail clearly without changing load semantics. + */ + @Test + public void testLedgerParameterXmlMissingAttributeHasPersistCode() throws Exception + { + var xml = save(legacyLedgerParameterCompatibilityClient()); + var brokenXml = replaceRequired(xml, + "", + ""); + + assertLedgerParameterXmlFailure(brokenXml, LedgerDiagnosticCode.LEDGER_PERSIST_008); + } + + /** + * Verifies that preparing a client for XML save does not visibly mutate owner lists. + * Runtime projections must be restored after the persistence filter runs. + */ + @Test + public void testSaveLeavesOwnerListsUnchanged() throws Exception + { + var fixture = saveRestoreFixture(new InvestmentPlan("plan")); + var accountBefore = List.copyOf(fixture.account().getTransactions()); + var portfolioBefore = List.copyOf(fixture.portfolio().getTransactions()); + var planBefore = List.copyOf(fixture.plan().getTransactions()); + + var xml = save(fixture.client()); + + assertTrue(xml.contains("")); + assertFalse(xml.contains("LedgerBacked")); + assertThat(fixture.account().getTransactions(), is(accountBefore)); + assertThat(fixture.portfolio().getTransactions(), is(portfolioBefore)); + assertThat(fixture.plan().getTransactions(), is(planBefore)); + } + + /** + * Verifies that serialization failures restore owner lists and their order. + * A failed save must not leave the UI model without its runtime projections. + */ + @Test + public void testSerializationFailureRestoresOwnerListsAndOrder() throws Exception + { + var fixture = saveRestoreFixture(new InvestmentPlan("plan")); + var accountBefore = List.copyOf(fixture.account().getTransactions()); + var portfolioBefore = List.copyOf(fixture.portfolio().getTransactions()); + var planBefore = List.copyOf(fixture.plan().getTransactions()); + + assertThrows(com.thoughtworks.xstream.io.StreamException.class, + () -> saveWithOutputStream(fixture.client(), new FailingOutputStream())); + + assertThat(fixture.account().getTransactions(), is(accountBefore)); + assertThat(fixture.portfolio().getTransactions(), is(portfolioBefore)); + assertThat(fixture.plan().getTransactions(), is(planBefore)); + } + + /** + * Verifies that preparation failures restore owner lists, projection refs, and order. + * Save-time cleanup must be atomic from the caller's point of view. + */ + @Test + public void testPreparationFailureRestoresOwnerListsRefsAndOrder() throws Exception + { + var plan = new FailingLedgerExecutionRefPlan(); + var fixture = saveRestoreFixture(plan); + var accountBefore = List.copyOf(fixture.account().getTransactions()); + var portfolioBefore = List.copyOf(fixture.portfolio().getTransactions()); + var planBefore = List.copyOf(fixture.plan().getTransactions()); + var refsBefore = List.copyOf(fixture.plan().getLedgerExecutionRefs()); + + assertThrows(IllegalStateException.class, () -> save(fixture.client())); + + assertThat(fixture.account().getTransactions(), is(accountBefore)); + assertThat(fixture.portfolio().getTransactions(), is(portfolioBefore)); + assertThat(fixture.plan().getTransactions(), is(planBefore)); + assertThat(fixture.plan().getLedgerExecutionRefs(), is(refsBefore)); + } + + /** + * Verifies that invalid ledger XML save failures include formatted diagnostics. + * The caller must see the concrete ledger validation problem instead of a generic error. + */ + @Test + public void testInvalidLedgerSaveExceptionUsesFormattedDiagnostics() + { + var client = new Client(); + var account = register(client, account()); + + account.setName("Save Account"); + var created = new LedgerTransactionCreator(client).createDeposit(LedgerTransactionMetadata.of(DATE_TIME), + LedgerAccountCashLeg.of(account, money(100))); + + created.getEntry().getPostings().get(0).setCurrency(null); + + var exception = assertThrows(IOException.class, () -> save(client)); + + assertTrue(exception.getMessage(), exception.getMessage().contains("[LEDGER-PERSIST-001] ")); + assertTrue(exception.getMessage(), exception.getMessage().contains("[POSTING_CURRENCY_REQUIRED] ")); + assertTrue(exception.getMessage(), exception.getMessage().contains("[LEDGER-STRUCT-014] ")); + assertTrue(exception.getMessage(), exception.getMessage().contains("\n Posting:\n")); + assertTrue(exception.getMessage(), exception.getMessage().contains("Currency: ")); + assertTrue(exception.getMessage(), exception.getMessage() + .contains(Messages.LedgerDiagnosticMessageFormatterTransactionContext + ":\n")); + assertTrue(exception.getMessage(), exception.getMessage() + .contains(Messages.LedgerDiagnosticMessageFormatterDate + ": 2026-02-03T00:00")); + assertTrue(exception.getMessage(), exception.getMessage() + .contains(Messages.LedgerDiagnosticMessageFormatterType + ": DEPOSIT")); + assertTrue(exception.getMessage(), exception.getMessage() + .contains(Messages.LedgerDiagnosticMessageFormatterAccount + ": Save Account")); + } + + /** + * Verifies that plan execution refs roundtrip through XML and still resolve. + * A generated booking must be found from the ledger entry and projection after reload. + */ + @Test + public void testInvestmentPlanLedgerExecutionRefsRoundtripAndResolve() throws Exception + { + var client = new Client(); + var account = register(client, account()); + var portfolio = register(client, portfolio()); + var security = register(client, security()); + var plan = new InvestmentPlan("plan"); + + client.addPlan(plan); + new LedgerTransactionCreator(client).createBuy(LedgerTransactionMetadata.of(DATE_TIME), + LedgerAccountCashLeg.of(account, money(100)), + LedgerPortfolioSecurityLeg.of(portfolio, + LedgerSecurityQuantity.of(security, Values.Share.factorize(5)), money(100)), + LedgerCreationUnits.none()); + LedgerProjectionService.materialize(client); + + var portfolioProjection = portfolio.getTransactions().get(0); + plan.getTransactions().add(portfolioProjection); + + var xml = save(client); + + assertTrue(xml.contains("")); + assertFalse(xml.contains("LedgerBacked")); + + var loaded = load(xml); + var loadedPlan = loaded.getPlans().get(0); + + assertThat(loadedPlan.getTransactions().size(), is(0)); + assertThat(loadedPlan.getLedgerExecutionRefs().size(), is(1)); + assertThat(loadedPlan.getLedgerExecutionRefs().get(0).getProjectionUUID(), is(portfolioProjection.getUUID())); + assertThat(loadedPlan.getTransactions(loaded).size(), is(1)); + assertThat(loadedPlan.getTransactions(loaded).get(0).getOwner(), is(loaded.getPortfolios().get(0))); + assertThat(loadedPlan.getTransactions(loaded).get(0).getTransaction().getUUID(), is(portfolioProjection.getUUID())); + } + + /** + * Verifies that ambiguous plan execution refs are rejected during XML load. + * A plan must not silently choose one of several possible ledger projections. + */ + @Test + public void testAmbiguousInvestmentPlanLedgerExecutionRefIsRejected() throws Exception + { + var client = new Client(); + var account = register(client, account()); + var portfolio = register(client, portfolio()); + var security = register(client, security()); + var plan = new InvestmentPlan("plan"); + + client.addPlan(plan); + new LedgerTransactionCreator(client).createBuy(LedgerTransactionMetadata.of(DATE_TIME), + LedgerAccountCashLeg.of(account, money(100)), + LedgerPortfolioSecurityLeg.of(portfolio, + LedgerSecurityQuantity.of(security, Values.Share.factorize(5)), money(100)), + LedgerCreationUnits.none()); + LedgerProjectionService.materialize(client); + plan.addLedgerExecutionRef(new InvestmentPlan.LedgerExecutionRef( + client.getLedger().getEntries().get(0).getUUID(), null, null)); + + assertThrows(IllegalArgumentException.class, () -> plan.getTransactions(client)); + } + + private String oldXmlWithoutLedger(Client client) throws Exception + { + return save(client).replaceFirst("(?s)\\s*.*?", "") + .replaceFirst("(?s)\\s*", ""); + } + + private String save(Client client) throws Exception + { + var file = File.createTempFile("ledger", ".xml"); + + try + { + ClientFactory.save(client, file); + return Files.readString(file.toPath(), StandardCharsets.UTF_8); + } + finally + { + Files.deleteIfExists(file.toPath()); + } + } + + private Client load(String xml) throws Exception + { + return ClientFactory.load(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); + } + + private void assertLedgerParameterXmlFailure(String xml, LedgerDiagnosticCode code) + { + var exception = assertThrows(IOException.class, () -> load(xml)); + + assertTrue(exception.getMessage(), exception.getMessage().contains(code.prefix())); + } + + private String replaceRequired(String xml, String target, String replacement) + { + var replaced = xml.replace(target, replacement); + + assertFalse(replaced.equals(xml)); + + return replaced; + } + + private String replaceRequiredPattern(String xml, String pattern, String replacement) + { + var replaced = xml.replaceFirst(pattern, replacement); + + assertFalse(replaced.equals(xml)); + + return replaced; + } + + private void saveWithOutputStream(Client client, OutputStream output) throws Exception + { + var type = Class.forName("name.abuchen.portfolio.model.ClientFactory$XmlSerialization"); + var constructor = type.getDeclaredConstructor(boolean.class); + var save = type.getDeclaredMethod("save", Client.class, OutputStream.class); + + constructor.setAccessible(true); + save.setAccessible(true); + + try + { + save.invoke(constructor.newInstance(false), client, output); + } + catch (InvocationTargetException e) + { + var cause = e.getCause(); + + if (cause instanceof Exception exception) + throw exception; + if (cause instanceof Error error) + throw error; + + throw new AssertionError(cause); + } + } + + private void assertValid(Client client) + { + assertTrue(LedgerStructuralValidator.validate(client.getLedger()).isOK()); + } + + private Account register(Client client, Account account) + { + client.addAccount(account); + return account; + } + + private Portfolio register(Client client, Portfolio portfolio) + { + client.addPortfolio(portfolio); + return portfolio; + } + + private Security register(Client client, Security security) + { + client.addSecurity(security); + return security; + } + + private Account account() + { + var account = new Account(); + + account.setCurrencyCode(CurrencyUnit.EUR); + + return account; + } + + private Portfolio portfolio() + { + return new Portfolio(); + } + + private Security security() + { + return new Security("Security", CurrencyUnit.EUR); + } + + private AccountTransaction accountTransaction(AccountTransaction.Type type, int amount) + { + var transaction = new AccountTransaction(type); + + transaction.setDateTime(DATE_TIME); + transaction.setCurrencyCode(CurrencyUnit.EUR); + transaction.setAmount(Values.Amount.factorize(amount)); + transaction.setNote("note"); + transaction.setSource("source"); + + return transaction; + } + + private PortfolioTransaction portfolioTransaction(PortfolioTransaction.Type type, Security security, int amount) + { + var transaction = new PortfolioTransaction(type); + + transaction.setDateTime(DATE_TIME); + transaction.setCurrencyCode(CurrencyUnit.EUR); + transaction.setAmount(Values.Amount.factorize(amount)); + transaction.setSecurity(security); + transaction.setShares(Values.Share.factorize(5)); + transaction.setNote("note"); + transaction.setSource("source"); + + return transaction; + } + + private Money money(int amount) + { + return Money.of(CurrencyUnit.EUR, Values.Amount.factorize(amount)); + } + + private Client legacyLedgerParameterCompatibilityClient() + { + var client = new Client(); + var account = register(client, account()); + var portfolio = register(client, portfolio()); + var sourceSecurity = register(client, new Security("Source Security", CurrencyUnit.EUR)); + var targetSecurity = register(client, new Security("Target Security", CurrencyUnit.EUR)); + + LedgerNativeEntryAssembler.forClient(client).spinOff() // + .metadata(NativeEntryMetadata.of(DATE_TIME).note("legacy alias test") + .source("ledger-xml-compatibility-test")) // + .event(NativeCorporateActionEvent.builder() // + .kind(CorporateActionKind.SPIN_OFF) // + .subtype(CorporateActionSubtype.STANDARD) // + .reference("legacy-ledger-parameter-alias") // + .stage(EventStage.SETTLED) // + .effectiveDate(LocalDate.of(2026, 2, 3)) // + .build()) // + .securityLeg(NativeSecurityLeg.source() // + .portfolio(portfolio) // + .security(sourceSecurity) // + .shares(Values.Share.factorize(10)) // + .amount(money(100)) // + .sourceSecurity(sourceSecurity) // + .targetSecurity(targetSecurity) // + .ratio(Ratio.of(BigDecimal.ONE, BigDecimal.valueOf(2))) // + .build()) // + .securityLeg(NativeSecurityLeg.target() // + .portfolio(portfolio) // + .security(targetSecurity) // + .shares(Values.Share.factorize(5)) // + .amount(money(50)) // + .sourceSecurity(sourceSecurity) // + .targetSecurity(targetSecurity) // + .ratio(Ratio.of(BigDecimal.ONE, BigDecimal.valueOf(2))) // + .build()) // + .cashCompensation(NativeCashCompensation.builder() // + .account(account) // + .amount(money(5)) // + .kind(CashCompensationKind.CASH_IN_LIEU) // + .applied(true) // + .build()) // + .buildAndAdd(); + + return client; + } + + private String legacyLedgerParameterXml(String xml) + { + return xml.replace("", + "" + + "CORPORATE_ACTION_LEG" + + "STRING" + + "SOURCE_SECURITY" + + ""); + } + + private int parameterCount(LedgerEntry entry) + { + return entry.getParameters().size() + entry.getPostings().stream().mapToInt(posting -> posting.getParameters().size()) + .sum(); + } + + private LedgerProjectionRef projection(LedgerEntry entry, LedgerProjectionRole role) + { + return entry.getProjectionRefs().stream().filter(projection -> projection.getRole() == role).findFirst() + .orElseThrow(); + } + + private String primaryPostingUUID(LedgerProjectionRef projection) + { + return projection.getPrimaryMembership().map(ProjectionMembership::getPostingUUID) + .orElse(projection.getPrimaryPostingUUID()); + } + + private String postingGroupUUID(LedgerProjectionRef projection) + { + return projection.getMembershipsByRole(ProjectionMembershipRole.GROUP_ANCHOR).stream().findFirst() + .map(ProjectionMembership::getPostingUUID).orElse(projection.getPostingGroupUUID()); + } + + private LedgerPosting posting(LedgerEntry entry, String uuid) + { + return entry.getPostings().stream().filter(posting -> uuid.equals(posting.getUUID())).findFirst() + .orElseThrow(); + } + + private LedgerParameter parameter(List> parameters, LedgerParameterType type) + { + return parameters.stream().filter(parameter -> parameter.getType() == type).findFirst().orElseThrow(); + } + + private SaveRestoreFixture saveRestoreFixture(InvestmentPlan plan) + { + var client = new Client(); + var account = register(client, account()); + var portfolio = register(client, portfolio()); + var security = register(client, security()); + var legacyAccountTransaction = accountTransaction(AccountTransaction.Type.DEPOSIT, 1); + var legacyPortfolioTransaction = portfolioTransaction(PortfolioTransaction.Type.DELIVERY_INBOUND, security, 2); + + account.addTransaction(legacyAccountTransaction); + portfolio.addTransaction(legacyPortfolioTransaction); + client.addPlan(plan); + + new LedgerTransactionCreator(client).createBuy(LedgerTransactionMetadata.of(DATE_TIME), + LedgerAccountCashLeg.of(account, money(100)), + LedgerPortfolioSecurityLeg.of(portfolio, + LedgerSecurityQuantity.of(security, Values.Share.factorize(5)), money(100)), + LedgerCreationUnits.none()); + LedgerProjectionService.materialize(client); + + var accountProjection = account.getTransactions().stream().filter(LedgerBackedTransaction.class::isInstance) + .findFirst().orElseThrow(); + var portfolioProjection = portfolio.getTransactions().stream().filter(LedgerBackedTransaction.class::isInstance) + .findFirst().orElseThrow(); + + plan.getTransactions().add(legacyAccountTransaction); + plan.getTransactions().add(portfolioProjection); + plan.getTransactions().add(legacyPortfolioTransaction); + + assertThat(account.getTransactions(), is(List.of(legacyAccountTransaction, accountProjection))); + assertThat(portfolio.getTransactions(), is(List.of(legacyPortfolioTransaction, portfolioProjection))); + assertThat(plan.getTransactions(), is(List.of(legacyAccountTransaction, portfolioProjection, + legacyPortfolioTransaction))); + + return new SaveRestoreFixture(client, account, portfolio, plan); + } + + private record SaveRestoreFixture(Client client, Account account, Portfolio portfolio, InvestmentPlan plan) + { + } + + private static final class FailingOutputStream extends OutputStream + { + @Override + public void write(int b) throws IOException + { + throw new IOException("forced serialization failure"); + } + } + + private static final class FailingLedgerExecutionRefPlan extends InvestmentPlan + { + private final List refs = new FailingLedgerExecutionRefList(); + + @Override + public List getLedgerExecutionRefs() + { + return refs; + } + } + + private static final class FailingLedgerExecutionRefList extends ArrayList + { + private static final long serialVersionUID = 1L; + + @Override + public boolean add(InvestmentPlan.LedgerExecutionRef ref) + { + super.add(ref); + throw new IllegalStateException("forced preparation failure"); + } + } +} diff --git a/name.abuchen.portfolio/META-INF/MANIFEST.MF b/name.abuchen.portfolio/META-INF/MANIFEST.MF index 23c4ca0c78..be697b235a 100644 --- a/name.abuchen.portfolio/META-INF/MANIFEST.MF +++ b/name.abuchen.portfolio/META-INF/MANIFEST.MF @@ -17,6 +17,7 @@ Export-Package: name.abuchen.portfolio, name.abuchen.portfolio.json, name.abuchen.portfolio.math, name.abuchen.portfolio.model, + name.abuchen.portfolio.model.ledger.compatibility, name.abuchen.portfolio.money, name.abuchen.portfolio.money.impl;x-friends:="name.abuchen.portfolio.junit", name.abuchen.portfolio.oauth, diff --git a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/ClientProtos.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/ClientProtos.java index 09bcf3ceeb..3d65528092 100644 --- a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/ClientProtos.java +++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/ClientProtos.java @@ -16,162 +16,197 @@ public static void registerAllExtensions( } static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PDecimalValue_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PDecimalValue_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PLocalDateTime_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PLocalDateTime_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PAnyValue_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PAnyValue_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PKeyValue_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PKeyValue_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PMap_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PMap_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PHistoricalPrice_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PHistoricalPrice_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PFullHistoricalPrice_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PFullHistoricalPrice_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PSecurityEvent_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PSecurityEvent_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PSecurity_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PSecurity_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PWatchlist_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PWatchlist_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PAccount_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PAccount_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PPortfolio_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PPortfolio_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PTransactionUnit_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PTransactionUnit_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PTransaction_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PTransaction_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PInvestmentPlan_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PInvestmentPlan_fieldAccessorTable; + static final com.google.protobuf.Descriptors.Descriptor + internal_static_name_abuchen_portfolio_PInvestmentPlanLedgerExecutionRef_descriptor; + static final + com.google.protobuf.GeneratedMessageV3.FieldAccessorTable + internal_static_name_abuchen_portfolio_PInvestmentPlanLedgerExecutionRef_fieldAccessorTable; + static final com.google.protobuf.Descriptors.Descriptor + internal_static_name_abuchen_portfolio_PLedger_descriptor; + static final + com.google.protobuf.GeneratedMessageV3.FieldAccessorTable + internal_static_name_abuchen_portfolio_PLedger_fieldAccessorTable; + static final com.google.protobuf.Descriptors.Descriptor + internal_static_name_abuchen_portfolio_PLedgerEntry_descriptor; + static final + com.google.protobuf.GeneratedMessageV3.FieldAccessorTable + internal_static_name_abuchen_portfolio_PLedgerEntry_fieldAccessorTable; + static final com.google.protobuf.Descriptors.Descriptor + internal_static_name_abuchen_portfolio_PLedgerPosting_descriptor; + static final + com.google.protobuf.GeneratedMessageV3.FieldAccessorTable + internal_static_name_abuchen_portfolio_PLedgerPosting_fieldAccessorTable; + static final com.google.protobuf.Descriptors.Descriptor + internal_static_name_abuchen_portfolio_PLedgerProjectionRef_descriptor; + static final + com.google.protobuf.GeneratedMessageV3.FieldAccessorTable + internal_static_name_abuchen_portfolio_PLedgerProjectionRef_fieldAccessorTable; + static final com.google.protobuf.Descriptors.Descriptor + internal_static_name_abuchen_portfolio_PLedgerProjectionMembership_descriptor; + static final + com.google.protobuf.GeneratedMessageV3.FieldAccessorTable + internal_static_name_abuchen_portfolio_PLedgerProjectionMembership_fieldAccessorTable; + static final com.google.protobuf.Descriptors.Descriptor + internal_static_name_abuchen_portfolio_PLedgerParameter_descriptor; + static final + com.google.protobuf.GeneratedMessageV3.FieldAccessorTable + internal_static_name_abuchen_portfolio_PLedgerParameter_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PTaxonomy_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PTaxonomy_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PTaxonomy_Assignment_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PTaxonomy_Assignment_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PTaxonomy_Classification_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PTaxonomy_Classification_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PDashboard_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PDashboard_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PDashboard_Widget_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PDashboard_Widget_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PDashboard_Widget_ConfigurationEntry_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PDashboard_Widget_ConfigurationEntry_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PDashboard_Column_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PDashboard_Column_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PDashboard_ConfigurationEntry_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PDashboard_ConfigurationEntry_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PBookmark_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PBookmark_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PAttributeType_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PAttributeType_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PConfigurationSet_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PConfigurationSet_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PSettings_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PSettings_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PClient_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PClient_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PClient_PropertiesEntry_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PClient_PropertiesEntry_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PExchangeRate_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PExchangeRate_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PExchangeRateTimeSeries_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PExchangeRateTimeSeries_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PECBData_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PECBData_fieldAccessorTable; @@ -274,7 +309,7 @@ public static void registerAllExtensions( "olioB\017\n\r_otherAccountB\021\n\017_otherPortfolio" + "B\014\n\n_otherUuidB\021\n\017_otherUpdatedAtB\t\n\007_sh" + "aresB\007\n\005_noteB\013\n\t_securityB\t\n\007_sourceB\t\n" + - "\007_exDate\"\335\003\n\017PInvestmentPlan\022\014\n\004name\030\001 \001" + + "\007_exDate\"\265\004\n\017PInvestmentPlan\022\014\n\004name\030\001 \001" + "(\t\022\021\n\004note\030\002 \001(\tH\000\210\001\001\022\025\n\010security\030\003 \001(\tH" + "\001\210\001\001\022\026\n\tportfolio\030\004 \001(\tH\002\210\001\001\022\024\n\007account\030" + "\005 \001(\tH\003\210\001\001\0225\n\nattributes\030\006 \003(\0132!.name.ab" + @@ -283,77 +318,175 @@ public static void registerAllExtensions( "\016\n\006amount\030\n \001(\003\022\014\n\004fees\030\013 \001(\003\022\024\n\014transac" + "tions\030\014 \003(\t\022\r\n\005taxes\030\r \001(\003\022:\n\004type\030\016 \001(\016" + "2,.name.abuchen.portfolio.PInvestmentPla" + - "n.Type\"H\n\004Type\022\030\n\024PURCHASE_OR_DELIVERY\020\000" + - "\022\013\n\007DEPOSIT\020\001\022\013\n\007REMOVAL\020\002\022\014\n\010INTEREST\020\003" + - "B\007\n\005_noteB\013\n\t_securityB\014\n\n_portfolioB\n\n\010" + - "_account\"\252\004\n\tPTaxonomy\022\n\n\002id\030\001 \001(\t\022\014\n\004na" + - "me\030\002 \001(\t\022\023\n\006source\030\003 \001(\tH\000\210\001\001\022\022\n\ndimensi" + - "ons\030\004 \003(\t\022I\n\017classifications\030\005 \003(\01320.nam" + - "e.abuchen.portfolio.PTaxonomy.Classifica" + - "tion\032v\n\nAssignment\022\031\n\021investmentVehicle\030" + - "\001 \001(\t\022\016\n\006weight\030\002 \001(\005\022\014\n\004rank\030\003 \001(\005\022/\n\004d" + - "ata\030\004 \003(\0132!.name.abuchen.portfolio.PKeyV" + - "alue\032\213\002\n\016Classification\022\n\n\002id\030\001 \001(\t\022\025\n\010p" + - "arentId\030\002 \001(\tH\000\210\001\001\022\014\n\004name\030\003 \001(\t\022\021\n\004note" + - "\030\004 \001(\tH\001\210\001\001\022\r\n\005color\030\005 \001(\t\022\016\n\006weight\030\006 \001" + - "(\005\022\014\n\004rank\030\007 \001(\005\022/\n\004data\030\010 \003(\0132!.name.ab" + - "uchen.portfolio.PKeyValue\022A\n\013assignments" + - "\030\t \003(\0132,.name.abuchen.portfolio.PTaxonom" + - "y.AssignmentB\013\n\t_parentIdB\007\n\005_noteB\t\n\007_s" + - "ource\"\357\003\n\nPDashboard\022\014\n\004name\030\001 \001(\t\022L\n\rco" + - "nfiguration\030\002 \003(\01325.name.abuchen.portfol" + - "io.PDashboard.ConfigurationEntry\022:\n\007colu" + - "mns\030\003 \003(\0132).name.abuchen.portfolio.PDash" + - "board.Column\022\n\n\002id\030\004 \001(\t\032\260\001\n\006Widget\022\014\n\004t" + - "ype\030\001 \001(\t\022\r\n\005label\030\002 \001(\t\022S\n\rconfiguratio" + - "n\030\003 \003(\0132<.name.abuchen.portfolio.PDashbo" + - "ard.Widget.ConfigurationEntry\0324\n\022Configu" + - "rationEntry\022\013\n\003key\030\001 \001(\t\022\r\n\005value\030\002 \001(\t:" + - "\0028\001\032T\n\006Column\022\016\n\006weight\030\001 \001(\005\022:\n\007widgets" + - "\030\002 \003(\0132).name.abuchen.portfolio.PDashboa" + - "rd.Widget\0324\n\022ConfigurationEntry\022\013\n\003key\030\001" + - " \001(\t\022\r\n\005value\030\002 \001(\t:\0028\001\"+\n\tPBookmark\022\r\n\005" + - "label\030\001 \001(\t\022\017\n\007pattern\030\002 \001(\t\"\307\001\n\016PAttrib" + - "uteType\022\n\n\002id\030\001 \001(\t\022\014\n\004name\030\002 \001(\t\022\023\n\013col" + - "umnLabel\030\003 \001(\t\022\023\n\006source\030\004 \001(\tH\000\210\001\001\022\016\n\006t" + - "arget\030\005 \001(\t\022\014\n\004type\030\006 \001(\t\022\026\n\016converterCl" + - "ass\030\007 \001(\t\0220\n\nproperties\030\010 \001(\0132\034.name.abu" + - "chen.portfolio.PMapB\t\n\007_source\"J\n\021PConfi" + - "gurationSet\022\013\n\003key\030\001 \001(\t\022\014\n\004uuid\030\002 \001(\t\022\014" + - "\n\004name\030\003 \001(\t\022\014\n\004data\030\004 \001(\t\"\307\001\n\tPSettings" + - "\0224\n\tbookmarks\030\001 \003(\0132!.name.abuchen.portf" + - "olio.PBookmark\022>\n\016attributeTypes\030\002 \003(\0132&" + - ".name.abuchen.portfolio.PAttributeType\022D" + - "\n\021configurationSets\030\003 \003(\0132).name.abuchen" + - ".portfolio.PConfigurationSet\"\305\005\n\007PClient" + - "\022\017\n\007version\030\001 \001(\005\0225\n\nsecurities\030\002 \003(\0132!." + - "name.abuchen.portfolio.PSecurity\0222\n\010acco" + - "unts\030\003 \003(\0132 .name.abuchen.portfolio.PAcc" + - "ount\0226\n\nportfolios\030\004 \003(\0132\".name.abuchen." + - "portfolio.PPortfolio\022:\n\014transactions\030\005 \003" + - "(\0132$.name.abuchen.portfolio.PTransaction" + - "\0226\n\005plans\030\006 \003(\0132\'.name.abuchen.portfolio" + - ".PInvestmentPlan\0226\n\nwatchlists\030\007 \003(\0132\".n" + - "ame.abuchen.portfolio.PWatchlist\0225\n\ntaxo" + - "nomies\030\010 \003(\0132!.name.abuchen.portfolio.PT" + - "axonomy\0226\n\ndashboards\030\t \003(\0132\".name.abuch" + - "en.portfolio.PDashboard\022C\n\nproperties\030\n " + - "\003(\0132/.name.abuchen.portfolio.PClient.Pro" + - "pertiesEntry\0223\n\010settings\030\013 \001(\0132!.name.ab" + - "uchen.portfolio.PSettings\022\024\n\014baseCurrenc" + - "y\030\014 \001(\t\022(\n\nextensions\030c \003(\0132\024.google.pro" + - "tobuf.Any\0321\n\017PropertiesEntry\022\013\n\003key\030\001 \001(" + - "\t\022\r\n\005value\030\002 \001(\t:\0028\001\"S\n\rPExchangeRate\022\014\n" + - "\004date\030\001 \001(\003\0224\n\005value\030\002 \001(\0132%.name.abuche" + - "n.portfolio.PDecimalValue\"\203\001\n\027PExchangeR" + - "ateTimeSeries\022\024\n\014baseCurrency\030\001 \001(\t\022\024\n\014t" + - "ermCurrency\030\002 \001(\t\022<\n\rexchangeRates\030\003 \003(\013" + - "2%.name.abuchen.portfolio.PExchangeRate\"" + - "a\n\010PECBData\022\024\n\014lastModified\030\001 \001(\003\022?\n\006ser" + - "ies\030\002 \003(\0132/.name.abuchen.portfolio.PExch" + - "angeRateTimeSeriesB7\n%name.abuchen.portf" + - "olio.model.proto.v1B\014ClientProtosP\001b\006pro" + - "to3" + "n.Type\022V\n\023ledgerExecutionRefs\030\017 \003(\01329.na" + + "me.abuchen.portfolio.PInvestmentPlanLedg" + + "erExecutionRef\"H\n\004Type\022\030\n\024PURCHASE_OR_DE" + + "LIVERY\020\000\022\013\n\007DEPOSIT\020\001\022\013\n\007REMOVAL\020\002\022\014\n\010IN" + + "TEREST\020\003B\007\n\005_noteB\013\n\t_securityB\014\n\n_portf" + + "olioB\n\n\010_account\"\313\001\n!PInvestmentPlanLedg" + + "erExecutionRef\022\027\n\017ledgerEntryUUID\030\001 \001(\t\022" + + "\033\n\016projectionUUID\030\002 \001(\tH\000\210\001\001\022J\n\016projecti" + + "onRole\030\003 \001(\0162-.name.abuchen.portfolio.PL" + + "edgerProjectionRoleH\001\210\001\001B\021\n\017_projectionU" + + "UIDB\021\n\017_projectionRole\"@\n\007PLedger\0225\n\007ent" + + "ries\030\001 \003(\0132$.name.abuchen.portfolio.PLed" + + "gerEntry\"\231\003\n\014PLedgerEntry\022\014\n\004uuid\030\001 \001(\t\022" + + ",\n\010dateTime\030\003 \001(\0132\032.google.protobuf.Time" + + "stamp\022\021\n\004note\030\004 \001(\tH\000\210\001\001\022\023\n\006source\030\005 \001(\t" + + "H\001\210\001\001\022-\n\tupdatedAt\030\006 \001(\0132\032.google.protob" + + "uf.Timestamp\0228\n\010postings\030\007 \003(\0132&.name.ab" + + "uchen.portfolio.PLedgerPosting\022D\n\016projec" + + "tionRefs\030\010 \003(\0132,.name.abuchen.portfolio." + + "PLedgerProjectionRef\022\023\n\006typeId\030\t \001(\rH\002\210\001" + + "\001\022<\n\nparameters\030\n \003(\0132(.name.abuchen.por" + + "tfolio.PLedgerParameterB\007\n\005_noteB\t\n\007_sou" + + "rceB\t\n\007_typeIdJ\004\010\002\020\003\"\341\003\n\016PLedgerPosting\022" + + "\014\n\004uuid\030\001 \001(\t\022\016\n\006amount\030\003 \001(\003\022\025\n\010currenc" + + "y\030\004 \001(\tH\000\210\001\001\022\030\n\013forexAmount\030\005 \001(\003H\001\210\001\001\022\032" + + "\n\rforexCurrency\030\006 \001(\tH\002\210\001\001\022@\n\014exchangeRa" + + "te\030\007 \001(\0132%.name.abuchen.portfolio.PDecim" + + "alValueH\003\210\001\001\022\025\n\010security\030\010 \001(\tH\004\210\001\001\022\016\n\006s" + + "hares\030\t \001(\003\022\024\n\007account\030\n \001(\tH\005\210\001\001\022\026\n\tpor" + + "tfolio\030\013 \001(\tH\006\210\001\001\022<\n\nparameters\030\014 \003(\0132(." + + "name.abuchen.portfolio.PLedgerParameter\022" + + "\025\n\010typeCode\030\r \001(\tH\007\210\001\001B\013\n\t_currencyB\016\n\014_" + + "forexAmountB\020\n\016_forexCurrencyB\017\n\r_exchan" + + "geRateB\013\n\t_securityB\n\n\010_accountB\014\n\n_port" + + "folioB\013\n\t_typeCodeJ\004\010\002\020\003\"\363\001\n\024PLedgerProj" + + "ectionRef\022\014\n\004uuid\030\001 \001(\t\022;\n\004role\030\002 \001(\0162-." + + "name.abuchen.portfolio.PLedgerProjection" + + "Role\022\024\n\007account\030\003 \001(\tH\000\210\001\001\022\026\n\tportfolio\030" + + "\004 \001(\tH\001\210\001\001\022H\n\013memberships\030\005 \003(\01323.name.a" + + "buchen.portfolio.PLedgerProjectionMember" + + "shipB\n\n\010_accountB\014\n\n_portfolio\"y\n\033PLedge" + + "rProjectionMembership\022\023\n\013postingUUID\030\001 \001" + + "(\t\022E\n\004role\030\002 \001(\01627.name.abuchen.portfoli" + + "o.PLedgerProjectionMembershipRole\"\266\005\n\020PL" + + "edgerParameter\022D\n\tvalueKind\030\002 \001(\01621.name" + + ".abuchen.portfolio.PLedgerParameterValue" + + "Kind\022\030\n\013stringValue\030\003 \001(\tH\000\210\001\001\022@\n\014decima" + + "lValue\030\004 \001(\0132%.name.abuchen.portfolio.PD" + + "ecimalValueH\001\210\001\001\022\026\n\tlongValue\030\005 \001(\003H\002\210\001\001" + + "\022\030\n\013moneyAmount\030\006 \001(\003H\003\210\001\001\022\032\n\rmoneyCurre" + + "ncy\030\007 \001(\tH\004\210\001\001\022\025\n\010security\030\010 \001(\tH\005\210\001\001\022\024\n" + + "\007account\030\t \001(\tH\006\210\001\001\022\026\n\tportfolio\030\n \001(\tH\007" + + "\210\001\001\022G\n\022localDateTimeValue\030\014 \001(\0132&.name.a" + + "buchen.portfolio.PLocalDateTimeH\010\210\001\001\022\025\n\010" + + "typeCode\030\r \001(\tH\t\210\001\001\022\031\n\014booleanValue\030\016 \001(" + + "\010H\n\210\001\001\022\033\n\016localDateValue\030\017 \001(\003H\013\210\001\001B\016\n\014_" + + "stringValueB\017\n\r_decimalValueB\014\n\n_longVal" + + "ueB\016\n\014_moneyAmountB\020\n\016_moneyCurrencyB\013\n\t" + + "_securityB\n\n\010_accountB\014\n\n_portfolioB\025\n\023_" + + "localDateTimeValueB\013\n\t_typeCodeB\017\n\r_bool" + + "eanValueB\021\n\017_localDateValueJ\004\010\001\020\002J\004\010\013\020\014R" + + "\tenumValue\"\252\004\n\tPTaxonomy\022\n\n\002id\030\001 \001(\t\022\014\n\004" + + "name\030\002 \001(\t\022\023\n\006source\030\003 \001(\tH\000\210\001\001\022\022\n\ndimen" + + "sions\030\004 \003(\t\022I\n\017classifications\030\005 \003(\01320.n" + + "ame.abuchen.portfolio.PTaxonomy.Classifi" + + "cation\032v\n\nAssignment\022\031\n\021investmentVehicl" + + "e\030\001 \001(\t\022\016\n\006weight\030\002 \001(\005\022\014\n\004rank\030\003 \001(\005\022/\n" + + "\004data\030\004 \003(\0132!.name.abuchen.portfolio.PKe" + + "yValue\032\213\002\n\016Classification\022\n\n\002id\030\001 \001(\t\022\025\n" + + "\010parentId\030\002 \001(\tH\000\210\001\001\022\014\n\004name\030\003 \001(\t\022\021\n\004no" + + "te\030\004 \001(\tH\001\210\001\001\022\r\n\005color\030\005 \001(\t\022\016\n\006weight\030\006" + + " \001(\005\022\014\n\004rank\030\007 \001(\005\022/\n\004data\030\010 \003(\0132!.name." + + "abuchen.portfolio.PKeyValue\022A\n\013assignmen" + + "ts\030\t \003(\0132,.name.abuchen.portfolio.PTaxon" + + "omy.AssignmentB\013\n\t_parentIdB\007\n\005_noteB\t\n\007" + + "_source\"\357\003\n\nPDashboard\022\014\n\004name\030\001 \001(\t\022L\n\r" + + "configuration\030\002 \003(\01325.name.abuchen.portf" + + "olio.PDashboard.ConfigurationEntry\022:\n\007co" + + "lumns\030\003 \003(\0132).name.abuchen.portfolio.PDa" + + "shboard.Column\022\n\n\002id\030\004 \001(\t\032\260\001\n\006Widget\022\014\n" + + "\004type\030\001 \001(\t\022\r\n\005label\030\002 \001(\t\022S\n\rconfigurat" + + "ion\030\003 \003(\0132<.name.abuchen.portfolio.PDash" + + "board.Widget.ConfigurationEntry\0324\n\022Confi" + + "gurationEntry\022\013\n\003key\030\001 \001(\t\022\r\n\005value\030\002 \001(" + + "\t:\0028\001\032T\n\006Column\022\016\n\006weight\030\001 \001(\005\022:\n\007widge" + + "ts\030\002 \003(\0132).name.abuchen.portfolio.PDashb" + + "oard.Widget\0324\n\022ConfigurationEntry\022\013\n\003key" + + "\030\001 \001(\t\022\r\n\005value\030\002 \001(\t:\0028\001\"+\n\tPBookmark\022\r" + + "\n\005label\030\001 \001(\t\022\017\n\007pattern\030\002 \001(\t\"\307\001\n\016PAttr" + + "ibuteType\022\n\n\002id\030\001 \001(\t\022\014\n\004name\030\002 \001(\t\022\023\n\013c" + + "olumnLabel\030\003 \001(\t\022\023\n\006source\030\004 \001(\tH\000\210\001\001\022\016\n" + + "\006target\030\005 \001(\t\022\014\n\004type\030\006 \001(\t\022\026\n\016converter" + + "Class\030\007 \001(\t\0220\n\nproperties\030\010 \001(\0132\034.name.a" + + "buchen.portfolio.PMapB\t\n\007_source\"J\n\021PCon" + + "figurationSet\022\013\n\003key\030\001 \001(\t\022\014\n\004uuid\030\002 \001(\t" + + "\022\014\n\004name\030\003 \001(\t\022\014\n\004data\030\004 \001(\t\"\307\001\n\tPSettin" + + "gs\0224\n\tbookmarks\030\001 \003(\0132!.name.abuchen.por" + + "tfolio.PBookmark\022>\n\016attributeTypes\030\002 \003(\013" + + "2&.name.abuchen.portfolio.PAttributeType" + + "\022D\n\021configurationSets\030\003 \003(\0132).name.abuch" + + "en.portfolio.PConfigurationSet\"\366\005\n\007PClie" + + "nt\022\017\n\007version\030\001 \001(\005\0225\n\nsecurities\030\002 \003(\0132" + + "!.name.abuchen.portfolio.PSecurity\0222\n\010ac" + + "counts\030\003 \003(\0132 .name.abuchen.portfolio.PA" + + "ccount\0226\n\nportfolios\030\004 \003(\0132\".name.abuche" + + "n.portfolio.PPortfolio\022:\n\014transactions\030\005" + + " \003(\0132$.name.abuchen.portfolio.PTransacti" + + "on\0226\n\005plans\030\006 \003(\0132\'.name.abuchen.portfol" + + "io.PInvestmentPlan\0226\n\nwatchlists\030\007 \003(\0132\"" + + ".name.abuchen.portfolio.PWatchlist\0225\n\nta" + + "xonomies\030\010 \003(\0132!.name.abuchen.portfolio." + + "PTaxonomy\0226\n\ndashboards\030\t \003(\0132\".name.abu" + + "chen.portfolio.PDashboard\022C\n\nproperties\030" + + "\n \003(\0132/.name.abuchen.portfolio.PClient.P" + + "ropertiesEntry\0223\n\010settings\030\013 \001(\0132!.name." + + "abuchen.portfolio.PSettings\022\024\n\014baseCurre" + + "ncy\030\014 \001(\t\022/\n\006ledger\030\r \001(\0132\037.name.abuchen" + + ".portfolio.PLedger\022(\n\nextensions\030c \003(\0132\024" + + ".google.protobuf.Any\0321\n\017PropertiesEntry\022" + + "\013\n\003key\030\001 \001(\t\022\r\n\005value\030\002 \001(\t:\0028\001\"S\n\rPExch" + + "angeRate\022\014\n\004date\030\001 \001(\003\0224\n\005value\030\002 \001(\0132%." + + "name.abuchen.portfolio.PDecimalValue\"\203\001\n" + + "\027PExchangeRateTimeSeries\022\024\n\014baseCurrency" + + "\030\001 \001(\t\022\024\n\014termCurrency\030\002 \001(\t\022<\n\rexchange" + + "Rates\030\003 \003(\0132%.name.abuchen.portfolio.PEx" + + "changeRate\"a\n\010PECBData\022\024\n\014lastModified\030\001" + + " \001(\003\022?\n\006series\030\002 \003(\0132/.name.abuchen.port" + + "folio.PExchangeRateTimeSeries*\301\004\n\025PLedge" + + "rProjectionRole\022&\n\"LEDGER_PROJECTION_ROL" + + "E_UNSPECIFIED\020\000\022\"\n\036LEDGER_PROJECTION_ROL" + + "E_ACCOUNT\020\001\022$\n LEDGER_PROJECTION_ROLE_PO" + + "RTFOLIO\020\002\022)\n%LEDGER_PROJECTION_ROLE_SOUR" + + "CE_ACCOUNT\020\003\022)\n%LEDGER_PROJECTION_ROLE_T" + + "ARGET_ACCOUNT\020\004\022+\n\'LEDGER_PROJECTION_ROL" + + "E_SOURCE_PORTFOLIO\020\005\022+\n\'LEDGER_PROJECTIO" + + "N_ROLE_TARGET_PORTFOLIO\020\006\022#\n\037LEDGER_PROJ" + + "ECTION_ROLE_DELIVERY\020\007\022+\n\'LEDGER_PROJECT" + + "ION_ROLE_DELIVERY_INBOUND\020\010\022,\n(LEDGER_PR" + + "OJECTION_ROLE_DELIVERY_OUTBOUND\020\t\022,\n(LED" + + "GER_PROJECTION_ROLE_CASH_COMPENSATION\020\n\022" + + "+\n\'LEDGER_PROJECTION_ROLE_OLD_SECURITY_L" + + "EG\020\013\022+\n\'LEDGER_PROJECTION_ROLE_NEW_SECUR" + + "ITY_LEG\020\014*\204\003\n\037PLedgerProjectionMembershi" + + "pRole\0221\n-LEDGER_PROJECTION_MEMBERSHIP_RO" + + "LE_UNSPECIFIED\020\000\022-\n)LEDGER_PROJECTION_ME" + + "MBERSHIP_ROLE_PRIMARY\020\001\0222\n.LEDGER_PROJEC" + + "TION_MEMBERSHIP_ROLE_GROUP_ANCHOR\020\002\022.\n*L" + + "EDGER_PROJECTION_MEMBERSHIP_ROLE_FEE_UNI" + + "T\020\003\022.\n*LEDGER_PROJECTION_MEMBERSHIP_ROLE" + + "_TAX_UNIT\020\004\0226\n2LEDGER_PROJECTION_MEMBERS" + + "HIP_ROLE_GROSS_VALUE_UNIT\020\005\0223\n/LEDGER_PR" + + "OJECTION_MEMBERSHIP_ROLE_FOREX_CONTEXT\020\006" + + "*\327\004\n\031PLedgerParameterValueKind\022+\n\'LEDGER" + + "_PARAMETER_VALUE_KIND_UNSPECIFIED\020\000\022&\n\"L" + + "EDGER_PARAMETER_VALUE_KIND_STRING\020\001\022\'\n#L" + + "EDGER_PARAMETER_VALUE_KIND_DECIMAL\020\002\022$\n " + + "LEDGER_PARAMETER_VALUE_KIND_LONG\020\003\022%\n!LE" + + "DGER_PARAMETER_VALUE_KIND_MONEY\020\004\022(\n$LED" + + "GER_PARAMETER_VALUE_KIND_SECURITY\020\005\022\'\n#L" + + "EDGER_PARAMETER_VALUE_KIND_ACCOUNT\020\006\022)\n%" + + "LEDGER_PARAMETER_VALUE_KIND_PORTFOLIO\020\007\022" + + "/\n+LEDGER_PARAMETER_VALUE_KIND_LOCAL_DAT" + + "E_TIME\020\n\022\'\n#LEDGER_PARAMETER_VALUE_KIND_" + + "BOOLEAN\020\013\022*\n&LEDGER_PARAMETER_VALUE_KIND" + + "_LOCAL_DATE\020\014\"\004\010\010\020\010\"\004\010\t\020\t*0LEDGER_PARAME" + + "TER_VALUE_KIND_CORPORATE_ACTION_LEG*-LED" + + "GER_PARAMETER_VALUE_KIND_COMPENSATION_KI" + + "NDB7\n%name.abuchen.portfolio.model.proto" + + ".v1B\014ClientProtosP\001b\006proto3" }; descriptor = com.google.protobuf.Descriptors.FileDescriptor .internalBuildGeneratedFileFrom(descriptorData, @@ -451,9 +584,51 @@ public static void registerAllExtensions( internal_static_name_abuchen_portfolio_PInvestmentPlan_fieldAccessorTable = new com.google.protobuf.GeneratedMessageV3.FieldAccessorTable( internal_static_name_abuchen_portfolio_PInvestmentPlan_descriptor, - new java.lang.String[] { "Name", "Note", "Security", "Portfolio", "Account", "Attributes", "AutoGenerate", "Date", "Interval", "Amount", "Fees", "Transactions", "Taxes", "Type", "Note", "Security", "Portfolio", "Account", }); - internal_static_name_abuchen_portfolio_PTaxonomy_descriptor = + new java.lang.String[] { "Name", "Note", "Security", "Portfolio", "Account", "Attributes", "AutoGenerate", "Date", "Interval", "Amount", "Fees", "Transactions", "Taxes", "Type", "LedgerExecutionRefs", "Note", "Security", "Portfolio", "Account", }); + internal_static_name_abuchen_portfolio_PInvestmentPlanLedgerExecutionRef_descriptor = getDescriptor().getMessageTypes().get(15); + internal_static_name_abuchen_portfolio_PInvestmentPlanLedgerExecutionRef_fieldAccessorTable = new + com.google.protobuf.GeneratedMessageV3.FieldAccessorTable( + internal_static_name_abuchen_portfolio_PInvestmentPlanLedgerExecutionRef_descriptor, + new java.lang.String[] { "LedgerEntryUUID", "ProjectionUUID", "ProjectionRole", "ProjectionUUID", "ProjectionRole", }); + internal_static_name_abuchen_portfolio_PLedger_descriptor = + getDescriptor().getMessageTypes().get(16); + internal_static_name_abuchen_portfolio_PLedger_fieldAccessorTable = new + com.google.protobuf.GeneratedMessageV3.FieldAccessorTable( + internal_static_name_abuchen_portfolio_PLedger_descriptor, + new java.lang.String[] { "Entries", }); + internal_static_name_abuchen_portfolio_PLedgerEntry_descriptor = + getDescriptor().getMessageTypes().get(17); + internal_static_name_abuchen_portfolio_PLedgerEntry_fieldAccessorTable = new + com.google.protobuf.GeneratedMessageV3.FieldAccessorTable( + internal_static_name_abuchen_portfolio_PLedgerEntry_descriptor, + new java.lang.String[] { "Uuid", "DateTime", "Note", "Source", "UpdatedAt", "Postings", "ProjectionRefs", "TypeId", "Parameters", "Note", "Source", "TypeId", }); + internal_static_name_abuchen_portfolio_PLedgerPosting_descriptor = + getDescriptor().getMessageTypes().get(18); + internal_static_name_abuchen_portfolio_PLedgerPosting_fieldAccessorTable = new + com.google.protobuf.GeneratedMessageV3.FieldAccessorTable( + internal_static_name_abuchen_portfolio_PLedgerPosting_descriptor, + new java.lang.String[] { "Uuid", "Amount", "Currency", "ForexAmount", "ForexCurrency", "ExchangeRate", "Security", "Shares", "Account", "Portfolio", "Parameters", "TypeCode", "Currency", "ForexAmount", "ForexCurrency", "ExchangeRate", "Security", "Account", "Portfolio", "TypeCode", }); + internal_static_name_abuchen_portfolio_PLedgerProjectionRef_descriptor = + getDescriptor().getMessageTypes().get(19); + internal_static_name_abuchen_portfolio_PLedgerProjectionRef_fieldAccessorTable = new + com.google.protobuf.GeneratedMessageV3.FieldAccessorTable( + internal_static_name_abuchen_portfolio_PLedgerProjectionRef_descriptor, + new java.lang.String[] { "Uuid", "Role", "Account", "Portfolio", "Memberships", "Account", "Portfolio", }); + internal_static_name_abuchen_portfolio_PLedgerProjectionMembership_descriptor = + getDescriptor().getMessageTypes().get(20); + internal_static_name_abuchen_portfolio_PLedgerProjectionMembership_fieldAccessorTable = new + com.google.protobuf.GeneratedMessageV3.FieldAccessorTable( + internal_static_name_abuchen_portfolio_PLedgerProjectionMembership_descriptor, + new java.lang.String[] { "PostingUUID", "Role", }); + internal_static_name_abuchen_portfolio_PLedgerParameter_descriptor = + getDescriptor().getMessageTypes().get(21); + internal_static_name_abuchen_portfolio_PLedgerParameter_fieldAccessorTable = new + com.google.protobuf.GeneratedMessageV3.FieldAccessorTable( + internal_static_name_abuchen_portfolio_PLedgerParameter_descriptor, + new java.lang.String[] { "ValueKind", "StringValue", "DecimalValue", "LongValue", "MoneyAmount", "MoneyCurrency", "Security", "Account", "Portfolio", "LocalDateTimeValue", "TypeCode", "BooleanValue", "LocalDateValue", "StringValue", "DecimalValue", "LongValue", "MoneyAmount", "MoneyCurrency", "Security", "Account", "Portfolio", "LocalDateTimeValue", "TypeCode", "BooleanValue", "LocalDateValue", }); + internal_static_name_abuchen_portfolio_PTaxonomy_descriptor = + getDescriptor().getMessageTypes().get(22); internal_static_name_abuchen_portfolio_PTaxonomy_fieldAccessorTable = new com.google.protobuf.GeneratedMessageV3.FieldAccessorTable( internal_static_name_abuchen_portfolio_PTaxonomy_descriptor, @@ -471,7 +646,7 @@ public static void registerAllExtensions( internal_static_name_abuchen_portfolio_PTaxonomy_Classification_descriptor, new java.lang.String[] { "Id", "ParentId", "Name", "Note", "Color", "Weight", "Rank", "Data", "Assignments", "ParentId", "Note", }); internal_static_name_abuchen_portfolio_PDashboard_descriptor = - getDescriptor().getMessageTypes().get(16); + getDescriptor().getMessageTypes().get(23); internal_static_name_abuchen_portfolio_PDashboard_fieldAccessorTable = new com.google.protobuf.GeneratedMessageV3.FieldAccessorTable( internal_static_name_abuchen_portfolio_PDashboard_descriptor, @@ -501,35 +676,35 @@ public static void registerAllExtensions( internal_static_name_abuchen_portfolio_PDashboard_ConfigurationEntry_descriptor, new java.lang.String[] { "Key", "Value", }); internal_static_name_abuchen_portfolio_PBookmark_descriptor = - getDescriptor().getMessageTypes().get(17); + getDescriptor().getMessageTypes().get(24); internal_static_name_abuchen_portfolio_PBookmark_fieldAccessorTable = new com.google.protobuf.GeneratedMessageV3.FieldAccessorTable( internal_static_name_abuchen_portfolio_PBookmark_descriptor, new java.lang.String[] { "Label", "Pattern", }); internal_static_name_abuchen_portfolio_PAttributeType_descriptor = - getDescriptor().getMessageTypes().get(18); + getDescriptor().getMessageTypes().get(25); internal_static_name_abuchen_portfolio_PAttributeType_fieldAccessorTable = new com.google.protobuf.GeneratedMessageV3.FieldAccessorTable( internal_static_name_abuchen_portfolio_PAttributeType_descriptor, new java.lang.String[] { "Id", "Name", "ColumnLabel", "Source", "Target", "Type", "ConverterClass", "Properties", "Source", }); internal_static_name_abuchen_portfolio_PConfigurationSet_descriptor = - getDescriptor().getMessageTypes().get(19); + getDescriptor().getMessageTypes().get(26); internal_static_name_abuchen_portfolio_PConfigurationSet_fieldAccessorTable = new com.google.protobuf.GeneratedMessageV3.FieldAccessorTable( internal_static_name_abuchen_portfolio_PConfigurationSet_descriptor, new java.lang.String[] { "Key", "Uuid", "Name", "Data", }); internal_static_name_abuchen_portfolio_PSettings_descriptor = - getDescriptor().getMessageTypes().get(20); + getDescriptor().getMessageTypes().get(27); internal_static_name_abuchen_portfolio_PSettings_fieldAccessorTable = new com.google.protobuf.GeneratedMessageV3.FieldAccessorTable( internal_static_name_abuchen_portfolio_PSettings_descriptor, new java.lang.String[] { "Bookmarks", "AttributeTypes", "ConfigurationSets", }); internal_static_name_abuchen_portfolio_PClient_descriptor = - getDescriptor().getMessageTypes().get(21); + getDescriptor().getMessageTypes().get(28); internal_static_name_abuchen_portfolio_PClient_fieldAccessorTable = new com.google.protobuf.GeneratedMessageV3.FieldAccessorTable( internal_static_name_abuchen_portfolio_PClient_descriptor, - new java.lang.String[] { "Version", "Securities", "Accounts", "Portfolios", "Transactions", "Plans", "Watchlists", "Taxonomies", "Dashboards", "Properties", "Settings", "BaseCurrency", "Extensions", }); + new java.lang.String[] { "Version", "Securities", "Accounts", "Portfolios", "Transactions", "Plans", "Watchlists", "Taxonomies", "Dashboards", "Properties", "Settings", "BaseCurrency", "Ledger", "Extensions", }); internal_static_name_abuchen_portfolio_PClient_PropertiesEntry_descriptor = internal_static_name_abuchen_portfolio_PClient_descriptor.getNestedTypes().get(0); internal_static_name_abuchen_portfolio_PClient_PropertiesEntry_fieldAccessorTable = new @@ -537,19 +712,19 @@ public static void registerAllExtensions( internal_static_name_abuchen_portfolio_PClient_PropertiesEntry_descriptor, new java.lang.String[] { "Key", "Value", }); internal_static_name_abuchen_portfolio_PExchangeRate_descriptor = - getDescriptor().getMessageTypes().get(22); + getDescriptor().getMessageTypes().get(29); internal_static_name_abuchen_portfolio_PExchangeRate_fieldAccessorTable = new com.google.protobuf.GeneratedMessageV3.FieldAccessorTable( internal_static_name_abuchen_portfolio_PExchangeRate_descriptor, new java.lang.String[] { "Date", "Value", }); internal_static_name_abuchen_portfolio_PExchangeRateTimeSeries_descriptor = - getDescriptor().getMessageTypes().get(23); + getDescriptor().getMessageTypes().get(30); internal_static_name_abuchen_portfolio_PExchangeRateTimeSeries_fieldAccessorTable = new com.google.protobuf.GeneratedMessageV3.FieldAccessorTable( internal_static_name_abuchen_portfolio_PExchangeRateTimeSeries_descriptor, new java.lang.String[] { "BaseCurrency", "TermCurrency", "ExchangeRates", }); internal_static_name_abuchen_portfolio_PECBData_descriptor = - getDescriptor().getMessageTypes().get(24); + getDescriptor().getMessageTypes().get(31); internal_static_name_abuchen_portfolio_PECBData_fieldAccessorTable = new com.google.protobuf.GeneratedMessageV3.FieldAccessorTable( internal_static_name_abuchen_portfolio_PECBData_descriptor, diff --git a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PClient.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PClient.java index c5e885b58d..1b5a0383ce 100644 --- a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PClient.java +++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PClient.java @@ -543,6 +543,32 @@ public java.lang.String getBaseCurrency() { } } + public static final int LEDGER_FIELD_NUMBER = 13; + private name.abuchen.portfolio.model.proto.v1.PLedger ledger_; + /** + * .name.abuchen.portfolio.PLedger ledger = 13; + * @return Whether the ledger field is set. + */ + @java.lang.Override + public boolean hasLedger() { + return ledger_ != null; + } + /** + * .name.abuchen.portfolio.PLedger ledger = 13; + * @return The ledger. + */ + @java.lang.Override + public name.abuchen.portfolio.model.proto.v1.PLedger getLedger() { + return ledger_ == null ? name.abuchen.portfolio.model.proto.v1.PLedger.getDefaultInstance() : ledger_; + } + /** + * .name.abuchen.portfolio.PLedger ledger = 13; + */ + @java.lang.Override + public name.abuchen.portfolio.model.proto.v1.PLedgerOrBuilder getLedgerOrBuilder() { + return ledger_ == null ? name.abuchen.portfolio.model.proto.v1.PLedger.getDefaultInstance() : ledger_; + } + public static final int EXTENSIONS_FIELD_NUMBER = 99; @SuppressWarnings("serial") private java.util.List extensions_; @@ -657,6 +683,9 @@ public void writeTo(com.google.protobuf.CodedOutputStream output) if (!com.google.protobuf.GeneratedMessageV3.isStringEmpty(baseCurrency_)) { com.google.protobuf.GeneratedMessageV3.writeString(output, 12, baseCurrency_); } + if (ledger_ != null) { + output.writeMessage(13, getLedger()); + } for (int i = 0; i < extensions_.size(); i++) { output.writeMessage(99, extensions_.get(i)); } @@ -722,6 +751,10 @@ public int getSerializedSize() { if (!com.google.protobuf.GeneratedMessageV3.isStringEmpty(baseCurrency_)) { size += com.google.protobuf.GeneratedMessageV3.computeStringSize(12, baseCurrency_); } + if (ledger_ != null) { + size += com.google.protobuf.CodedOutputStream + .computeMessageSize(13, getLedger()); + } for (int i = 0; i < extensions_.size(); i++) { size += com.google.protobuf.CodedOutputStream .computeMessageSize(99, extensions_.get(i)); @@ -768,6 +801,11 @@ public boolean equals(final java.lang.Object obj) { } if (!getBaseCurrency() .equals(other.getBaseCurrency())) return false; + if (hasLedger() != other.hasLedger()) return false; + if (hasLedger()) { + if (!getLedger() + .equals(other.getLedger())) return false; + } if (!getExtensionsList() .equals(other.getExtensionsList())) return false; if (!getUnknownFields().equals(other.getUnknownFields())) return false; @@ -825,6 +863,10 @@ public int hashCode() { } hash = (37 * hash) + BASECURRENCY_FIELD_NUMBER; hash = (53 * hash) + getBaseCurrency().hashCode(); + if (hasLedger()) { + hash = (37 * hash) + LEDGER_FIELD_NUMBER; + hash = (53 * hash) + getLedger().hashCode(); + } if (getExtensionsCount() > 0) { hash = (37 * hash) + EXTENSIONS_FIELD_NUMBER; hash = (53 * hash) + getExtensionsList().hashCode(); @@ -1044,13 +1086,18 @@ public Builder clear() { settingsBuilder_ = null; } baseCurrency_ = ""; + ledger_ = null; + if (ledgerBuilder_ != null) { + ledgerBuilder_.dispose(); + ledgerBuilder_ = null; + } if (extensionsBuilder_ == null) { extensions_ = java.util.Collections.emptyList(); } else { extensions_ = null; extensionsBuilder_.clear(); } - bitField0_ = (bitField0_ & ~0x00001000); + bitField0_ = (bitField0_ & ~0x00002000); return this; } @@ -1157,9 +1204,9 @@ private void buildPartialRepeatedFields(name.abuchen.portfolio.model.proto.v1.PC result.dashboards_ = dashboardsBuilder_.build(); } if (extensionsBuilder_ == null) { - if (((bitField0_ & 0x00001000) != 0)) { + if (((bitField0_ & 0x00002000) != 0)) { extensions_ = java.util.Collections.unmodifiableList(extensions_); - bitField0_ = (bitField0_ & ~0x00001000); + bitField0_ = (bitField0_ & ~0x00002000); } result.extensions_ = extensions_; } else { @@ -1184,6 +1231,11 @@ private void buildPartial0(name.abuchen.portfolio.model.proto.v1.PClient result) if (((from_bitField0_ & 0x00000800) != 0)) { result.baseCurrency_ = baseCurrency_; } + if (((from_bitField0_ & 0x00001000) != 0)) { + result.ledger_ = ledgerBuilder_ == null + ? ledger_ + : ledgerBuilder_.build(); + } } @java.lang.Override @@ -1420,11 +1472,14 @@ public Builder mergeFrom(name.abuchen.portfolio.model.proto.v1.PClient other) { bitField0_ |= 0x00000800; onChanged(); } + if (other.hasLedger()) { + mergeLedger(other.getLedger()); + } if (extensionsBuilder_ == null) { if (!other.extensions_.isEmpty()) { if (extensions_.isEmpty()) { extensions_ = other.extensions_; - bitField0_ = (bitField0_ & ~0x00001000); + bitField0_ = (bitField0_ & ~0x00002000); } else { ensureExtensionsIsMutable(); extensions_.addAll(other.extensions_); @@ -1437,7 +1492,7 @@ public Builder mergeFrom(name.abuchen.portfolio.model.proto.v1.PClient other) { extensionsBuilder_.dispose(); extensionsBuilder_ = null; extensions_ = other.extensions_; - bitField0_ = (bitField0_ & ~0x00001000); + bitField0_ = (bitField0_ & ~0x00002000); extensionsBuilder_ = com.google.protobuf.GeneratedMessageV3.alwaysUseFieldBuilders ? getExtensionsFieldBuilder() : null; @@ -1602,6 +1657,13 @@ public Builder mergeFrom( bitField0_ |= 0x00000800; break; } // case 98 + case 106: { + input.readMessage( + getLedgerFieldBuilder().getBuilder(), + extensionRegistry); + bitField0_ |= 0x00001000; + break; + } // case 106 case 794: { com.google.protobuf.Any m = input.readMessage( @@ -3902,12 +3964,131 @@ public Builder setBaseCurrencyBytes( return this; } + private name.abuchen.portfolio.model.proto.v1.PLedger ledger_; + private com.google.protobuf.SingleFieldBuilderV3< + name.abuchen.portfolio.model.proto.v1.PLedger, name.abuchen.portfolio.model.proto.v1.PLedger.Builder, name.abuchen.portfolio.model.proto.v1.PLedgerOrBuilder> ledgerBuilder_; + /** + * .name.abuchen.portfolio.PLedger ledger = 13; + * @return Whether the ledger field is set. + */ + public boolean hasLedger() { + return ((bitField0_ & 0x00001000) != 0); + } + /** + * .name.abuchen.portfolio.PLedger ledger = 13; + * @return The ledger. + */ + public name.abuchen.portfolio.model.proto.v1.PLedger getLedger() { + if (ledgerBuilder_ == null) { + return ledger_ == null ? name.abuchen.portfolio.model.proto.v1.PLedger.getDefaultInstance() : ledger_; + } else { + return ledgerBuilder_.getMessage(); + } + } + /** + * .name.abuchen.portfolio.PLedger ledger = 13; + */ + public Builder setLedger(name.abuchen.portfolio.model.proto.v1.PLedger value) { + if (ledgerBuilder_ == null) { + if (value == null) { + throw new NullPointerException(); + } + ledger_ = value; + } else { + ledgerBuilder_.setMessage(value); + } + bitField0_ |= 0x00001000; + onChanged(); + return this; + } + /** + * .name.abuchen.portfolio.PLedger ledger = 13; + */ + public Builder setLedger( + name.abuchen.portfolio.model.proto.v1.PLedger.Builder builderForValue) { + if (ledgerBuilder_ == null) { + ledger_ = builderForValue.build(); + } else { + ledgerBuilder_.setMessage(builderForValue.build()); + } + bitField0_ |= 0x00001000; + onChanged(); + return this; + } + /** + * .name.abuchen.portfolio.PLedger ledger = 13; + */ + public Builder mergeLedger(name.abuchen.portfolio.model.proto.v1.PLedger value) { + if (ledgerBuilder_ == null) { + if (((bitField0_ & 0x00001000) != 0) && + ledger_ != null && + ledger_ != name.abuchen.portfolio.model.proto.v1.PLedger.getDefaultInstance()) { + getLedgerBuilder().mergeFrom(value); + } else { + ledger_ = value; + } + } else { + ledgerBuilder_.mergeFrom(value); + } + bitField0_ |= 0x00001000; + onChanged(); + return this; + } + /** + * .name.abuchen.portfolio.PLedger ledger = 13; + */ + public Builder clearLedger() { + bitField0_ = (bitField0_ & ~0x00001000); + ledger_ = null; + if (ledgerBuilder_ != null) { + ledgerBuilder_.dispose(); + ledgerBuilder_ = null; + } + onChanged(); + return this; + } + /** + * .name.abuchen.portfolio.PLedger ledger = 13; + */ + public name.abuchen.portfolio.model.proto.v1.PLedger.Builder getLedgerBuilder() { + bitField0_ |= 0x00001000; + onChanged(); + return getLedgerFieldBuilder().getBuilder(); + } + /** + * .name.abuchen.portfolio.PLedger ledger = 13; + */ + public name.abuchen.portfolio.model.proto.v1.PLedgerOrBuilder getLedgerOrBuilder() { + if (ledgerBuilder_ != null) { + return ledgerBuilder_.getMessageOrBuilder(); + } else { + return ledger_ == null ? + name.abuchen.portfolio.model.proto.v1.PLedger.getDefaultInstance() : ledger_; + } + } + /** + * .name.abuchen.portfolio.PLedger ledger = 13; + */ + private com.google.protobuf.SingleFieldBuilderV3< + name.abuchen.portfolio.model.proto.v1.PLedger, name.abuchen.portfolio.model.proto.v1.PLedger.Builder, name.abuchen.portfolio.model.proto.v1.PLedgerOrBuilder> + getLedgerFieldBuilder() { + if (ledgerBuilder_ == null) { + ledgerBuilder_ = new com.google.protobuf.SingleFieldBuilderV3< + name.abuchen.portfolio.model.proto.v1.PLedger, name.abuchen.portfolio.model.proto.v1.PLedger.Builder, name.abuchen.portfolio.model.proto.v1.PLedgerOrBuilder>( + getLedger(), + getParentForChildren(), + isClean()); + ledger_ = null; + } + return ledgerBuilder_; + } + private java.util.List extensions_ = java.util.Collections.emptyList(); private void ensureExtensionsIsMutable() { - if (!((bitField0_ & 0x00001000) != 0)) { + if (!((bitField0_ & 0x00002000) != 0)) { extensions_ = new java.util.ArrayList(extensions_); - bitField0_ |= 0x00001000; + bitField0_ |= 0x00002000; } } @@ -4101,7 +4282,7 @@ public Builder addAllExtensions( public Builder clearExtensions() { if (extensionsBuilder_ == null) { extensions_ = java.util.Collections.emptyList(); - bitField0_ = (bitField0_ & ~0x00001000); + bitField0_ = (bitField0_ & ~0x00002000); onChanged(); } else { extensionsBuilder_.clear(); @@ -4206,7 +4387,7 @@ public com.google.protobuf.Any.Builder addExtensionsBuilder( extensionsBuilder_ = new com.google.protobuf.RepeatedFieldBuilderV3< com.google.protobuf.Any, com.google.protobuf.Any.Builder, com.google.protobuf.AnyOrBuilder>( extensions_, - ((bitField0_ & 0x00001000) != 0), + ((bitField0_ & 0x00002000) != 0), getParentForChildren(), isClean()); extensions_ = null; diff --git a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PClientOrBuilder.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PClientOrBuilder.java index ffc5438800..97a6e4a3cb 100644 --- a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PClientOrBuilder.java +++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PClientOrBuilder.java @@ -266,6 +266,21 @@ java.lang.String getPropertiesOrThrow( com.google.protobuf.ByteString getBaseCurrencyBytes(); + /** + * .name.abuchen.portfolio.PLedger ledger = 13; + * @return Whether the ledger field is set. + */ + boolean hasLedger(); + /** + * .name.abuchen.portfolio.PLedger ledger = 13; + * @return The ledger. + */ + name.abuchen.portfolio.model.proto.v1.PLedger getLedger(); + /** + * .name.abuchen.portfolio.PLedger ledger = 13; + */ + name.abuchen.portfolio.model.proto.v1.PLedgerOrBuilder getLedgerOrBuilder(); + /** *
    * Extension data using Any type for maximum flexibility
diff --git a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PInvestmentPlan.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PInvestmentPlan.java
index 9a878abe7e..e378c0f308 100644
--- a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PInvestmentPlan.java
+++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PInvestmentPlan.java
@@ -25,6 +25,7 @@ private PInvestmentPlan() {
     transactions_ =
         com.google.protobuf.LazyStringArrayList.emptyList();
     type_ = 0;
+    ledgerExecutionRefs_ = java.util.Collections.emptyList();
   }
 
   @java.lang.Override
@@ -579,6 +580,47 @@ public long getTaxes() {
     return result == null ? name.abuchen.portfolio.model.proto.v1.PInvestmentPlan.Type.UNRECOGNIZED : result;
   }
 
+  public static final int LEDGEREXECUTIONREFS_FIELD_NUMBER = 15;
+  @SuppressWarnings("serial")
+  private java.util.List ledgerExecutionRefs_;
+  /**
+   * repeated .name.abuchen.portfolio.PInvestmentPlanLedgerExecutionRef ledgerExecutionRefs = 15;
+   */
+  @java.lang.Override
+  public java.util.List getLedgerExecutionRefsList() {
+    return ledgerExecutionRefs_;
+  }
+  /**
+   * repeated .name.abuchen.portfolio.PInvestmentPlanLedgerExecutionRef ledgerExecutionRefs = 15;
+   */
+  @java.lang.Override
+  public java.util.List 
+      getLedgerExecutionRefsOrBuilderList() {
+    return ledgerExecutionRefs_;
+  }
+  /**
+   * repeated .name.abuchen.portfolio.PInvestmentPlanLedgerExecutionRef ledgerExecutionRefs = 15;
+   */
+  @java.lang.Override
+  public int getLedgerExecutionRefsCount() {
+    return ledgerExecutionRefs_.size();
+  }
+  /**
+   * repeated .name.abuchen.portfolio.PInvestmentPlanLedgerExecutionRef ledgerExecutionRefs = 15;
+   */
+  @java.lang.Override
+  public name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef getLedgerExecutionRefs(int index) {
+    return ledgerExecutionRefs_.get(index);
+  }
+  /**
+   * repeated .name.abuchen.portfolio.PInvestmentPlanLedgerExecutionRef ledgerExecutionRefs = 15;
+   */
+  @java.lang.Override
+  public name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRefOrBuilder getLedgerExecutionRefsOrBuilder(
+      int index) {
+    return ledgerExecutionRefs_.get(index);
+  }
+
   private byte memoizedIsInitialized = -1;
   @java.lang.Override
   public final boolean isInitialized() {
@@ -635,6 +677,9 @@ public void writeTo(com.google.protobuf.CodedOutputStream output)
     if (type_ != name.abuchen.portfolio.model.proto.v1.PInvestmentPlan.Type.PURCHASE_OR_DELIVERY.getNumber()) {
       output.writeEnum(14, type_);
     }
+    for (int i = 0; i < ledgerExecutionRefs_.size(); i++) {
+      output.writeMessage(15, ledgerExecutionRefs_.get(i));
+    }
     getUnknownFields().writeTo(output);
   }
 
@@ -699,6 +744,10 @@ public int getSerializedSize() {
       size += com.google.protobuf.CodedOutputStream
         .computeEnumSize(14, type_);
     }
+    for (int i = 0; i < ledgerExecutionRefs_.size(); i++) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeMessageSize(15, ledgerExecutionRefs_.get(i));
+    }
     size += getUnknownFields().getSerializedSize();
     memoizedSize = size;
     return size;
@@ -753,6 +802,8 @@ public boolean equals(final java.lang.Object obj) {
     if (getTaxes()
         != other.getTaxes()) return false;
     if (type_ != other.type_) return false;
+    if (!getLedgerExecutionRefsList()
+        .equals(other.getLedgerExecutionRefsList())) return false;
     if (!getUnknownFields().equals(other.getUnknownFields())) return false;
     return true;
   }
@@ -809,6 +860,10 @@ public int hashCode() {
         getTaxes());
     hash = (37 * hash) + TYPE_FIELD_NUMBER;
     hash = (53 * hash) + type_;
+    if (getLedgerExecutionRefsCount() > 0) {
+      hash = (37 * hash) + LEDGEREXECUTIONREFS_FIELD_NUMBER;
+      hash = (53 * hash) + getLedgerExecutionRefsList().hashCode();
+    }
     hash = (29 * hash) + getUnknownFields().hashCode();
     memoizedHashCode = hash;
     return hash;
@@ -959,6 +1014,13 @@ public Builder clear() {
           com.google.protobuf.LazyStringArrayList.emptyList();
       taxes_ = 0L;
       type_ = 0;
+      if (ledgerExecutionRefsBuilder_ == null) {
+        ledgerExecutionRefs_ = java.util.Collections.emptyList();
+      } else {
+        ledgerExecutionRefs_ = null;
+        ledgerExecutionRefsBuilder_.clear();
+      }
+      bitField0_ = (bitField0_ & ~0x00004000);
       return this;
     }
 
@@ -1001,6 +1063,15 @@ private void buildPartialRepeatedFields(name.abuchen.portfolio.model.proto.v1.PI
       } else {
         result.attributes_ = attributesBuilder_.build();
       }
+      if (ledgerExecutionRefsBuilder_ == null) {
+        if (((bitField0_ & 0x00004000) != 0)) {
+          ledgerExecutionRefs_ = java.util.Collections.unmodifiableList(ledgerExecutionRefs_);
+          bitField0_ = (bitField0_ & ~0x00004000);
+        }
+        result.ledgerExecutionRefs_ = ledgerExecutionRefs_;
+      } else {
+        result.ledgerExecutionRefs_ = ledgerExecutionRefsBuilder_.build();
+      }
     }
 
     private void buildPartial0(name.abuchen.portfolio.model.proto.v1.PInvestmentPlan result) {
@@ -1147,6 +1218,32 @@ public Builder mergeFrom(name.abuchen.portfolio.model.proto.v1.PInvestmentPlan o
       if (other.type_ != 0) {
         setTypeValue(other.getTypeValue());
       }
+      if (ledgerExecutionRefsBuilder_ == null) {
+        if (!other.ledgerExecutionRefs_.isEmpty()) {
+          if (ledgerExecutionRefs_.isEmpty()) {
+            ledgerExecutionRefs_ = other.ledgerExecutionRefs_;
+            bitField0_ = (bitField0_ & ~0x00004000);
+          } else {
+            ensureLedgerExecutionRefsIsMutable();
+            ledgerExecutionRefs_.addAll(other.ledgerExecutionRefs_);
+          }
+          onChanged();
+        }
+      } else {
+        if (!other.ledgerExecutionRefs_.isEmpty()) {
+          if (ledgerExecutionRefsBuilder_.isEmpty()) {
+            ledgerExecutionRefsBuilder_.dispose();
+            ledgerExecutionRefsBuilder_ = null;
+            ledgerExecutionRefs_ = other.ledgerExecutionRefs_;
+            bitField0_ = (bitField0_ & ~0x00004000);
+            ledgerExecutionRefsBuilder_ = 
+              com.google.protobuf.GeneratedMessageV3.alwaysUseFieldBuilders ?
+                 getLedgerExecutionRefsFieldBuilder() : null;
+          } else {
+            ledgerExecutionRefsBuilder_.addAllMessages(other.ledgerExecutionRefs_);
+          }
+        }
+      }
       this.mergeUnknownFields(other.getUnknownFields());
       onChanged();
       return this;
@@ -1252,6 +1349,19 @@ public Builder mergeFrom(
               bitField0_ |= 0x00002000;
               break;
             } // case 112
+            case 122: {
+              name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef m =
+                  input.readMessage(
+                      name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef.parser(),
+                      extensionRegistry);
+              if (ledgerExecutionRefsBuilder_ == null) {
+                ensureLedgerExecutionRefsIsMutable();
+                ledgerExecutionRefs_.add(m);
+              } else {
+                ledgerExecutionRefsBuilder_.addMessage(m);
+              }
+              break;
+            } // case 122
             default: {
               if (!super.parseUnknownField(input, extensionRegistry, tag)) {
                 done = true; // was an endgroup tag
@@ -2288,6 +2398,246 @@ public Builder clearType() {
       onChanged();
       return this;
     }
+
+    private java.util.List ledgerExecutionRefs_ =
+      java.util.Collections.emptyList();
+    private void ensureLedgerExecutionRefsIsMutable() {
+      if (!((bitField0_ & 0x00004000) != 0)) {
+        ledgerExecutionRefs_ = new java.util.ArrayList(ledgerExecutionRefs_);
+        bitField0_ |= 0x00004000;
+       }
+    }
+
+    private com.google.protobuf.RepeatedFieldBuilderV3<
+        name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef, name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef.Builder, name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRefOrBuilder> ledgerExecutionRefsBuilder_;
+
+    /**
+     * repeated .name.abuchen.portfolio.PInvestmentPlanLedgerExecutionRef ledgerExecutionRefs = 15;
+     */
+    public java.util.List getLedgerExecutionRefsList() {
+      if (ledgerExecutionRefsBuilder_ == null) {
+        return java.util.Collections.unmodifiableList(ledgerExecutionRefs_);
+      } else {
+        return ledgerExecutionRefsBuilder_.getMessageList();
+      }
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PInvestmentPlanLedgerExecutionRef ledgerExecutionRefs = 15;
+     */
+    public int getLedgerExecutionRefsCount() {
+      if (ledgerExecutionRefsBuilder_ == null) {
+        return ledgerExecutionRefs_.size();
+      } else {
+        return ledgerExecutionRefsBuilder_.getCount();
+      }
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PInvestmentPlanLedgerExecutionRef ledgerExecutionRefs = 15;
+     */
+    public name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef getLedgerExecutionRefs(int index) {
+      if (ledgerExecutionRefsBuilder_ == null) {
+        return ledgerExecutionRefs_.get(index);
+      } else {
+        return ledgerExecutionRefsBuilder_.getMessage(index);
+      }
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PInvestmentPlanLedgerExecutionRef ledgerExecutionRefs = 15;
+     */
+    public Builder setLedgerExecutionRefs(
+        int index, name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef value) {
+      if (ledgerExecutionRefsBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensureLedgerExecutionRefsIsMutable();
+        ledgerExecutionRefs_.set(index, value);
+        onChanged();
+      } else {
+        ledgerExecutionRefsBuilder_.setMessage(index, value);
+      }
+      return this;
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PInvestmentPlanLedgerExecutionRef ledgerExecutionRefs = 15;
+     */
+    public Builder setLedgerExecutionRefs(
+        int index, name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef.Builder builderForValue) {
+      if (ledgerExecutionRefsBuilder_ == null) {
+        ensureLedgerExecutionRefsIsMutable();
+        ledgerExecutionRefs_.set(index, builderForValue.build());
+        onChanged();
+      } else {
+        ledgerExecutionRefsBuilder_.setMessage(index, builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PInvestmentPlanLedgerExecutionRef ledgerExecutionRefs = 15;
+     */
+    public Builder addLedgerExecutionRefs(name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef value) {
+      if (ledgerExecutionRefsBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensureLedgerExecutionRefsIsMutable();
+        ledgerExecutionRefs_.add(value);
+        onChanged();
+      } else {
+        ledgerExecutionRefsBuilder_.addMessage(value);
+      }
+      return this;
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PInvestmentPlanLedgerExecutionRef ledgerExecutionRefs = 15;
+     */
+    public Builder addLedgerExecutionRefs(
+        int index, name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef value) {
+      if (ledgerExecutionRefsBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensureLedgerExecutionRefsIsMutable();
+        ledgerExecutionRefs_.add(index, value);
+        onChanged();
+      } else {
+        ledgerExecutionRefsBuilder_.addMessage(index, value);
+      }
+      return this;
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PInvestmentPlanLedgerExecutionRef ledgerExecutionRefs = 15;
+     */
+    public Builder addLedgerExecutionRefs(
+        name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef.Builder builderForValue) {
+      if (ledgerExecutionRefsBuilder_ == null) {
+        ensureLedgerExecutionRefsIsMutable();
+        ledgerExecutionRefs_.add(builderForValue.build());
+        onChanged();
+      } else {
+        ledgerExecutionRefsBuilder_.addMessage(builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PInvestmentPlanLedgerExecutionRef ledgerExecutionRefs = 15;
+     */
+    public Builder addLedgerExecutionRefs(
+        int index, name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef.Builder builderForValue) {
+      if (ledgerExecutionRefsBuilder_ == null) {
+        ensureLedgerExecutionRefsIsMutable();
+        ledgerExecutionRefs_.add(index, builderForValue.build());
+        onChanged();
+      } else {
+        ledgerExecutionRefsBuilder_.addMessage(index, builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PInvestmentPlanLedgerExecutionRef ledgerExecutionRefs = 15;
+     */
+    public Builder addAllLedgerExecutionRefs(
+        java.lang.Iterable values) {
+      if (ledgerExecutionRefsBuilder_ == null) {
+        ensureLedgerExecutionRefsIsMutable();
+        com.google.protobuf.AbstractMessageLite.Builder.addAll(
+            values, ledgerExecutionRefs_);
+        onChanged();
+      } else {
+        ledgerExecutionRefsBuilder_.addAllMessages(values);
+      }
+      return this;
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PInvestmentPlanLedgerExecutionRef ledgerExecutionRefs = 15;
+     */
+    public Builder clearLedgerExecutionRefs() {
+      if (ledgerExecutionRefsBuilder_ == null) {
+        ledgerExecutionRefs_ = java.util.Collections.emptyList();
+        bitField0_ = (bitField0_ & ~0x00004000);
+        onChanged();
+      } else {
+        ledgerExecutionRefsBuilder_.clear();
+      }
+      return this;
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PInvestmentPlanLedgerExecutionRef ledgerExecutionRefs = 15;
+     */
+    public Builder removeLedgerExecutionRefs(int index) {
+      if (ledgerExecutionRefsBuilder_ == null) {
+        ensureLedgerExecutionRefsIsMutable();
+        ledgerExecutionRefs_.remove(index);
+        onChanged();
+      } else {
+        ledgerExecutionRefsBuilder_.remove(index);
+      }
+      return this;
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PInvestmentPlanLedgerExecutionRef ledgerExecutionRefs = 15;
+     */
+    public name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef.Builder getLedgerExecutionRefsBuilder(
+        int index) {
+      return getLedgerExecutionRefsFieldBuilder().getBuilder(index);
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PInvestmentPlanLedgerExecutionRef ledgerExecutionRefs = 15;
+     */
+    public name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRefOrBuilder getLedgerExecutionRefsOrBuilder(
+        int index) {
+      if (ledgerExecutionRefsBuilder_ == null) {
+        return ledgerExecutionRefs_.get(index);  } else {
+        return ledgerExecutionRefsBuilder_.getMessageOrBuilder(index);
+      }
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PInvestmentPlanLedgerExecutionRef ledgerExecutionRefs = 15;
+     */
+    public java.util.List 
+         getLedgerExecutionRefsOrBuilderList() {
+      if (ledgerExecutionRefsBuilder_ != null) {
+        return ledgerExecutionRefsBuilder_.getMessageOrBuilderList();
+      } else {
+        return java.util.Collections.unmodifiableList(ledgerExecutionRefs_);
+      }
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PInvestmentPlanLedgerExecutionRef ledgerExecutionRefs = 15;
+     */
+    public name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef.Builder addLedgerExecutionRefsBuilder() {
+      return getLedgerExecutionRefsFieldBuilder().addBuilder(
+          name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef.getDefaultInstance());
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PInvestmentPlanLedgerExecutionRef ledgerExecutionRefs = 15;
+     */
+    public name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef.Builder addLedgerExecutionRefsBuilder(
+        int index) {
+      return getLedgerExecutionRefsFieldBuilder().addBuilder(
+          index, name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef.getDefaultInstance());
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PInvestmentPlanLedgerExecutionRef ledgerExecutionRefs = 15;
+     */
+    public java.util.List 
+         getLedgerExecutionRefsBuilderList() {
+      return getLedgerExecutionRefsFieldBuilder().getBuilderList();
+    }
+    private com.google.protobuf.RepeatedFieldBuilderV3<
+        name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef, name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef.Builder, name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRefOrBuilder> 
+        getLedgerExecutionRefsFieldBuilder() {
+      if (ledgerExecutionRefsBuilder_ == null) {
+        ledgerExecutionRefsBuilder_ = new com.google.protobuf.RepeatedFieldBuilderV3<
+            name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef, name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef.Builder, name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRefOrBuilder>(
+                ledgerExecutionRefs_,
+                ((bitField0_ & 0x00004000) != 0),
+                getParentForChildren(),
+                isClean());
+        ledgerExecutionRefs_ = null;
+      }
+      return ledgerExecutionRefsBuilder_;
+    }
     @java.lang.Override
     public final Builder setUnknownFields(
         final com.google.protobuf.UnknownFieldSet unknownFields) {
diff --git a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PInvestmentPlanLedgerExecutionRef.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PInvestmentPlanLedgerExecutionRef.java
new file mode 100644
index 0000000000..89ac440ae8
--- /dev/null
+++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PInvestmentPlanLedgerExecutionRef.java
@@ -0,0 +1,781 @@
+// Generated by the protocol buffer compiler.  DO NOT EDIT!
+// source: client.proto
+
+package name.abuchen.portfolio.model.proto.v1;
+
+/**
+ * Protobuf type {@code name.abuchen.portfolio.PInvestmentPlanLedgerExecutionRef}
+ */
+public final class PInvestmentPlanLedgerExecutionRef extends
+    com.google.protobuf.GeneratedMessageV3 implements
+    // @@protoc_insertion_point(message_implements:name.abuchen.portfolio.PInvestmentPlanLedgerExecutionRef)
+    PInvestmentPlanLedgerExecutionRefOrBuilder {
+private static final long serialVersionUID = 0L;
+  // Use PInvestmentPlanLedgerExecutionRef.newBuilder() to construct.
+  private PInvestmentPlanLedgerExecutionRef(com.google.protobuf.GeneratedMessageV3.Builder builder) {
+    super(builder);
+  }
+  private PInvestmentPlanLedgerExecutionRef() {
+    ledgerEntryUUID_ = "";
+    projectionUUID_ = "";
+    projectionRole_ = 0;
+  }
+
+  @java.lang.Override
+  @SuppressWarnings({"unused"})
+  protected java.lang.Object newInstance(
+      UnusedPrivateParameter unused) {
+    return new PInvestmentPlanLedgerExecutionRef();
+  }
+
+  public static final com.google.protobuf.Descriptors.Descriptor
+      getDescriptor() {
+    return name.abuchen.portfolio.model.proto.v1.ClientProtos.internal_static_name_abuchen_portfolio_PInvestmentPlanLedgerExecutionRef_descriptor;
+  }
+
+  @java.lang.Override
+  protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable
+      internalGetFieldAccessorTable() {
+    return name.abuchen.portfolio.model.proto.v1.ClientProtos.internal_static_name_abuchen_portfolio_PInvestmentPlanLedgerExecutionRef_fieldAccessorTable
+        .ensureFieldAccessorsInitialized(
+            name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef.class, name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef.Builder.class);
+  }
+
+  private int bitField0_;
+  public static final int LEDGERENTRYUUID_FIELD_NUMBER = 1;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object ledgerEntryUUID_ = "";
+  /**
+   * string ledgerEntryUUID = 1;
+   * @return The ledgerEntryUUID.
+   */
+  @java.lang.Override
+  public java.lang.String getLedgerEntryUUID() {
+    java.lang.Object ref = ledgerEntryUUID_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      ledgerEntryUUID_ = s;
+      return s;
+    }
+  }
+  /**
+   * string ledgerEntryUUID = 1;
+   * @return The bytes for ledgerEntryUUID.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getLedgerEntryUUIDBytes() {
+    java.lang.Object ref = ledgerEntryUUID_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      ledgerEntryUUID_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int PROJECTIONUUID_FIELD_NUMBER = 2;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object projectionUUID_ = "";
+  /**
+   * optional string projectionUUID = 2;
+   * @return Whether the projectionUUID field is set.
+   */
+  @java.lang.Override
+  public boolean hasProjectionUUID() {
+    return ((bitField0_ & 0x00000001) != 0);
+  }
+  /**
+   * optional string projectionUUID = 2;
+   * @return The projectionUUID.
+   */
+  @java.lang.Override
+  public java.lang.String getProjectionUUID() {
+    java.lang.Object ref = projectionUUID_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      projectionUUID_ = s;
+      return s;
+    }
+  }
+  /**
+   * optional string projectionUUID = 2;
+   * @return The bytes for projectionUUID.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getProjectionUUIDBytes() {
+    java.lang.Object ref = projectionUUID_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      projectionUUID_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int PROJECTIONROLE_FIELD_NUMBER = 3;
+  private int projectionRole_ = 0;
+  /**
+   * optional .name.abuchen.portfolio.PLedgerProjectionRole projectionRole = 3;
+   * @return Whether the projectionRole field is set.
+   */
+  @java.lang.Override public boolean hasProjectionRole() {
+    return ((bitField0_ & 0x00000002) != 0);
+  }
+  /**
+   * optional .name.abuchen.portfolio.PLedgerProjectionRole projectionRole = 3;
+   * @return The enum numeric value on the wire for projectionRole.
+   */
+  @java.lang.Override public int getProjectionRoleValue() {
+    return projectionRole_;
+  }
+  /**
+   * optional .name.abuchen.portfolio.PLedgerProjectionRole projectionRole = 3;
+   * @return The projectionRole.
+   */
+  @java.lang.Override public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRole getProjectionRole() {
+    name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRole result = name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRole.forNumber(projectionRole_);
+    return result == null ? name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRole.UNRECOGNIZED : result;
+  }
+
+  private byte memoizedIsInitialized = -1;
+  @java.lang.Override
+  public final boolean isInitialized() {
+    byte isInitialized = memoizedIsInitialized;
+    if (isInitialized == 1) return true;
+    if (isInitialized == 0) return false;
+
+    memoizedIsInitialized = 1;
+    return true;
+  }
+
+  @java.lang.Override
+  public void writeTo(com.google.protobuf.CodedOutputStream output)
+                      throws java.io.IOException {
+    if (!com.google.protobuf.GeneratedMessageV3.isStringEmpty(ledgerEntryUUID_)) {
+      com.google.protobuf.GeneratedMessageV3.writeString(output, 1, ledgerEntryUUID_);
+    }
+    if (((bitField0_ & 0x00000001) != 0)) {
+      com.google.protobuf.GeneratedMessageV3.writeString(output, 2, projectionUUID_);
+    }
+    if (((bitField0_ & 0x00000002) != 0)) {
+      output.writeEnum(3, projectionRole_);
+    }
+    getUnknownFields().writeTo(output);
+  }
+
+  @java.lang.Override
+  public int getSerializedSize() {
+    int size = memoizedSize;
+    if (size != -1) return size;
+
+    size = 0;
+    if (!com.google.protobuf.GeneratedMessageV3.isStringEmpty(ledgerEntryUUID_)) {
+      size += com.google.protobuf.GeneratedMessageV3.computeStringSize(1, ledgerEntryUUID_);
+    }
+    if (((bitField0_ & 0x00000001) != 0)) {
+      size += com.google.protobuf.GeneratedMessageV3.computeStringSize(2, projectionUUID_);
+    }
+    if (((bitField0_ & 0x00000002) != 0)) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeEnumSize(3, projectionRole_);
+    }
+    size += getUnknownFields().getSerializedSize();
+    memoizedSize = size;
+    return size;
+  }
+
+  @java.lang.Override
+  public boolean equals(final java.lang.Object obj) {
+    if (obj == this) {
+     return true;
+    }
+    if (!(obj instanceof name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef)) {
+      return super.equals(obj);
+    }
+    name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef other = (name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef) obj;
+
+    if (!getLedgerEntryUUID()
+        .equals(other.getLedgerEntryUUID())) return false;
+    if (hasProjectionUUID() != other.hasProjectionUUID()) return false;
+    if (hasProjectionUUID()) {
+      if (!getProjectionUUID()
+          .equals(other.getProjectionUUID())) return false;
+    }
+    if (hasProjectionRole() != other.hasProjectionRole()) return false;
+    if (hasProjectionRole()) {
+      if (projectionRole_ != other.projectionRole_) return false;
+    }
+    if (!getUnknownFields().equals(other.getUnknownFields())) return false;
+    return true;
+  }
+
+  @java.lang.Override
+  public int hashCode() {
+    if (memoizedHashCode != 0) {
+      return memoizedHashCode;
+    }
+    int hash = 41;
+    hash = (19 * hash) + getDescriptor().hashCode();
+    hash = (37 * hash) + LEDGERENTRYUUID_FIELD_NUMBER;
+    hash = (53 * hash) + getLedgerEntryUUID().hashCode();
+    if (hasProjectionUUID()) {
+      hash = (37 * hash) + PROJECTIONUUID_FIELD_NUMBER;
+      hash = (53 * hash) + getProjectionUUID().hashCode();
+    }
+    if (hasProjectionRole()) {
+      hash = (37 * hash) + PROJECTIONROLE_FIELD_NUMBER;
+      hash = (53 * hash) + projectionRole_;
+    }
+    hash = (29 * hash) + getUnknownFields().hashCode();
+    memoizedHashCode = hash;
+    return hash;
+  }
+
+  public static name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef parseFrom(
+      java.nio.ByteBuffer data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef parseFrom(
+      java.nio.ByteBuffer data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef parseFrom(
+      com.google.protobuf.ByteString data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef parseFrom(
+      com.google.protobuf.ByteString data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef parseFrom(byte[] data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef parseFrom(
+      byte[] data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef parseFrom(java.io.InputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessageV3
+        .parseWithIOException(PARSER, input);
+  }
+  public static name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef parseFrom(
+      java.io.InputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessageV3
+        .parseWithIOException(PARSER, input, extensionRegistry);
+  }
+  public static name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef parseDelimitedFrom(java.io.InputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessageV3
+        .parseDelimitedWithIOException(PARSER, input);
+  }
+  public static name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef parseDelimitedFrom(
+      java.io.InputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessageV3
+        .parseDelimitedWithIOException(PARSER, input, extensionRegistry);
+  }
+  public static name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef parseFrom(
+      com.google.protobuf.CodedInputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessageV3
+        .parseWithIOException(PARSER, input);
+  }
+  public static name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef parseFrom(
+      com.google.protobuf.CodedInputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessageV3
+        .parseWithIOException(PARSER, input, extensionRegistry);
+  }
+
+  @java.lang.Override
+  public Builder newBuilderForType() { return newBuilder(); }
+  public static Builder newBuilder() {
+    return DEFAULT_INSTANCE.toBuilder();
+  }
+  public static Builder newBuilder(name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef prototype) {
+    return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype);
+  }
+  @java.lang.Override
+  public Builder toBuilder() {
+    return this == DEFAULT_INSTANCE
+        ? new Builder() : new Builder().mergeFrom(this);
+  }
+
+  @java.lang.Override
+  protected Builder newBuilderForType(
+      com.google.protobuf.GeneratedMessageV3.BuilderParent parent) {
+    Builder builder = new Builder(parent);
+    return builder;
+  }
+  /**
+   * Protobuf type {@code name.abuchen.portfolio.PInvestmentPlanLedgerExecutionRef}
+   */
+  public static final class Builder extends
+      com.google.protobuf.GeneratedMessageV3.Builder implements
+      // @@protoc_insertion_point(builder_implements:name.abuchen.portfolio.PInvestmentPlanLedgerExecutionRef)
+      name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRefOrBuilder {
+    public static final com.google.protobuf.Descriptors.Descriptor
+        getDescriptor() {
+      return name.abuchen.portfolio.model.proto.v1.ClientProtos.internal_static_name_abuchen_portfolio_PInvestmentPlanLedgerExecutionRef_descriptor;
+    }
+
+    @java.lang.Override
+    protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable
+        internalGetFieldAccessorTable() {
+      return name.abuchen.portfolio.model.proto.v1.ClientProtos.internal_static_name_abuchen_portfolio_PInvestmentPlanLedgerExecutionRef_fieldAccessorTable
+          .ensureFieldAccessorsInitialized(
+              name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef.class, name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef.Builder.class);
+    }
+
+    // Construct using name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef.newBuilder()
+    private Builder() {
+
+    }
+
+    private Builder(
+        com.google.protobuf.GeneratedMessageV3.BuilderParent parent) {
+      super(parent);
+
+    }
+    @java.lang.Override
+    public Builder clear() {
+      super.clear();
+      bitField0_ = 0;
+      ledgerEntryUUID_ = "";
+      projectionUUID_ = "";
+      projectionRole_ = 0;
+      return this;
+    }
+
+    @java.lang.Override
+    public com.google.protobuf.Descriptors.Descriptor
+        getDescriptorForType() {
+      return name.abuchen.portfolio.model.proto.v1.ClientProtos.internal_static_name_abuchen_portfolio_PInvestmentPlanLedgerExecutionRef_descriptor;
+    }
+
+    @java.lang.Override
+    public name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef getDefaultInstanceForType() {
+      return name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef.getDefaultInstance();
+    }
+
+    @java.lang.Override
+    public name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef build() {
+      name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef result = buildPartial();
+      if (!result.isInitialized()) {
+        throw newUninitializedMessageException(result);
+      }
+      return result;
+    }
+
+    @java.lang.Override
+    public name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef buildPartial() {
+      name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef result = new name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef(this);
+      if (bitField0_ != 0) { buildPartial0(result); }
+      onBuilt();
+      return result;
+    }
+
+    private void buildPartial0(name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef result) {
+      int from_bitField0_ = bitField0_;
+      if (((from_bitField0_ & 0x00000001) != 0)) {
+        result.ledgerEntryUUID_ = ledgerEntryUUID_;
+      }
+      int to_bitField0_ = 0;
+      if (((from_bitField0_ & 0x00000002) != 0)) {
+        result.projectionUUID_ = projectionUUID_;
+        to_bitField0_ |= 0x00000001;
+      }
+      if (((from_bitField0_ & 0x00000004) != 0)) {
+        result.projectionRole_ = projectionRole_;
+        to_bitField0_ |= 0x00000002;
+      }
+      result.bitField0_ |= to_bitField0_;
+    }
+
+    @java.lang.Override
+    public Builder mergeFrom(com.google.protobuf.Message other) {
+      if (other instanceof name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef) {
+        return mergeFrom((name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef)other);
+      } else {
+        super.mergeFrom(other);
+        return this;
+      }
+    }
+
+    public Builder mergeFrom(name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef other) {
+      if (other == name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef.getDefaultInstance()) return this;
+      if (!other.getLedgerEntryUUID().isEmpty()) {
+        ledgerEntryUUID_ = other.ledgerEntryUUID_;
+        bitField0_ |= 0x00000001;
+        onChanged();
+      }
+      if (other.hasProjectionUUID()) {
+        projectionUUID_ = other.projectionUUID_;
+        bitField0_ |= 0x00000002;
+        onChanged();
+      }
+      if (other.hasProjectionRole()) {
+        setProjectionRole(other.getProjectionRole());
+      }
+      this.mergeUnknownFields(other.getUnknownFields());
+      onChanged();
+      return this;
+    }
+
+    @java.lang.Override
+    public final boolean isInitialized() {
+      return true;
+    }
+
+    @java.lang.Override
+    public Builder mergeFrom(
+        com.google.protobuf.CodedInputStream input,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws java.io.IOException {
+      if (extensionRegistry == null) {
+        throw new java.lang.NullPointerException();
+      }
+      try {
+        boolean done = false;
+        while (!done) {
+          int tag = input.readTag();
+          switch (tag) {
+            case 0:
+              done = true;
+              break;
+            case 10: {
+              ledgerEntryUUID_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000001;
+              break;
+            } // case 10
+            case 18: {
+              projectionUUID_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000002;
+              break;
+            } // case 18
+            case 24: {
+              projectionRole_ = input.readEnum();
+              bitField0_ |= 0x00000004;
+              break;
+            } // case 24
+            default: {
+              if (!super.parseUnknownField(input, extensionRegistry, tag)) {
+                done = true; // was an endgroup tag
+              }
+              break;
+            } // default:
+          } // switch (tag)
+        } // while (!done)
+      } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+        throw e.unwrapIOException();
+      } finally {
+        onChanged();
+      } // finally
+      return this;
+    }
+    private int bitField0_;
+
+    private java.lang.Object ledgerEntryUUID_ = "";
+    /**
+     * string ledgerEntryUUID = 1;
+     * @return The ledgerEntryUUID.
+     */
+    public java.lang.String getLedgerEntryUUID() {
+      java.lang.Object ref = ledgerEntryUUID_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        ledgerEntryUUID_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * string ledgerEntryUUID = 1;
+     * @return The bytes for ledgerEntryUUID.
+     */
+    public com.google.protobuf.ByteString
+        getLedgerEntryUUIDBytes() {
+      java.lang.Object ref = ledgerEntryUUID_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        ledgerEntryUUID_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * string ledgerEntryUUID = 1;
+     * @param value The ledgerEntryUUID to set.
+     * @return This builder for chaining.
+     */
+    public Builder setLedgerEntryUUID(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      ledgerEntryUUID_ = value;
+      bitField0_ |= 0x00000001;
+      onChanged();
+      return this;
+    }
+    /**
+     * string ledgerEntryUUID = 1;
+     * @return This builder for chaining.
+     */
+    public Builder clearLedgerEntryUUID() {
+      ledgerEntryUUID_ = getDefaultInstance().getLedgerEntryUUID();
+      bitField0_ = (bitField0_ & ~0x00000001);
+      onChanged();
+      return this;
+    }
+    /**
+     * string ledgerEntryUUID = 1;
+     * @param value The bytes for ledgerEntryUUID to set.
+     * @return This builder for chaining.
+     */
+    public Builder setLedgerEntryUUIDBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      ledgerEntryUUID_ = value;
+      bitField0_ |= 0x00000001;
+      onChanged();
+      return this;
+    }
+
+    private java.lang.Object projectionUUID_ = "";
+    /**
+     * optional string projectionUUID = 2;
+     * @return Whether the projectionUUID field is set.
+     */
+    public boolean hasProjectionUUID() {
+      return ((bitField0_ & 0x00000002) != 0);
+    }
+    /**
+     * optional string projectionUUID = 2;
+     * @return The projectionUUID.
+     */
+    public java.lang.String getProjectionUUID() {
+      java.lang.Object ref = projectionUUID_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        projectionUUID_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * optional string projectionUUID = 2;
+     * @return The bytes for projectionUUID.
+     */
+    public com.google.protobuf.ByteString
+        getProjectionUUIDBytes() {
+      java.lang.Object ref = projectionUUID_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        projectionUUID_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * optional string projectionUUID = 2;
+     * @param value The projectionUUID to set.
+     * @return This builder for chaining.
+     */
+    public Builder setProjectionUUID(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      projectionUUID_ = value;
+      bitField0_ |= 0x00000002;
+      onChanged();
+      return this;
+    }
+    /**
+     * optional string projectionUUID = 2;
+     * @return This builder for chaining.
+     */
+    public Builder clearProjectionUUID() {
+      projectionUUID_ = getDefaultInstance().getProjectionUUID();
+      bitField0_ = (bitField0_ & ~0x00000002);
+      onChanged();
+      return this;
+    }
+    /**
+     * optional string projectionUUID = 2;
+     * @param value The bytes for projectionUUID to set.
+     * @return This builder for chaining.
+     */
+    public Builder setProjectionUUIDBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      projectionUUID_ = value;
+      bitField0_ |= 0x00000002;
+      onChanged();
+      return this;
+    }
+
+    private int projectionRole_ = 0;
+    /**
+     * optional .name.abuchen.portfolio.PLedgerProjectionRole projectionRole = 3;
+     * @return Whether the projectionRole field is set.
+     */
+    @java.lang.Override public boolean hasProjectionRole() {
+      return ((bitField0_ & 0x00000004) != 0);
+    }
+    /**
+     * optional .name.abuchen.portfolio.PLedgerProjectionRole projectionRole = 3;
+     * @return The enum numeric value on the wire for projectionRole.
+     */
+    @java.lang.Override public int getProjectionRoleValue() {
+      return projectionRole_;
+    }
+    /**
+     * optional .name.abuchen.portfolio.PLedgerProjectionRole projectionRole = 3;
+     * @param value The enum numeric value on the wire for projectionRole to set.
+     * @return This builder for chaining.
+     */
+    public Builder setProjectionRoleValue(int value) {
+      projectionRole_ = value;
+      bitField0_ |= 0x00000004;
+      onChanged();
+      return this;
+    }
+    /**
+     * optional .name.abuchen.portfolio.PLedgerProjectionRole projectionRole = 3;
+     * @return The projectionRole.
+     */
+    @java.lang.Override
+    public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRole getProjectionRole() {
+      name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRole result = name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRole.forNumber(projectionRole_);
+      return result == null ? name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRole.UNRECOGNIZED : result;
+    }
+    /**
+     * optional .name.abuchen.portfolio.PLedgerProjectionRole projectionRole = 3;
+     * @param value The projectionRole to set.
+     * @return This builder for chaining.
+     */
+    public Builder setProjectionRole(name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRole value) {
+      if (value == null) {
+        throw new NullPointerException();
+      }
+      bitField0_ |= 0x00000004;
+      projectionRole_ = value.getNumber();
+      onChanged();
+      return this;
+    }
+    /**
+     * optional .name.abuchen.portfolio.PLedgerProjectionRole projectionRole = 3;
+     * @return This builder for chaining.
+     */
+    public Builder clearProjectionRole() {
+      bitField0_ = (bitField0_ & ~0x00000004);
+      projectionRole_ = 0;
+      onChanged();
+      return this;
+    }
+    @java.lang.Override
+    public final Builder setUnknownFields(
+        final com.google.protobuf.UnknownFieldSet unknownFields) {
+      return super.setUnknownFields(unknownFields);
+    }
+
+    @java.lang.Override
+    public final Builder mergeUnknownFields(
+        final com.google.protobuf.UnknownFieldSet unknownFields) {
+      return super.mergeUnknownFields(unknownFields);
+    }
+
+
+    // @@protoc_insertion_point(builder_scope:name.abuchen.portfolio.PInvestmentPlanLedgerExecutionRef)
+  }
+
+  // @@protoc_insertion_point(class_scope:name.abuchen.portfolio.PInvestmentPlanLedgerExecutionRef)
+  private static final name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef DEFAULT_INSTANCE;
+  static {
+    DEFAULT_INSTANCE = new name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef();
+  }
+
+  public static name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef getDefaultInstance() {
+    return DEFAULT_INSTANCE;
+  }
+
+  private static final com.google.protobuf.Parser
+      PARSER = new com.google.protobuf.AbstractParser() {
+    @java.lang.Override
+    public PInvestmentPlanLedgerExecutionRef parsePartialFrom(
+        com.google.protobuf.CodedInputStream input,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws com.google.protobuf.InvalidProtocolBufferException {
+      Builder builder = newBuilder();
+      try {
+        builder.mergeFrom(input, extensionRegistry);
+      } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+        throw e.setUnfinishedMessage(builder.buildPartial());
+      } catch (com.google.protobuf.UninitializedMessageException e) {
+        throw e.asInvalidProtocolBufferException().setUnfinishedMessage(builder.buildPartial());
+      } catch (java.io.IOException e) {
+        throw new com.google.protobuf.InvalidProtocolBufferException(e)
+            .setUnfinishedMessage(builder.buildPartial());
+      }
+      return builder.buildPartial();
+    }
+  };
+
+  public static com.google.protobuf.Parser parser() {
+    return PARSER;
+  }
+
+  @java.lang.Override
+  public com.google.protobuf.Parser getParserForType() {
+    return PARSER;
+  }
+
+  @java.lang.Override
+  public name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef getDefaultInstanceForType() {
+    return DEFAULT_INSTANCE;
+  }
+
+}
+
diff --git a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PInvestmentPlanLedgerExecutionRefOrBuilder.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PInvestmentPlanLedgerExecutionRefOrBuilder.java
new file mode 100644
index 0000000000..ddfbcd8ad1
--- /dev/null
+++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PInvestmentPlanLedgerExecutionRefOrBuilder.java
@@ -0,0 +1,54 @@
+// Generated by the protocol buffer compiler.  DO NOT EDIT!
+// source: client.proto
+
+package name.abuchen.portfolio.model.proto.v1;
+
+public interface PInvestmentPlanLedgerExecutionRefOrBuilder extends
+    // @@protoc_insertion_point(interface_extends:name.abuchen.portfolio.PInvestmentPlanLedgerExecutionRef)
+    com.google.protobuf.MessageOrBuilder {
+
+  /**
+   * string ledgerEntryUUID = 1;
+   * @return The ledgerEntryUUID.
+   */
+  java.lang.String getLedgerEntryUUID();
+  /**
+   * string ledgerEntryUUID = 1;
+   * @return The bytes for ledgerEntryUUID.
+   */
+  com.google.protobuf.ByteString
+      getLedgerEntryUUIDBytes();
+
+  /**
+   * optional string projectionUUID = 2;
+   * @return Whether the projectionUUID field is set.
+   */
+  boolean hasProjectionUUID();
+  /**
+   * optional string projectionUUID = 2;
+   * @return The projectionUUID.
+   */
+  java.lang.String getProjectionUUID();
+  /**
+   * optional string projectionUUID = 2;
+   * @return The bytes for projectionUUID.
+   */
+  com.google.protobuf.ByteString
+      getProjectionUUIDBytes();
+
+  /**
+   * optional .name.abuchen.portfolio.PLedgerProjectionRole projectionRole = 3;
+   * @return Whether the projectionRole field is set.
+   */
+  boolean hasProjectionRole();
+  /**
+   * optional .name.abuchen.portfolio.PLedgerProjectionRole projectionRole = 3;
+   * @return The enum numeric value on the wire for projectionRole.
+   */
+  int getProjectionRoleValue();
+  /**
+   * optional .name.abuchen.portfolio.PLedgerProjectionRole projectionRole = 3;
+   * @return The projectionRole.
+   */
+  name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRole getProjectionRole();
+}
diff --git a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PInvestmentPlanOrBuilder.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PInvestmentPlanOrBuilder.java
index d4c422d049..1faace4133 100644
--- a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PInvestmentPlanOrBuilder.java
+++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PInvestmentPlanOrBuilder.java
@@ -198,4 +198,28 @@ name.abuchen.portfolio.model.proto.v1.PKeyValueOrBuilder getAttributesOrBuilder(
    * @return The type.
    */
   name.abuchen.portfolio.model.proto.v1.PInvestmentPlan.Type getType();
+
+  /**
+   * repeated .name.abuchen.portfolio.PInvestmentPlanLedgerExecutionRef ledgerExecutionRefs = 15;
+   */
+  java.util.List 
+      getLedgerExecutionRefsList();
+  /**
+   * repeated .name.abuchen.portfolio.PInvestmentPlanLedgerExecutionRef ledgerExecutionRefs = 15;
+   */
+  name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef getLedgerExecutionRefs(int index);
+  /**
+   * repeated .name.abuchen.portfolio.PInvestmentPlanLedgerExecutionRef ledgerExecutionRefs = 15;
+   */
+  int getLedgerExecutionRefsCount();
+  /**
+   * repeated .name.abuchen.portfolio.PInvestmentPlanLedgerExecutionRef ledgerExecutionRefs = 15;
+   */
+  java.util.List 
+      getLedgerExecutionRefsOrBuilderList();
+  /**
+   * repeated .name.abuchen.portfolio.PInvestmentPlanLedgerExecutionRef ledgerExecutionRefs = 15;
+   */
+  name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRefOrBuilder getLedgerExecutionRefsOrBuilder(
+      int index);
 }
diff --git a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedger.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedger.java
new file mode 100644
index 0000000000..768a12ce02
--- /dev/null
+++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedger.java
@@ -0,0 +1,725 @@
+// Generated by the protocol buffer compiler.  DO NOT EDIT!
+// source: client.proto
+
+package name.abuchen.portfolio.model.proto.v1;
+
+/**
+ * Protobuf type {@code name.abuchen.portfolio.PLedger}
+ */
+public final class PLedger extends
+    com.google.protobuf.GeneratedMessageV3 implements
+    // @@protoc_insertion_point(message_implements:name.abuchen.portfolio.PLedger)
+    PLedgerOrBuilder {
+private static final long serialVersionUID = 0L;
+  // Use PLedger.newBuilder() to construct.
+  private PLedger(com.google.protobuf.GeneratedMessageV3.Builder builder) {
+    super(builder);
+  }
+  private PLedger() {
+    entries_ = java.util.Collections.emptyList();
+  }
+
+  @java.lang.Override
+  @SuppressWarnings({"unused"})
+  protected java.lang.Object newInstance(
+      UnusedPrivateParameter unused) {
+    return new PLedger();
+  }
+
+  public static final com.google.protobuf.Descriptors.Descriptor
+      getDescriptor() {
+    return name.abuchen.portfolio.model.proto.v1.ClientProtos.internal_static_name_abuchen_portfolio_PLedger_descriptor;
+  }
+
+  @java.lang.Override
+  protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable
+      internalGetFieldAccessorTable() {
+    return name.abuchen.portfolio.model.proto.v1.ClientProtos.internal_static_name_abuchen_portfolio_PLedger_fieldAccessorTable
+        .ensureFieldAccessorsInitialized(
+            name.abuchen.portfolio.model.proto.v1.PLedger.class, name.abuchen.portfolio.model.proto.v1.PLedger.Builder.class);
+  }
+
+  public static final int ENTRIES_FIELD_NUMBER = 1;
+  @SuppressWarnings("serial")
+  private java.util.List entries_;
+  /**
+   * repeated .name.abuchen.portfolio.PLedgerEntry entries = 1;
+   */
+  @java.lang.Override
+  public java.util.List getEntriesList() {
+    return entries_;
+  }
+  /**
+   * repeated .name.abuchen.portfolio.PLedgerEntry entries = 1;
+   */
+  @java.lang.Override
+  public java.util.List 
+      getEntriesOrBuilderList() {
+    return entries_;
+  }
+  /**
+   * repeated .name.abuchen.portfolio.PLedgerEntry entries = 1;
+   */
+  @java.lang.Override
+  public int getEntriesCount() {
+    return entries_.size();
+  }
+  /**
+   * repeated .name.abuchen.portfolio.PLedgerEntry entries = 1;
+   */
+  @java.lang.Override
+  public name.abuchen.portfolio.model.proto.v1.PLedgerEntry getEntries(int index) {
+    return entries_.get(index);
+  }
+  /**
+   * repeated .name.abuchen.portfolio.PLedgerEntry entries = 1;
+   */
+  @java.lang.Override
+  public name.abuchen.portfolio.model.proto.v1.PLedgerEntryOrBuilder getEntriesOrBuilder(
+      int index) {
+    return entries_.get(index);
+  }
+
+  private byte memoizedIsInitialized = -1;
+  @java.lang.Override
+  public final boolean isInitialized() {
+    byte isInitialized = memoizedIsInitialized;
+    if (isInitialized == 1) return true;
+    if (isInitialized == 0) return false;
+
+    memoizedIsInitialized = 1;
+    return true;
+  }
+
+  @java.lang.Override
+  public void writeTo(com.google.protobuf.CodedOutputStream output)
+                      throws java.io.IOException {
+    for (int i = 0; i < entries_.size(); i++) {
+      output.writeMessage(1, entries_.get(i));
+    }
+    getUnknownFields().writeTo(output);
+  }
+
+  @java.lang.Override
+  public int getSerializedSize() {
+    int size = memoizedSize;
+    if (size != -1) return size;
+
+    size = 0;
+    for (int i = 0; i < entries_.size(); i++) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeMessageSize(1, entries_.get(i));
+    }
+    size += getUnknownFields().getSerializedSize();
+    memoizedSize = size;
+    return size;
+  }
+
+  @java.lang.Override
+  public boolean equals(final java.lang.Object obj) {
+    if (obj == this) {
+     return true;
+    }
+    if (!(obj instanceof name.abuchen.portfolio.model.proto.v1.PLedger)) {
+      return super.equals(obj);
+    }
+    name.abuchen.portfolio.model.proto.v1.PLedger other = (name.abuchen.portfolio.model.proto.v1.PLedger) obj;
+
+    if (!getEntriesList()
+        .equals(other.getEntriesList())) return false;
+    if (!getUnknownFields().equals(other.getUnknownFields())) return false;
+    return true;
+  }
+
+  @java.lang.Override
+  public int hashCode() {
+    if (memoizedHashCode != 0) {
+      return memoizedHashCode;
+    }
+    int hash = 41;
+    hash = (19 * hash) + getDescriptor().hashCode();
+    if (getEntriesCount() > 0) {
+      hash = (37 * hash) + ENTRIES_FIELD_NUMBER;
+      hash = (53 * hash) + getEntriesList().hashCode();
+    }
+    hash = (29 * hash) + getUnknownFields().hashCode();
+    memoizedHashCode = hash;
+    return hash;
+  }
+
+  public static name.abuchen.portfolio.model.proto.v1.PLedger parseFrom(
+      java.nio.ByteBuffer data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static name.abuchen.portfolio.model.proto.v1.PLedger parseFrom(
+      java.nio.ByteBuffer data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static name.abuchen.portfolio.model.proto.v1.PLedger parseFrom(
+      com.google.protobuf.ByteString data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static name.abuchen.portfolio.model.proto.v1.PLedger parseFrom(
+      com.google.protobuf.ByteString data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static name.abuchen.portfolio.model.proto.v1.PLedger parseFrom(byte[] data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static name.abuchen.portfolio.model.proto.v1.PLedger parseFrom(
+      byte[] data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static name.abuchen.portfolio.model.proto.v1.PLedger parseFrom(java.io.InputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessageV3
+        .parseWithIOException(PARSER, input);
+  }
+  public static name.abuchen.portfolio.model.proto.v1.PLedger parseFrom(
+      java.io.InputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessageV3
+        .parseWithIOException(PARSER, input, extensionRegistry);
+  }
+  public static name.abuchen.portfolio.model.proto.v1.PLedger parseDelimitedFrom(java.io.InputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessageV3
+        .parseDelimitedWithIOException(PARSER, input);
+  }
+  public static name.abuchen.portfolio.model.proto.v1.PLedger parseDelimitedFrom(
+      java.io.InputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessageV3
+        .parseDelimitedWithIOException(PARSER, input, extensionRegistry);
+  }
+  public static name.abuchen.portfolio.model.proto.v1.PLedger parseFrom(
+      com.google.protobuf.CodedInputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessageV3
+        .parseWithIOException(PARSER, input);
+  }
+  public static name.abuchen.portfolio.model.proto.v1.PLedger parseFrom(
+      com.google.protobuf.CodedInputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessageV3
+        .parseWithIOException(PARSER, input, extensionRegistry);
+  }
+
+  @java.lang.Override
+  public Builder newBuilderForType() { return newBuilder(); }
+  public static Builder newBuilder() {
+    return DEFAULT_INSTANCE.toBuilder();
+  }
+  public static Builder newBuilder(name.abuchen.portfolio.model.proto.v1.PLedger prototype) {
+    return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype);
+  }
+  @java.lang.Override
+  public Builder toBuilder() {
+    return this == DEFAULT_INSTANCE
+        ? new Builder() : new Builder().mergeFrom(this);
+  }
+
+  @java.lang.Override
+  protected Builder newBuilderForType(
+      com.google.protobuf.GeneratedMessageV3.BuilderParent parent) {
+    Builder builder = new Builder(parent);
+    return builder;
+  }
+  /**
+   * Protobuf type {@code name.abuchen.portfolio.PLedger}
+   */
+  public static final class Builder extends
+      com.google.protobuf.GeneratedMessageV3.Builder implements
+      // @@protoc_insertion_point(builder_implements:name.abuchen.portfolio.PLedger)
+      name.abuchen.portfolio.model.proto.v1.PLedgerOrBuilder {
+    public static final com.google.protobuf.Descriptors.Descriptor
+        getDescriptor() {
+      return name.abuchen.portfolio.model.proto.v1.ClientProtos.internal_static_name_abuchen_portfolio_PLedger_descriptor;
+    }
+
+    @java.lang.Override
+    protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable
+        internalGetFieldAccessorTable() {
+      return name.abuchen.portfolio.model.proto.v1.ClientProtos.internal_static_name_abuchen_portfolio_PLedger_fieldAccessorTable
+          .ensureFieldAccessorsInitialized(
+              name.abuchen.portfolio.model.proto.v1.PLedger.class, name.abuchen.portfolio.model.proto.v1.PLedger.Builder.class);
+    }
+
+    // Construct using name.abuchen.portfolio.model.proto.v1.PLedger.newBuilder()
+    private Builder() {
+
+    }
+
+    private Builder(
+        com.google.protobuf.GeneratedMessageV3.BuilderParent parent) {
+      super(parent);
+
+    }
+    @java.lang.Override
+    public Builder clear() {
+      super.clear();
+      bitField0_ = 0;
+      if (entriesBuilder_ == null) {
+        entries_ = java.util.Collections.emptyList();
+      } else {
+        entries_ = null;
+        entriesBuilder_.clear();
+      }
+      bitField0_ = (bitField0_ & ~0x00000001);
+      return this;
+    }
+
+    @java.lang.Override
+    public com.google.protobuf.Descriptors.Descriptor
+        getDescriptorForType() {
+      return name.abuchen.portfolio.model.proto.v1.ClientProtos.internal_static_name_abuchen_portfolio_PLedger_descriptor;
+    }
+
+    @java.lang.Override
+    public name.abuchen.portfolio.model.proto.v1.PLedger getDefaultInstanceForType() {
+      return name.abuchen.portfolio.model.proto.v1.PLedger.getDefaultInstance();
+    }
+
+    @java.lang.Override
+    public name.abuchen.portfolio.model.proto.v1.PLedger build() {
+      name.abuchen.portfolio.model.proto.v1.PLedger result = buildPartial();
+      if (!result.isInitialized()) {
+        throw newUninitializedMessageException(result);
+      }
+      return result;
+    }
+
+    @java.lang.Override
+    public name.abuchen.portfolio.model.proto.v1.PLedger buildPartial() {
+      name.abuchen.portfolio.model.proto.v1.PLedger result = new name.abuchen.portfolio.model.proto.v1.PLedger(this);
+      buildPartialRepeatedFields(result);
+      if (bitField0_ != 0) { buildPartial0(result); }
+      onBuilt();
+      return result;
+    }
+
+    private void buildPartialRepeatedFields(name.abuchen.portfolio.model.proto.v1.PLedger result) {
+      if (entriesBuilder_ == null) {
+        if (((bitField0_ & 0x00000001) != 0)) {
+          entries_ = java.util.Collections.unmodifiableList(entries_);
+          bitField0_ = (bitField0_ & ~0x00000001);
+        }
+        result.entries_ = entries_;
+      } else {
+        result.entries_ = entriesBuilder_.build();
+      }
+    }
+
+    private void buildPartial0(name.abuchen.portfolio.model.proto.v1.PLedger result) {
+      int from_bitField0_ = bitField0_;
+    }
+
+    @java.lang.Override
+    public Builder mergeFrom(com.google.protobuf.Message other) {
+      if (other instanceof name.abuchen.portfolio.model.proto.v1.PLedger) {
+        return mergeFrom((name.abuchen.portfolio.model.proto.v1.PLedger)other);
+      } else {
+        super.mergeFrom(other);
+        return this;
+      }
+    }
+
+    public Builder mergeFrom(name.abuchen.portfolio.model.proto.v1.PLedger other) {
+      if (other == name.abuchen.portfolio.model.proto.v1.PLedger.getDefaultInstance()) return this;
+      if (entriesBuilder_ == null) {
+        if (!other.entries_.isEmpty()) {
+          if (entries_.isEmpty()) {
+            entries_ = other.entries_;
+            bitField0_ = (bitField0_ & ~0x00000001);
+          } else {
+            ensureEntriesIsMutable();
+            entries_.addAll(other.entries_);
+          }
+          onChanged();
+        }
+      } else {
+        if (!other.entries_.isEmpty()) {
+          if (entriesBuilder_.isEmpty()) {
+            entriesBuilder_.dispose();
+            entriesBuilder_ = null;
+            entries_ = other.entries_;
+            bitField0_ = (bitField0_ & ~0x00000001);
+            entriesBuilder_ = 
+              com.google.protobuf.GeneratedMessageV3.alwaysUseFieldBuilders ?
+                 getEntriesFieldBuilder() : null;
+          } else {
+            entriesBuilder_.addAllMessages(other.entries_);
+          }
+        }
+      }
+      this.mergeUnknownFields(other.getUnknownFields());
+      onChanged();
+      return this;
+    }
+
+    @java.lang.Override
+    public final boolean isInitialized() {
+      return true;
+    }
+
+    @java.lang.Override
+    public Builder mergeFrom(
+        com.google.protobuf.CodedInputStream input,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws java.io.IOException {
+      if (extensionRegistry == null) {
+        throw new java.lang.NullPointerException();
+      }
+      try {
+        boolean done = false;
+        while (!done) {
+          int tag = input.readTag();
+          switch (tag) {
+            case 0:
+              done = true;
+              break;
+            case 10: {
+              name.abuchen.portfolio.model.proto.v1.PLedgerEntry m =
+                  input.readMessage(
+                      name.abuchen.portfolio.model.proto.v1.PLedgerEntry.parser(),
+                      extensionRegistry);
+              if (entriesBuilder_ == null) {
+                ensureEntriesIsMutable();
+                entries_.add(m);
+              } else {
+                entriesBuilder_.addMessage(m);
+              }
+              break;
+            } // case 10
+            default: {
+              if (!super.parseUnknownField(input, extensionRegistry, tag)) {
+                done = true; // was an endgroup tag
+              }
+              break;
+            } // default:
+          } // switch (tag)
+        } // while (!done)
+      } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+        throw e.unwrapIOException();
+      } finally {
+        onChanged();
+      } // finally
+      return this;
+    }
+    private int bitField0_;
+
+    private java.util.List entries_ =
+      java.util.Collections.emptyList();
+    private void ensureEntriesIsMutable() {
+      if (!((bitField0_ & 0x00000001) != 0)) {
+        entries_ = new java.util.ArrayList(entries_);
+        bitField0_ |= 0x00000001;
+       }
+    }
+
+    private com.google.protobuf.RepeatedFieldBuilderV3<
+        name.abuchen.portfolio.model.proto.v1.PLedgerEntry, name.abuchen.portfolio.model.proto.v1.PLedgerEntry.Builder, name.abuchen.portfolio.model.proto.v1.PLedgerEntryOrBuilder> entriesBuilder_;
+
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerEntry entries = 1;
+     */
+    public java.util.List getEntriesList() {
+      if (entriesBuilder_ == null) {
+        return java.util.Collections.unmodifiableList(entries_);
+      } else {
+        return entriesBuilder_.getMessageList();
+      }
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerEntry entries = 1;
+     */
+    public int getEntriesCount() {
+      if (entriesBuilder_ == null) {
+        return entries_.size();
+      } else {
+        return entriesBuilder_.getCount();
+      }
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerEntry entries = 1;
+     */
+    public name.abuchen.portfolio.model.proto.v1.PLedgerEntry getEntries(int index) {
+      if (entriesBuilder_ == null) {
+        return entries_.get(index);
+      } else {
+        return entriesBuilder_.getMessage(index);
+      }
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerEntry entries = 1;
+     */
+    public Builder setEntries(
+        int index, name.abuchen.portfolio.model.proto.v1.PLedgerEntry value) {
+      if (entriesBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensureEntriesIsMutable();
+        entries_.set(index, value);
+        onChanged();
+      } else {
+        entriesBuilder_.setMessage(index, value);
+      }
+      return this;
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerEntry entries = 1;
+     */
+    public Builder setEntries(
+        int index, name.abuchen.portfolio.model.proto.v1.PLedgerEntry.Builder builderForValue) {
+      if (entriesBuilder_ == null) {
+        ensureEntriesIsMutable();
+        entries_.set(index, builderForValue.build());
+        onChanged();
+      } else {
+        entriesBuilder_.setMessage(index, builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerEntry entries = 1;
+     */
+    public Builder addEntries(name.abuchen.portfolio.model.proto.v1.PLedgerEntry value) {
+      if (entriesBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensureEntriesIsMutable();
+        entries_.add(value);
+        onChanged();
+      } else {
+        entriesBuilder_.addMessage(value);
+      }
+      return this;
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerEntry entries = 1;
+     */
+    public Builder addEntries(
+        int index, name.abuchen.portfolio.model.proto.v1.PLedgerEntry value) {
+      if (entriesBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensureEntriesIsMutable();
+        entries_.add(index, value);
+        onChanged();
+      } else {
+        entriesBuilder_.addMessage(index, value);
+      }
+      return this;
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerEntry entries = 1;
+     */
+    public Builder addEntries(
+        name.abuchen.portfolio.model.proto.v1.PLedgerEntry.Builder builderForValue) {
+      if (entriesBuilder_ == null) {
+        ensureEntriesIsMutable();
+        entries_.add(builderForValue.build());
+        onChanged();
+      } else {
+        entriesBuilder_.addMessage(builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerEntry entries = 1;
+     */
+    public Builder addEntries(
+        int index, name.abuchen.portfolio.model.proto.v1.PLedgerEntry.Builder builderForValue) {
+      if (entriesBuilder_ == null) {
+        ensureEntriesIsMutable();
+        entries_.add(index, builderForValue.build());
+        onChanged();
+      } else {
+        entriesBuilder_.addMessage(index, builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerEntry entries = 1;
+     */
+    public Builder addAllEntries(
+        java.lang.Iterable values) {
+      if (entriesBuilder_ == null) {
+        ensureEntriesIsMutable();
+        com.google.protobuf.AbstractMessageLite.Builder.addAll(
+            values, entries_);
+        onChanged();
+      } else {
+        entriesBuilder_.addAllMessages(values);
+      }
+      return this;
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerEntry entries = 1;
+     */
+    public Builder clearEntries() {
+      if (entriesBuilder_ == null) {
+        entries_ = java.util.Collections.emptyList();
+        bitField0_ = (bitField0_ & ~0x00000001);
+        onChanged();
+      } else {
+        entriesBuilder_.clear();
+      }
+      return this;
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerEntry entries = 1;
+     */
+    public Builder removeEntries(int index) {
+      if (entriesBuilder_ == null) {
+        ensureEntriesIsMutable();
+        entries_.remove(index);
+        onChanged();
+      } else {
+        entriesBuilder_.remove(index);
+      }
+      return this;
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerEntry entries = 1;
+     */
+    public name.abuchen.portfolio.model.proto.v1.PLedgerEntry.Builder getEntriesBuilder(
+        int index) {
+      return getEntriesFieldBuilder().getBuilder(index);
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerEntry entries = 1;
+     */
+    public name.abuchen.portfolio.model.proto.v1.PLedgerEntryOrBuilder getEntriesOrBuilder(
+        int index) {
+      if (entriesBuilder_ == null) {
+        return entries_.get(index);  } else {
+        return entriesBuilder_.getMessageOrBuilder(index);
+      }
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerEntry entries = 1;
+     */
+    public java.util.List 
+         getEntriesOrBuilderList() {
+      if (entriesBuilder_ != null) {
+        return entriesBuilder_.getMessageOrBuilderList();
+      } else {
+        return java.util.Collections.unmodifiableList(entries_);
+      }
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerEntry entries = 1;
+     */
+    public name.abuchen.portfolio.model.proto.v1.PLedgerEntry.Builder addEntriesBuilder() {
+      return getEntriesFieldBuilder().addBuilder(
+          name.abuchen.portfolio.model.proto.v1.PLedgerEntry.getDefaultInstance());
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerEntry entries = 1;
+     */
+    public name.abuchen.portfolio.model.proto.v1.PLedgerEntry.Builder addEntriesBuilder(
+        int index) {
+      return getEntriesFieldBuilder().addBuilder(
+          index, name.abuchen.portfolio.model.proto.v1.PLedgerEntry.getDefaultInstance());
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerEntry entries = 1;
+     */
+    public java.util.List 
+         getEntriesBuilderList() {
+      return getEntriesFieldBuilder().getBuilderList();
+    }
+    private com.google.protobuf.RepeatedFieldBuilderV3<
+        name.abuchen.portfolio.model.proto.v1.PLedgerEntry, name.abuchen.portfolio.model.proto.v1.PLedgerEntry.Builder, name.abuchen.portfolio.model.proto.v1.PLedgerEntryOrBuilder> 
+        getEntriesFieldBuilder() {
+      if (entriesBuilder_ == null) {
+        entriesBuilder_ = new com.google.protobuf.RepeatedFieldBuilderV3<
+            name.abuchen.portfolio.model.proto.v1.PLedgerEntry, name.abuchen.portfolio.model.proto.v1.PLedgerEntry.Builder, name.abuchen.portfolio.model.proto.v1.PLedgerEntryOrBuilder>(
+                entries_,
+                ((bitField0_ & 0x00000001) != 0),
+                getParentForChildren(),
+                isClean());
+        entries_ = null;
+      }
+      return entriesBuilder_;
+    }
+    @java.lang.Override
+    public final Builder setUnknownFields(
+        final com.google.protobuf.UnknownFieldSet unknownFields) {
+      return super.setUnknownFields(unknownFields);
+    }
+
+    @java.lang.Override
+    public final Builder mergeUnknownFields(
+        final com.google.protobuf.UnknownFieldSet unknownFields) {
+      return super.mergeUnknownFields(unknownFields);
+    }
+
+
+    // @@protoc_insertion_point(builder_scope:name.abuchen.portfolio.PLedger)
+  }
+
+  // @@protoc_insertion_point(class_scope:name.abuchen.portfolio.PLedger)
+  private static final name.abuchen.portfolio.model.proto.v1.PLedger DEFAULT_INSTANCE;
+  static {
+    DEFAULT_INSTANCE = new name.abuchen.portfolio.model.proto.v1.PLedger();
+  }
+
+  public static name.abuchen.portfolio.model.proto.v1.PLedger getDefaultInstance() {
+    return DEFAULT_INSTANCE;
+  }
+
+  private static final com.google.protobuf.Parser
+      PARSER = new com.google.protobuf.AbstractParser() {
+    @java.lang.Override
+    public PLedger parsePartialFrom(
+        com.google.protobuf.CodedInputStream input,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws com.google.protobuf.InvalidProtocolBufferException {
+      Builder builder = newBuilder();
+      try {
+        builder.mergeFrom(input, extensionRegistry);
+      } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+        throw e.setUnfinishedMessage(builder.buildPartial());
+      } catch (com.google.protobuf.UninitializedMessageException e) {
+        throw e.asInvalidProtocolBufferException().setUnfinishedMessage(builder.buildPartial());
+      } catch (java.io.IOException e) {
+        throw new com.google.protobuf.InvalidProtocolBufferException(e)
+            .setUnfinishedMessage(builder.buildPartial());
+      }
+      return builder.buildPartial();
+    }
+  };
+
+  public static com.google.protobuf.Parser parser() {
+    return PARSER;
+  }
+
+  @java.lang.Override
+  public com.google.protobuf.Parser getParserForType() {
+    return PARSER;
+  }
+
+  @java.lang.Override
+  public name.abuchen.portfolio.model.proto.v1.PLedger getDefaultInstanceForType() {
+    return DEFAULT_INSTANCE;
+  }
+
+}
+
diff --git a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerEntry.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerEntry.java
new file mode 100644
index 0000000000..4a53d4e904
--- /dev/null
+++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerEntry.java
@@ -0,0 +1,2327 @@
+// Generated by the protocol buffer compiler.  DO NOT EDIT!
+// source: client.proto
+
+package name.abuchen.portfolio.model.proto.v1;
+
+/**
+ * Protobuf type {@code name.abuchen.portfolio.PLedgerEntry}
+ */
+public final class PLedgerEntry extends
+    com.google.protobuf.GeneratedMessageV3 implements
+    // @@protoc_insertion_point(message_implements:name.abuchen.portfolio.PLedgerEntry)
+    PLedgerEntryOrBuilder {
+private static final long serialVersionUID = 0L;
+  // Use PLedgerEntry.newBuilder() to construct.
+  private PLedgerEntry(com.google.protobuf.GeneratedMessageV3.Builder builder) {
+    super(builder);
+  }
+  private PLedgerEntry() {
+    uuid_ = "";
+    note_ = "";
+    source_ = "";
+    postings_ = java.util.Collections.emptyList();
+    projectionRefs_ = java.util.Collections.emptyList();
+    parameters_ = java.util.Collections.emptyList();
+  }
+
+  @java.lang.Override
+  @SuppressWarnings({"unused"})
+  protected java.lang.Object newInstance(
+      UnusedPrivateParameter unused) {
+    return new PLedgerEntry();
+  }
+
+  public static final com.google.protobuf.Descriptors.Descriptor
+      getDescriptor() {
+    return name.abuchen.portfolio.model.proto.v1.ClientProtos.internal_static_name_abuchen_portfolio_PLedgerEntry_descriptor;
+  }
+
+  @java.lang.Override
+  protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable
+      internalGetFieldAccessorTable() {
+    return name.abuchen.portfolio.model.proto.v1.ClientProtos.internal_static_name_abuchen_portfolio_PLedgerEntry_fieldAccessorTable
+        .ensureFieldAccessorsInitialized(
+            name.abuchen.portfolio.model.proto.v1.PLedgerEntry.class, name.abuchen.portfolio.model.proto.v1.PLedgerEntry.Builder.class);
+  }
+
+  private int bitField0_;
+  public static final int UUID_FIELD_NUMBER = 1;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object uuid_ = "";
+  /**
+   * string uuid = 1;
+   * @return The uuid.
+   */
+  @java.lang.Override
+  public java.lang.String getUuid() {
+    java.lang.Object ref = uuid_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs =
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      uuid_ = s;
+      return s;
+    }
+  }
+  /**
+   * string uuid = 1;
+   * @return The bytes for uuid.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getUuidBytes() {
+    java.lang.Object ref = uuid_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b =
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      uuid_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int DATETIME_FIELD_NUMBER = 3;
+  private com.google.protobuf.Timestamp dateTime_;
+  /**
+   * .google.protobuf.Timestamp dateTime = 3;
+   * @return Whether the dateTime field is set.
+   */
+  @java.lang.Override
+  public boolean hasDateTime() {
+    return dateTime_ != null;
+  }
+  /**
+   * .google.protobuf.Timestamp dateTime = 3;
+   * @return The dateTime.
+   */
+  @java.lang.Override
+  public com.google.protobuf.Timestamp getDateTime() {
+    return dateTime_ == null ? com.google.protobuf.Timestamp.getDefaultInstance() : dateTime_;
+  }
+  /**
+   * .google.protobuf.Timestamp dateTime = 3;
+   */
+  @java.lang.Override
+  public com.google.protobuf.TimestampOrBuilder getDateTimeOrBuilder() {
+    return dateTime_ == null ? com.google.protobuf.Timestamp.getDefaultInstance() : dateTime_;
+  }
+
+  public static final int NOTE_FIELD_NUMBER = 4;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object note_ = "";
+  /**
+   * optional string note = 4;
+   * @return Whether the note field is set.
+   */
+  @java.lang.Override
+  public boolean hasNote() {
+    return ((bitField0_ & 0x00000001) != 0);
+  }
+  /**
+   * optional string note = 4;
+   * @return The note.
+   */
+  @java.lang.Override
+  public java.lang.String getNote() {
+    java.lang.Object ref = note_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs =
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      note_ = s;
+      return s;
+    }
+  }
+  /**
+   * optional string note = 4;
+   * @return The bytes for note.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getNoteBytes() {
+    java.lang.Object ref = note_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b =
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      note_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int SOURCE_FIELD_NUMBER = 5;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object source_ = "";
+  /**
+   * optional string source = 5;
+   * @return Whether the source field is set.
+   */
+  @java.lang.Override
+  public boolean hasSource() {
+    return ((bitField0_ & 0x00000002) != 0);
+  }
+  /**
+   * optional string source = 5;
+   * @return The source.
+   */
+  @java.lang.Override
+  public java.lang.String getSource() {
+    java.lang.Object ref = source_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs =
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      source_ = s;
+      return s;
+    }
+  }
+  /**
+   * optional string source = 5;
+   * @return The bytes for source.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getSourceBytes() {
+    java.lang.Object ref = source_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b =
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      source_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int UPDATEDAT_FIELD_NUMBER = 6;
+  private com.google.protobuf.Timestamp updatedAt_;
+  /**
+   * .google.protobuf.Timestamp updatedAt = 6;
+   * @return Whether the updatedAt field is set.
+   */
+  @java.lang.Override
+  public boolean hasUpdatedAt() {
+    return updatedAt_ != null;
+  }
+  /**
+   * .google.protobuf.Timestamp updatedAt = 6;
+   * @return The updatedAt.
+   */
+  @java.lang.Override
+  public com.google.protobuf.Timestamp getUpdatedAt() {
+    return updatedAt_ == null ? com.google.protobuf.Timestamp.getDefaultInstance() : updatedAt_;
+  }
+  /**
+   * .google.protobuf.Timestamp updatedAt = 6;
+   */
+  @java.lang.Override
+  public com.google.protobuf.TimestampOrBuilder getUpdatedAtOrBuilder() {
+    return updatedAt_ == null ? com.google.protobuf.Timestamp.getDefaultInstance() : updatedAt_;
+  }
+
+  public static final int POSTINGS_FIELD_NUMBER = 7;
+  @SuppressWarnings("serial")
+  private java.util.List postings_;
+  /**
+   * repeated .name.abuchen.portfolio.PLedgerPosting postings = 7;
+   */
+  @java.lang.Override
+  public java.util.List getPostingsList() {
+    return postings_;
+  }
+  /**
+   * repeated .name.abuchen.portfolio.PLedgerPosting postings = 7;
+   */
+  @java.lang.Override
+  public java.util.List
+      getPostingsOrBuilderList() {
+    return postings_;
+  }
+  /**
+   * repeated .name.abuchen.portfolio.PLedgerPosting postings = 7;
+   */
+  @java.lang.Override
+  public int getPostingsCount() {
+    return postings_.size();
+  }
+  /**
+   * repeated .name.abuchen.portfolio.PLedgerPosting postings = 7;
+   */
+  @java.lang.Override
+  public name.abuchen.portfolio.model.proto.v1.PLedgerPosting getPostings(int index) {
+    return postings_.get(index);
+  }
+  /**
+   * repeated .name.abuchen.portfolio.PLedgerPosting postings = 7;
+   */
+  @java.lang.Override
+  public name.abuchen.portfolio.model.proto.v1.PLedgerPostingOrBuilder getPostingsOrBuilder(
+      int index) {
+    return postings_.get(index);
+  }
+
+  public static final int PROJECTIONREFS_FIELD_NUMBER = 8;
+  @SuppressWarnings("serial")
+  private java.util.List projectionRefs_;
+  /**
+   * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8;
+   */
+  @java.lang.Override
+  public java.util.List getProjectionRefsList() {
+    return projectionRefs_;
+  }
+  /**
+   * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8;
+   */
+  @java.lang.Override
+  public java.util.List
+      getProjectionRefsOrBuilderList() {
+    return projectionRefs_;
+  }
+  /**
+   * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8;
+   */
+  @java.lang.Override
+  public int getProjectionRefsCount() {
+    return projectionRefs_.size();
+  }
+  /**
+   * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8;
+   */
+  @java.lang.Override
+  public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef getProjectionRefs(int index) {
+    return projectionRefs_.get(index);
+  }
+  /**
+   * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8;
+   */
+  @java.lang.Override
+  public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRefOrBuilder getProjectionRefsOrBuilder(
+      int index) {
+    return projectionRefs_.get(index);
+  }
+
+  public static final int TYPEID_FIELD_NUMBER = 9;
+  private int typeId_ = 0;
+  /**
+   * optional uint32 typeId = 9;
+   * @return Whether the typeId field is set.
+   */
+  @java.lang.Override
+  public boolean hasTypeId() {
+    return ((bitField0_ & 0x00000004) != 0);
+  }
+  /**
+   * optional uint32 typeId = 9;
+   * @return The typeId.
+   */
+  @java.lang.Override
+  public int getTypeId() {
+    return typeId_;
+  }
+
+  public static final int PARAMETERS_FIELD_NUMBER = 10;
+  @SuppressWarnings("serial")
+  private java.util.List parameters_;
+  /**
+   * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 10;
+   */
+  @java.lang.Override
+  public java.util.List getParametersList() {
+    return parameters_;
+  }
+  /**
+   * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 10;
+   */
+  @java.lang.Override
+  public java.util.List
+      getParametersOrBuilderList() {
+    return parameters_;
+  }
+  /**
+   * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 10;
+   */
+  @java.lang.Override
+  public int getParametersCount() {
+    return parameters_.size();
+  }
+  /**
+   * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 10;
+   */
+  @java.lang.Override
+  public name.abuchen.portfolio.model.proto.v1.PLedgerParameter getParameters(int index) {
+    return parameters_.get(index);
+  }
+  /**
+   * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 10;
+   */
+  @java.lang.Override
+  public name.abuchen.portfolio.model.proto.v1.PLedgerParameterOrBuilder getParametersOrBuilder(
+      int index) {
+    return parameters_.get(index);
+  }
+
+  private byte memoizedIsInitialized = -1;
+  @java.lang.Override
+  public final boolean isInitialized() {
+    byte isInitialized = memoizedIsInitialized;
+    if (isInitialized == 1) return true;
+    if (isInitialized == 0) return false;
+
+    memoizedIsInitialized = 1;
+    return true;
+  }
+
+  @java.lang.Override
+  public void writeTo(com.google.protobuf.CodedOutputStream output)
+                      throws java.io.IOException {
+    if (!com.google.protobuf.GeneratedMessageV3.isStringEmpty(uuid_)) {
+      com.google.protobuf.GeneratedMessageV3.writeString(output, 1, uuid_);
+    }
+    if (dateTime_ != null) {
+      output.writeMessage(3, getDateTime());
+    }
+    if (((bitField0_ & 0x00000001) != 0)) {
+      com.google.protobuf.GeneratedMessageV3.writeString(output, 4, note_);
+    }
+    if (((bitField0_ & 0x00000002) != 0)) {
+      com.google.protobuf.GeneratedMessageV3.writeString(output, 5, source_);
+    }
+    if (updatedAt_ != null) {
+      output.writeMessage(6, getUpdatedAt());
+    }
+    for (int i = 0; i < postings_.size(); i++) {
+      output.writeMessage(7, postings_.get(i));
+    }
+    for (int i = 0; i < projectionRefs_.size(); i++) {
+      output.writeMessage(8, projectionRefs_.get(i));
+    }
+    if (((bitField0_ & 0x00000004) != 0)) {
+      output.writeUInt32(9, typeId_);
+    }
+    for (int i = 0; i < parameters_.size(); i++) {
+      output.writeMessage(10, parameters_.get(i));
+    }
+    getUnknownFields().writeTo(output);
+  }
+
+  @java.lang.Override
+  public int getSerializedSize() {
+    int size = memoizedSize;
+    if (size != -1) return size;
+
+    size = 0;
+    if (!com.google.protobuf.GeneratedMessageV3.isStringEmpty(uuid_)) {
+      size += com.google.protobuf.GeneratedMessageV3.computeStringSize(1, uuid_);
+    }
+    if (dateTime_ != null) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeMessageSize(3, getDateTime());
+    }
+    if (((bitField0_ & 0x00000001) != 0)) {
+      size += com.google.protobuf.GeneratedMessageV3.computeStringSize(4, note_);
+    }
+    if (((bitField0_ & 0x00000002) != 0)) {
+      size += com.google.protobuf.GeneratedMessageV3.computeStringSize(5, source_);
+    }
+    if (updatedAt_ != null) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeMessageSize(6, getUpdatedAt());
+    }
+    for (int i = 0; i < postings_.size(); i++) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeMessageSize(7, postings_.get(i));
+    }
+    for (int i = 0; i < projectionRefs_.size(); i++) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeMessageSize(8, projectionRefs_.get(i));
+    }
+    if (((bitField0_ & 0x00000004) != 0)) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeUInt32Size(9, typeId_);
+    }
+    for (int i = 0; i < parameters_.size(); i++) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeMessageSize(10, parameters_.get(i));
+    }
+    size += getUnknownFields().getSerializedSize();
+    memoizedSize = size;
+    return size;
+  }
+
+  @java.lang.Override
+  public boolean equals(final java.lang.Object obj) {
+    if (obj == this) {
+     return true;
+    }
+    if (!(obj instanceof name.abuchen.portfolio.model.proto.v1.PLedgerEntry)) {
+      return super.equals(obj);
+    }
+    name.abuchen.portfolio.model.proto.v1.PLedgerEntry other = (name.abuchen.portfolio.model.proto.v1.PLedgerEntry) obj;
+
+    if (!getUuid()
+        .equals(other.getUuid())) return false;
+    if (hasDateTime() != other.hasDateTime()) return false;
+    if (hasDateTime()) {
+      if (!getDateTime()
+          .equals(other.getDateTime())) return false;
+    }
+    if (hasNote() != other.hasNote()) return false;
+    if (hasNote()) {
+      if (!getNote()
+          .equals(other.getNote())) return false;
+    }
+    if (hasSource() != other.hasSource()) return false;
+    if (hasSource()) {
+      if (!getSource()
+          .equals(other.getSource())) return false;
+    }
+    if (hasUpdatedAt() != other.hasUpdatedAt()) return false;
+    if (hasUpdatedAt()) {
+      if (!getUpdatedAt()
+          .equals(other.getUpdatedAt())) return false;
+    }
+    if (!getPostingsList()
+        .equals(other.getPostingsList())) return false;
+    if (!getProjectionRefsList()
+        .equals(other.getProjectionRefsList())) return false;
+    if (hasTypeId() != other.hasTypeId()) return false;
+    if (hasTypeId()) {
+      if (getTypeId()
+          != other.getTypeId()) return false;
+    }
+    if (!getParametersList()
+        .equals(other.getParametersList())) return false;
+    if (!getUnknownFields().equals(other.getUnknownFields())) return false;
+    return true;
+  }
+
+  @java.lang.Override
+  public int hashCode() {
+    if (memoizedHashCode != 0) {
+      return memoizedHashCode;
+    }
+    int hash = 41;
+    hash = (19 * hash) + getDescriptor().hashCode();
+    hash = (37 * hash) + UUID_FIELD_NUMBER;
+    hash = (53 * hash) + getUuid().hashCode();
+    if (hasDateTime()) {
+      hash = (37 * hash) + DATETIME_FIELD_NUMBER;
+      hash = (53 * hash) + getDateTime().hashCode();
+    }
+    if (hasNote()) {
+      hash = (37 * hash) + NOTE_FIELD_NUMBER;
+      hash = (53 * hash) + getNote().hashCode();
+    }
+    if (hasSource()) {
+      hash = (37 * hash) + SOURCE_FIELD_NUMBER;
+      hash = (53 * hash) + getSource().hashCode();
+    }
+    if (hasUpdatedAt()) {
+      hash = (37 * hash) + UPDATEDAT_FIELD_NUMBER;
+      hash = (53 * hash) + getUpdatedAt().hashCode();
+    }
+    if (getPostingsCount() > 0) {
+      hash = (37 * hash) + POSTINGS_FIELD_NUMBER;
+      hash = (53 * hash) + getPostingsList().hashCode();
+    }
+    if (getProjectionRefsCount() > 0) {
+      hash = (37 * hash) + PROJECTIONREFS_FIELD_NUMBER;
+      hash = (53 * hash) + getProjectionRefsList().hashCode();
+    }
+    if (hasTypeId()) {
+      hash = (37 * hash) + TYPEID_FIELD_NUMBER;
+      hash = (53 * hash) + getTypeId();
+    }
+    if (getParametersCount() > 0) {
+      hash = (37 * hash) + PARAMETERS_FIELD_NUMBER;
+      hash = (53 * hash) + getParametersList().hashCode();
+    }
+    hash = (29 * hash) + getUnknownFields().hashCode();
+    memoizedHashCode = hash;
+    return hash;
+  }
+
+  public static name.abuchen.portfolio.model.proto.v1.PLedgerEntry parseFrom(
+      java.nio.ByteBuffer data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static name.abuchen.portfolio.model.proto.v1.PLedgerEntry parseFrom(
+      java.nio.ByteBuffer data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static name.abuchen.portfolio.model.proto.v1.PLedgerEntry parseFrom(
+      com.google.protobuf.ByteString data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static name.abuchen.portfolio.model.proto.v1.PLedgerEntry parseFrom(
+      com.google.protobuf.ByteString data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static name.abuchen.portfolio.model.proto.v1.PLedgerEntry parseFrom(byte[] data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static name.abuchen.portfolio.model.proto.v1.PLedgerEntry parseFrom(
+      byte[] data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static name.abuchen.portfolio.model.proto.v1.PLedgerEntry parseFrom(java.io.InputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessageV3
+        .parseWithIOException(PARSER, input);
+  }
+  public static name.abuchen.portfolio.model.proto.v1.PLedgerEntry parseFrom(
+      java.io.InputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessageV3
+        .parseWithIOException(PARSER, input, extensionRegistry);
+  }
+  public static name.abuchen.portfolio.model.proto.v1.PLedgerEntry parseDelimitedFrom(java.io.InputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessageV3
+        .parseDelimitedWithIOException(PARSER, input);
+  }
+  public static name.abuchen.portfolio.model.proto.v1.PLedgerEntry parseDelimitedFrom(
+      java.io.InputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessageV3
+        .parseDelimitedWithIOException(PARSER, input, extensionRegistry);
+  }
+  public static name.abuchen.portfolio.model.proto.v1.PLedgerEntry parseFrom(
+      com.google.protobuf.CodedInputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessageV3
+        .parseWithIOException(PARSER, input);
+  }
+  public static name.abuchen.portfolio.model.proto.v1.PLedgerEntry parseFrom(
+      com.google.protobuf.CodedInputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessageV3
+        .parseWithIOException(PARSER, input, extensionRegistry);
+  }
+
+  @java.lang.Override
+  public Builder newBuilderForType() { return newBuilder(); }
+  public static Builder newBuilder() {
+    return DEFAULT_INSTANCE.toBuilder();
+  }
+  public static Builder newBuilder(name.abuchen.portfolio.model.proto.v1.PLedgerEntry prototype) {
+    return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype);
+  }
+  @java.lang.Override
+  public Builder toBuilder() {
+    return this == DEFAULT_INSTANCE
+        ? new Builder() : new Builder().mergeFrom(this);
+  }
+
+  @java.lang.Override
+  protected Builder newBuilderForType(
+      com.google.protobuf.GeneratedMessageV3.BuilderParent parent) {
+    Builder builder = new Builder(parent);
+    return builder;
+  }
+  /**
+   * Protobuf type {@code name.abuchen.portfolio.PLedgerEntry}
+   */
+  public static final class Builder extends
+      com.google.protobuf.GeneratedMessageV3.Builder implements
+      // @@protoc_insertion_point(builder_implements:name.abuchen.portfolio.PLedgerEntry)
+      name.abuchen.portfolio.model.proto.v1.PLedgerEntryOrBuilder {
+    public static final com.google.protobuf.Descriptors.Descriptor
+        getDescriptor() {
+      return name.abuchen.portfolio.model.proto.v1.ClientProtos.internal_static_name_abuchen_portfolio_PLedgerEntry_descriptor;
+    }
+
+    @java.lang.Override
+    protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable
+        internalGetFieldAccessorTable() {
+      return name.abuchen.portfolio.model.proto.v1.ClientProtos.internal_static_name_abuchen_portfolio_PLedgerEntry_fieldAccessorTable
+          .ensureFieldAccessorsInitialized(
+              name.abuchen.portfolio.model.proto.v1.PLedgerEntry.class, name.abuchen.portfolio.model.proto.v1.PLedgerEntry.Builder.class);
+    }
+
+    // Construct using name.abuchen.portfolio.model.proto.v1.PLedgerEntry.newBuilder()
+    private Builder() {
+
+    }
+
+    private Builder(
+        com.google.protobuf.GeneratedMessageV3.BuilderParent parent) {
+      super(parent);
+
+    }
+    @java.lang.Override
+    public Builder clear() {
+      super.clear();
+      bitField0_ = 0;
+      uuid_ = "";
+      dateTime_ = null;
+      if (dateTimeBuilder_ != null) {
+        dateTimeBuilder_.dispose();
+        dateTimeBuilder_ = null;
+      }
+      note_ = "";
+      source_ = "";
+      updatedAt_ = null;
+      if (updatedAtBuilder_ != null) {
+        updatedAtBuilder_.dispose();
+        updatedAtBuilder_ = null;
+      }
+      if (postingsBuilder_ == null) {
+        postings_ = java.util.Collections.emptyList();
+      } else {
+        postings_ = null;
+        postingsBuilder_.clear();
+      }
+      bitField0_ = (bitField0_ & ~0x00000020);
+      if (projectionRefsBuilder_ == null) {
+        projectionRefs_ = java.util.Collections.emptyList();
+      } else {
+        projectionRefs_ = null;
+        projectionRefsBuilder_.clear();
+      }
+      bitField0_ = (bitField0_ & ~0x00000040);
+      typeId_ = 0;
+      if (parametersBuilder_ == null) {
+        parameters_ = java.util.Collections.emptyList();
+      } else {
+        parameters_ = null;
+        parametersBuilder_.clear();
+      }
+      bitField0_ = (bitField0_ & ~0x00000100);
+      return this;
+    }
+
+    @java.lang.Override
+    public com.google.protobuf.Descriptors.Descriptor
+        getDescriptorForType() {
+      return name.abuchen.portfolio.model.proto.v1.ClientProtos.internal_static_name_abuchen_portfolio_PLedgerEntry_descriptor;
+    }
+
+    @java.lang.Override
+    public name.abuchen.portfolio.model.proto.v1.PLedgerEntry getDefaultInstanceForType() {
+      return name.abuchen.portfolio.model.proto.v1.PLedgerEntry.getDefaultInstance();
+    }
+
+    @java.lang.Override
+    public name.abuchen.portfolio.model.proto.v1.PLedgerEntry build() {
+      name.abuchen.portfolio.model.proto.v1.PLedgerEntry result = buildPartial();
+      if (!result.isInitialized()) {
+        throw newUninitializedMessageException(result);
+      }
+      return result;
+    }
+
+    @java.lang.Override
+    public name.abuchen.portfolio.model.proto.v1.PLedgerEntry buildPartial() {
+      name.abuchen.portfolio.model.proto.v1.PLedgerEntry result = new name.abuchen.portfolio.model.proto.v1.PLedgerEntry(this);
+      buildPartialRepeatedFields(result);
+      if (bitField0_ != 0) { buildPartial0(result); }
+      onBuilt();
+      return result;
+    }
+
+    private void buildPartialRepeatedFields(name.abuchen.portfolio.model.proto.v1.PLedgerEntry result) {
+      if (postingsBuilder_ == null) {
+        if (((bitField0_ & 0x00000020) != 0)) {
+          postings_ = java.util.Collections.unmodifiableList(postings_);
+          bitField0_ = (bitField0_ & ~0x00000020);
+        }
+        result.postings_ = postings_;
+      } else {
+        result.postings_ = postingsBuilder_.build();
+      }
+      if (projectionRefsBuilder_ == null) {
+        if (((bitField0_ & 0x00000040) != 0)) {
+          projectionRefs_ = java.util.Collections.unmodifiableList(projectionRefs_);
+          bitField0_ = (bitField0_ & ~0x00000040);
+        }
+        result.projectionRefs_ = projectionRefs_;
+      } else {
+        result.projectionRefs_ = projectionRefsBuilder_.build();
+      }
+      if (parametersBuilder_ == null) {
+        if (((bitField0_ & 0x00000100) != 0)) {
+          parameters_ = java.util.Collections.unmodifiableList(parameters_);
+          bitField0_ = (bitField0_ & ~0x00000100);
+        }
+        result.parameters_ = parameters_;
+      } else {
+        result.parameters_ = parametersBuilder_.build();
+      }
+    }
+
+    private void buildPartial0(name.abuchen.portfolio.model.proto.v1.PLedgerEntry result) {
+      int from_bitField0_ = bitField0_;
+      if (((from_bitField0_ & 0x00000001) != 0)) {
+        result.uuid_ = uuid_;
+      }
+      if (((from_bitField0_ & 0x00000002) != 0)) {
+        result.dateTime_ = dateTimeBuilder_ == null
+            ? dateTime_
+            : dateTimeBuilder_.build();
+      }
+      int to_bitField0_ = 0;
+      if (((from_bitField0_ & 0x00000004) != 0)) {
+        result.note_ = note_;
+        to_bitField0_ |= 0x00000001;
+      }
+      if (((from_bitField0_ & 0x00000008) != 0)) {
+        result.source_ = source_;
+        to_bitField0_ |= 0x00000002;
+      }
+      if (((from_bitField0_ & 0x00000010) != 0)) {
+        result.updatedAt_ = updatedAtBuilder_ == null
+            ? updatedAt_
+            : updatedAtBuilder_.build();
+      }
+      if (((from_bitField0_ & 0x00000080) != 0)) {
+        result.typeId_ = typeId_;
+        to_bitField0_ |= 0x00000004;
+      }
+      result.bitField0_ |= to_bitField0_;
+    }
+
+    @java.lang.Override
+    public Builder mergeFrom(com.google.protobuf.Message other) {
+      if (other instanceof name.abuchen.portfolio.model.proto.v1.PLedgerEntry) {
+        return mergeFrom((name.abuchen.portfolio.model.proto.v1.PLedgerEntry)other);
+      } else {
+        super.mergeFrom(other);
+        return this;
+      }
+    }
+
+    public Builder mergeFrom(name.abuchen.portfolio.model.proto.v1.PLedgerEntry other) {
+      if (other == name.abuchen.portfolio.model.proto.v1.PLedgerEntry.getDefaultInstance()) return this;
+      if (!other.getUuid().isEmpty()) {
+        uuid_ = other.uuid_;
+        bitField0_ |= 0x00000001;
+        onChanged();
+      }
+      if (other.hasDateTime()) {
+        mergeDateTime(other.getDateTime());
+      }
+      if (other.hasNote()) {
+        note_ = other.note_;
+        bitField0_ |= 0x00000004;
+        onChanged();
+      }
+      if (other.hasSource()) {
+        source_ = other.source_;
+        bitField0_ |= 0x00000008;
+        onChanged();
+      }
+      if (other.hasUpdatedAt()) {
+        mergeUpdatedAt(other.getUpdatedAt());
+      }
+      if (postingsBuilder_ == null) {
+        if (!other.postings_.isEmpty()) {
+          if (postings_.isEmpty()) {
+            postings_ = other.postings_;
+            bitField0_ = (bitField0_ & ~0x00000020);
+          } else {
+            ensurePostingsIsMutable();
+            postings_.addAll(other.postings_);
+          }
+          onChanged();
+        }
+      } else {
+        if (!other.postings_.isEmpty()) {
+          if (postingsBuilder_.isEmpty()) {
+            postingsBuilder_.dispose();
+            postingsBuilder_ = null;
+            postings_ = other.postings_;
+            bitField0_ = (bitField0_ & ~0x00000020);
+            postingsBuilder_ =
+              com.google.protobuf.GeneratedMessageV3.alwaysUseFieldBuilders ?
+                 getPostingsFieldBuilder() : null;
+          } else {
+            postingsBuilder_.addAllMessages(other.postings_);
+          }
+        }
+      }
+      if (projectionRefsBuilder_ == null) {
+        if (!other.projectionRefs_.isEmpty()) {
+          if (projectionRefs_.isEmpty()) {
+            projectionRefs_ = other.projectionRefs_;
+            bitField0_ = (bitField0_ & ~0x00000040);
+          } else {
+            ensureProjectionRefsIsMutable();
+            projectionRefs_.addAll(other.projectionRefs_);
+          }
+          onChanged();
+        }
+      } else {
+        if (!other.projectionRefs_.isEmpty()) {
+          if (projectionRefsBuilder_.isEmpty()) {
+            projectionRefsBuilder_.dispose();
+            projectionRefsBuilder_ = null;
+            projectionRefs_ = other.projectionRefs_;
+            bitField0_ = (bitField0_ & ~0x00000040);
+            projectionRefsBuilder_ =
+              com.google.protobuf.GeneratedMessageV3.alwaysUseFieldBuilders ?
+                 getProjectionRefsFieldBuilder() : null;
+          } else {
+            projectionRefsBuilder_.addAllMessages(other.projectionRefs_);
+          }
+        }
+      }
+      if (other.hasTypeId()) {
+        setTypeId(other.getTypeId());
+      }
+      if (parametersBuilder_ == null) {
+        if (!other.parameters_.isEmpty()) {
+          if (parameters_.isEmpty()) {
+            parameters_ = other.parameters_;
+            bitField0_ = (bitField0_ & ~0x00000100);
+          } else {
+            ensureParametersIsMutable();
+            parameters_.addAll(other.parameters_);
+          }
+          onChanged();
+        }
+      } else {
+        if (!other.parameters_.isEmpty()) {
+          if (parametersBuilder_.isEmpty()) {
+            parametersBuilder_.dispose();
+            parametersBuilder_ = null;
+            parameters_ = other.parameters_;
+            bitField0_ = (bitField0_ & ~0x00000100);
+            parametersBuilder_ =
+              com.google.protobuf.GeneratedMessageV3.alwaysUseFieldBuilders ?
+                 getParametersFieldBuilder() : null;
+          } else {
+            parametersBuilder_.addAllMessages(other.parameters_);
+          }
+        }
+      }
+      this.mergeUnknownFields(other.getUnknownFields());
+      onChanged();
+      return this;
+    }
+
+    @java.lang.Override
+    public final boolean isInitialized() {
+      return true;
+    }
+
+    @java.lang.Override
+    public Builder mergeFrom(
+        com.google.protobuf.CodedInputStream input,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws java.io.IOException {
+      if (extensionRegistry == null) {
+        throw new java.lang.NullPointerException();
+      }
+      try {
+        boolean done = false;
+        while (!done) {
+          int tag = input.readTag();
+          switch (tag) {
+            case 0:
+              done = true;
+              break;
+            case 10: {
+              uuid_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000001;
+              break;
+            } // case 10
+            case 26: {
+              input.readMessage(
+                  getDateTimeFieldBuilder().getBuilder(),
+                  extensionRegistry);
+              bitField0_ |= 0x00000002;
+              break;
+            } // case 26
+            case 34: {
+              note_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000004;
+              break;
+            } // case 34
+            case 42: {
+              source_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000008;
+              break;
+            } // case 42
+            case 50: {
+              input.readMessage(
+                  getUpdatedAtFieldBuilder().getBuilder(),
+                  extensionRegistry);
+              bitField0_ |= 0x00000010;
+              break;
+            } // case 50
+            case 58: {
+              name.abuchen.portfolio.model.proto.v1.PLedgerPosting m =
+                  input.readMessage(
+                      name.abuchen.portfolio.model.proto.v1.PLedgerPosting.parser(),
+                      extensionRegistry);
+              if (postingsBuilder_ == null) {
+                ensurePostingsIsMutable();
+                postings_.add(m);
+              } else {
+                postingsBuilder_.addMessage(m);
+              }
+              break;
+            } // case 58
+            case 66: {
+              name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef m =
+                  input.readMessage(
+                      name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef.parser(),
+                      extensionRegistry);
+              if (projectionRefsBuilder_ == null) {
+                ensureProjectionRefsIsMutable();
+                projectionRefs_.add(m);
+              } else {
+                projectionRefsBuilder_.addMessage(m);
+              }
+              break;
+            } // case 66
+            case 72: {
+              typeId_ = input.readUInt32();
+              bitField0_ |= 0x00000080;
+              break;
+            } // case 72
+            case 82: {
+              name.abuchen.portfolio.model.proto.v1.PLedgerParameter m =
+                  input.readMessage(
+                      name.abuchen.portfolio.model.proto.v1.PLedgerParameter.parser(),
+                      extensionRegistry);
+              if (parametersBuilder_ == null) {
+                ensureParametersIsMutable();
+                parameters_.add(m);
+              } else {
+                parametersBuilder_.addMessage(m);
+              }
+              break;
+            } // case 82
+            default: {
+              if (!super.parseUnknownField(input, extensionRegistry, tag)) {
+                done = true; // was an endgroup tag
+              }
+              break;
+            } // default:
+          } // switch (tag)
+        } // while (!done)
+      } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+        throw e.unwrapIOException();
+      } finally {
+        onChanged();
+      } // finally
+      return this;
+    }
+    private int bitField0_;
+
+    private java.lang.Object uuid_ = "";
+    /**
+     * string uuid = 1;
+     * @return The uuid.
+     */
+    public java.lang.String getUuid() {
+      java.lang.Object ref = uuid_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        uuid_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * string uuid = 1;
+     * @return The bytes for uuid.
+     */
+    public com.google.protobuf.ByteString
+        getUuidBytes() {
+      java.lang.Object ref = uuid_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b =
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        uuid_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * string uuid = 1;
+     * @param value The uuid to set.
+     * @return This builder for chaining.
+     */
+    public Builder setUuid(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      uuid_ = value;
+      bitField0_ |= 0x00000001;
+      onChanged();
+      return this;
+    }
+    /**
+     * string uuid = 1;
+     * @return This builder for chaining.
+     */
+    public Builder clearUuid() {
+      uuid_ = getDefaultInstance().getUuid();
+      bitField0_ = (bitField0_ & ~0x00000001);
+      onChanged();
+      return this;
+    }
+    /**
+     * string uuid = 1;
+     * @param value The bytes for uuid to set.
+     * @return This builder for chaining.
+     */
+    public Builder setUuidBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      uuid_ = value;
+      bitField0_ |= 0x00000001;
+      onChanged();
+      return this;
+    }
+
+    private com.google.protobuf.Timestamp dateTime_;
+    private com.google.protobuf.SingleFieldBuilderV3<
+        com.google.protobuf.Timestamp, com.google.protobuf.Timestamp.Builder, com.google.protobuf.TimestampOrBuilder> dateTimeBuilder_;
+    /**
+     * .google.protobuf.Timestamp dateTime = 3;
+     * @return Whether the dateTime field is set.
+     */
+    public boolean hasDateTime() {
+      return ((bitField0_ & 0x00000002) != 0);
+    }
+    /**
+     * .google.protobuf.Timestamp dateTime = 3;
+     * @return The dateTime.
+     */
+    public com.google.protobuf.Timestamp getDateTime() {
+      if (dateTimeBuilder_ == null) {
+        return dateTime_ == null ? com.google.protobuf.Timestamp.getDefaultInstance() : dateTime_;
+      } else {
+        return dateTimeBuilder_.getMessage();
+      }
+    }
+    /**
+     * .google.protobuf.Timestamp dateTime = 3;
+     */
+    public Builder setDateTime(com.google.protobuf.Timestamp value) {
+      if (dateTimeBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        dateTime_ = value;
+      } else {
+        dateTimeBuilder_.setMessage(value);
+      }
+      bitField0_ |= 0x00000002;
+      onChanged();
+      return this;
+    }
+    /**
+     * .google.protobuf.Timestamp dateTime = 3;
+     */
+    public Builder setDateTime(
+        com.google.protobuf.Timestamp.Builder builderForValue) {
+      if (dateTimeBuilder_ == null) {
+        dateTime_ = builderForValue.build();
+      } else {
+        dateTimeBuilder_.setMessage(builderForValue.build());
+      }
+      bitField0_ |= 0x00000002;
+      onChanged();
+      return this;
+    }
+    /**
+     * .google.protobuf.Timestamp dateTime = 3;
+     */
+    public Builder mergeDateTime(com.google.protobuf.Timestamp value) {
+      if (dateTimeBuilder_ == null) {
+        if (((bitField0_ & 0x00000002) != 0) &&
+          dateTime_ != null &&
+          dateTime_ != com.google.protobuf.Timestamp.getDefaultInstance()) {
+          getDateTimeBuilder().mergeFrom(value);
+        } else {
+          dateTime_ = value;
+        }
+      } else {
+        dateTimeBuilder_.mergeFrom(value);
+      }
+      bitField0_ |= 0x00000002;
+      onChanged();
+      return this;
+    }
+    /**
+     * .google.protobuf.Timestamp dateTime = 3;
+     */
+    public Builder clearDateTime() {
+      bitField0_ = (bitField0_ & ~0x00000002);
+      dateTime_ = null;
+      if (dateTimeBuilder_ != null) {
+        dateTimeBuilder_.dispose();
+        dateTimeBuilder_ = null;
+      }
+      onChanged();
+      return this;
+    }
+    /**
+     * .google.protobuf.Timestamp dateTime = 3;
+     */
+    public com.google.protobuf.Timestamp.Builder getDateTimeBuilder() {
+      bitField0_ |= 0x00000002;
+      onChanged();
+      return getDateTimeFieldBuilder().getBuilder();
+    }
+    /**
+     * .google.protobuf.Timestamp dateTime = 3;
+     */
+    public com.google.protobuf.TimestampOrBuilder getDateTimeOrBuilder() {
+      if (dateTimeBuilder_ != null) {
+        return dateTimeBuilder_.getMessageOrBuilder();
+      } else {
+        return dateTime_ == null ?
+            com.google.protobuf.Timestamp.getDefaultInstance() : dateTime_;
+      }
+    }
+    /**
+     * .google.protobuf.Timestamp dateTime = 3;
+     */
+    private com.google.protobuf.SingleFieldBuilderV3<
+        com.google.protobuf.Timestamp, com.google.protobuf.Timestamp.Builder, com.google.protobuf.TimestampOrBuilder>
+        getDateTimeFieldBuilder() {
+      if (dateTimeBuilder_ == null) {
+        dateTimeBuilder_ = new com.google.protobuf.SingleFieldBuilderV3<
+            com.google.protobuf.Timestamp, com.google.protobuf.Timestamp.Builder, com.google.protobuf.TimestampOrBuilder>(
+                getDateTime(),
+                getParentForChildren(),
+                isClean());
+        dateTime_ = null;
+      }
+      return dateTimeBuilder_;
+    }
+
+    private java.lang.Object note_ = "";
+    /**
+     * optional string note = 4;
+     * @return Whether the note field is set.
+     */
+    public boolean hasNote() {
+      return ((bitField0_ & 0x00000004) != 0);
+    }
+    /**
+     * optional string note = 4;
+     * @return The note.
+     */
+    public java.lang.String getNote() {
+      java.lang.Object ref = note_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        note_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * optional string note = 4;
+     * @return The bytes for note.
+     */
+    public com.google.protobuf.ByteString
+        getNoteBytes() {
+      java.lang.Object ref = note_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b =
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        note_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * optional string note = 4;
+     * @param value The note to set.
+     * @return This builder for chaining.
+     */
+    public Builder setNote(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      note_ = value;
+      bitField0_ |= 0x00000004;
+      onChanged();
+      return this;
+    }
+    /**
+     * optional string note = 4;
+     * @return This builder for chaining.
+     */
+    public Builder clearNote() {
+      note_ = getDefaultInstance().getNote();
+      bitField0_ = (bitField0_ & ~0x00000004);
+      onChanged();
+      return this;
+    }
+    /**
+     * optional string note = 4;
+     * @param value The bytes for note to set.
+     * @return This builder for chaining.
+     */
+    public Builder setNoteBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      note_ = value;
+      bitField0_ |= 0x00000004;
+      onChanged();
+      return this;
+    }
+
+    private java.lang.Object source_ = "";
+    /**
+     * optional string source = 5;
+     * @return Whether the source field is set.
+     */
+    public boolean hasSource() {
+      return ((bitField0_ & 0x00000008) != 0);
+    }
+    /**
+     * optional string source = 5;
+     * @return The source.
+     */
+    public java.lang.String getSource() {
+      java.lang.Object ref = source_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        source_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * optional string source = 5;
+     * @return The bytes for source.
+     */
+    public com.google.protobuf.ByteString
+        getSourceBytes() {
+      java.lang.Object ref = source_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b =
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        source_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * optional string source = 5;
+     * @param value The source to set.
+     * @return This builder for chaining.
+     */
+    public Builder setSource(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      source_ = value;
+      bitField0_ |= 0x00000008;
+      onChanged();
+      return this;
+    }
+    /**
+     * optional string source = 5;
+     * @return This builder for chaining.
+     */
+    public Builder clearSource() {
+      source_ = getDefaultInstance().getSource();
+      bitField0_ = (bitField0_ & ~0x00000008);
+      onChanged();
+      return this;
+    }
+    /**
+     * optional string source = 5;
+     * @param value The bytes for source to set.
+     * @return This builder for chaining.
+     */
+    public Builder setSourceBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      source_ = value;
+      bitField0_ |= 0x00000008;
+      onChanged();
+      return this;
+    }
+
+    private com.google.protobuf.Timestamp updatedAt_;
+    private com.google.protobuf.SingleFieldBuilderV3<
+        com.google.protobuf.Timestamp, com.google.protobuf.Timestamp.Builder, com.google.protobuf.TimestampOrBuilder> updatedAtBuilder_;
+    /**
+     * .google.protobuf.Timestamp updatedAt = 6;
+     * @return Whether the updatedAt field is set.
+     */
+    public boolean hasUpdatedAt() {
+      return ((bitField0_ & 0x00000010) != 0);
+    }
+    /**
+     * .google.protobuf.Timestamp updatedAt = 6;
+     * @return The updatedAt.
+     */
+    public com.google.protobuf.Timestamp getUpdatedAt() {
+      if (updatedAtBuilder_ == null) {
+        return updatedAt_ == null ? com.google.protobuf.Timestamp.getDefaultInstance() : updatedAt_;
+      } else {
+        return updatedAtBuilder_.getMessage();
+      }
+    }
+    /**
+     * .google.protobuf.Timestamp updatedAt = 6;
+     */
+    public Builder setUpdatedAt(com.google.protobuf.Timestamp value) {
+      if (updatedAtBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        updatedAt_ = value;
+      } else {
+        updatedAtBuilder_.setMessage(value);
+      }
+      bitField0_ |= 0x00000010;
+      onChanged();
+      return this;
+    }
+    /**
+     * .google.protobuf.Timestamp updatedAt = 6;
+     */
+    public Builder setUpdatedAt(
+        com.google.protobuf.Timestamp.Builder builderForValue) {
+      if (updatedAtBuilder_ == null) {
+        updatedAt_ = builderForValue.build();
+      } else {
+        updatedAtBuilder_.setMessage(builderForValue.build());
+      }
+      bitField0_ |= 0x00000010;
+      onChanged();
+      return this;
+    }
+    /**
+     * .google.protobuf.Timestamp updatedAt = 6;
+     */
+    public Builder mergeUpdatedAt(com.google.protobuf.Timestamp value) {
+      if (updatedAtBuilder_ == null) {
+        if (((bitField0_ & 0x00000010) != 0) &&
+          updatedAt_ != null &&
+          updatedAt_ != com.google.protobuf.Timestamp.getDefaultInstance()) {
+          getUpdatedAtBuilder().mergeFrom(value);
+        } else {
+          updatedAt_ = value;
+        }
+      } else {
+        updatedAtBuilder_.mergeFrom(value);
+      }
+      bitField0_ |= 0x00000010;
+      onChanged();
+      return this;
+    }
+    /**
+     * .google.protobuf.Timestamp updatedAt = 6;
+     */
+    public Builder clearUpdatedAt() {
+      bitField0_ = (bitField0_ & ~0x00000010);
+      updatedAt_ = null;
+      if (updatedAtBuilder_ != null) {
+        updatedAtBuilder_.dispose();
+        updatedAtBuilder_ = null;
+      }
+      onChanged();
+      return this;
+    }
+    /**
+     * .google.protobuf.Timestamp updatedAt = 6;
+     */
+    public com.google.protobuf.Timestamp.Builder getUpdatedAtBuilder() {
+      bitField0_ |= 0x00000010;
+      onChanged();
+      return getUpdatedAtFieldBuilder().getBuilder();
+    }
+    /**
+     * .google.protobuf.Timestamp updatedAt = 6;
+     */
+    public com.google.protobuf.TimestampOrBuilder getUpdatedAtOrBuilder() {
+      if (updatedAtBuilder_ != null) {
+        return updatedAtBuilder_.getMessageOrBuilder();
+      } else {
+        return updatedAt_ == null ?
+            com.google.protobuf.Timestamp.getDefaultInstance() : updatedAt_;
+      }
+    }
+    /**
+     * .google.protobuf.Timestamp updatedAt = 6;
+     */
+    private com.google.protobuf.SingleFieldBuilderV3<
+        com.google.protobuf.Timestamp, com.google.protobuf.Timestamp.Builder, com.google.protobuf.TimestampOrBuilder>
+        getUpdatedAtFieldBuilder() {
+      if (updatedAtBuilder_ == null) {
+        updatedAtBuilder_ = new com.google.protobuf.SingleFieldBuilderV3<
+            com.google.protobuf.Timestamp, com.google.protobuf.Timestamp.Builder, com.google.protobuf.TimestampOrBuilder>(
+                getUpdatedAt(),
+                getParentForChildren(),
+                isClean());
+        updatedAt_ = null;
+      }
+      return updatedAtBuilder_;
+    }
+
+    private java.util.List postings_ =
+      java.util.Collections.emptyList();
+    private void ensurePostingsIsMutable() {
+      if (!((bitField0_ & 0x00000020) != 0)) {
+        postings_ = new java.util.ArrayList(postings_);
+        bitField0_ |= 0x00000020;
+       }
+    }
+
+    private com.google.protobuf.RepeatedFieldBuilderV3<
+        name.abuchen.portfolio.model.proto.v1.PLedgerPosting, name.abuchen.portfolio.model.proto.v1.PLedgerPosting.Builder, name.abuchen.portfolio.model.proto.v1.PLedgerPostingOrBuilder> postingsBuilder_;
+
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerPosting postings = 7;
+     */
+    public java.util.List getPostingsList() {
+      if (postingsBuilder_ == null) {
+        return java.util.Collections.unmodifiableList(postings_);
+      } else {
+        return postingsBuilder_.getMessageList();
+      }
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerPosting postings = 7;
+     */
+    public int getPostingsCount() {
+      if (postingsBuilder_ == null) {
+        return postings_.size();
+      } else {
+        return postingsBuilder_.getCount();
+      }
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerPosting postings = 7;
+     */
+    public name.abuchen.portfolio.model.proto.v1.PLedgerPosting getPostings(int index) {
+      if (postingsBuilder_ == null) {
+        return postings_.get(index);
+      } else {
+        return postingsBuilder_.getMessage(index);
+      }
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerPosting postings = 7;
+     */
+    public Builder setPostings(
+        int index, name.abuchen.portfolio.model.proto.v1.PLedgerPosting value) {
+      if (postingsBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensurePostingsIsMutable();
+        postings_.set(index, value);
+        onChanged();
+      } else {
+        postingsBuilder_.setMessage(index, value);
+      }
+      return this;
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerPosting postings = 7;
+     */
+    public Builder setPostings(
+        int index, name.abuchen.portfolio.model.proto.v1.PLedgerPosting.Builder builderForValue) {
+      if (postingsBuilder_ == null) {
+        ensurePostingsIsMutable();
+        postings_.set(index, builderForValue.build());
+        onChanged();
+      } else {
+        postingsBuilder_.setMessage(index, builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerPosting postings = 7;
+     */
+    public Builder addPostings(name.abuchen.portfolio.model.proto.v1.PLedgerPosting value) {
+      if (postingsBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensurePostingsIsMutable();
+        postings_.add(value);
+        onChanged();
+      } else {
+        postingsBuilder_.addMessage(value);
+      }
+      return this;
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerPosting postings = 7;
+     */
+    public Builder addPostings(
+        int index, name.abuchen.portfolio.model.proto.v1.PLedgerPosting value) {
+      if (postingsBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensurePostingsIsMutable();
+        postings_.add(index, value);
+        onChanged();
+      } else {
+        postingsBuilder_.addMessage(index, value);
+      }
+      return this;
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerPosting postings = 7;
+     */
+    public Builder addPostings(
+        name.abuchen.portfolio.model.proto.v1.PLedgerPosting.Builder builderForValue) {
+      if (postingsBuilder_ == null) {
+        ensurePostingsIsMutable();
+        postings_.add(builderForValue.build());
+        onChanged();
+      } else {
+        postingsBuilder_.addMessage(builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerPosting postings = 7;
+     */
+    public Builder addPostings(
+        int index, name.abuchen.portfolio.model.proto.v1.PLedgerPosting.Builder builderForValue) {
+      if (postingsBuilder_ == null) {
+        ensurePostingsIsMutable();
+        postings_.add(index, builderForValue.build());
+        onChanged();
+      } else {
+        postingsBuilder_.addMessage(index, builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerPosting postings = 7;
+     */
+    public Builder addAllPostings(
+        java.lang.Iterable values) {
+      if (postingsBuilder_ == null) {
+        ensurePostingsIsMutable();
+        com.google.protobuf.AbstractMessageLite.Builder.addAll(
+            values, postings_);
+        onChanged();
+      } else {
+        postingsBuilder_.addAllMessages(values);
+      }
+      return this;
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerPosting postings = 7;
+     */
+    public Builder clearPostings() {
+      if (postingsBuilder_ == null) {
+        postings_ = java.util.Collections.emptyList();
+        bitField0_ = (bitField0_ & ~0x00000020);
+        onChanged();
+      } else {
+        postingsBuilder_.clear();
+      }
+      return this;
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerPosting postings = 7;
+     */
+    public Builder removePostings(int index) {
+      if (postingsBuilder_ == null) {
+        ensurePostingsIsMutable();
+        postings_.remove(index);
+        onChanged();
+      } else {
+        postingsBuilder_.remove(index);
+      }
+      return this;
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerPosting postings = 7;
+     */
+    public name.abuchen.portfolio.model.proto.v1.PLedgerPosting.Builder getPostingsBuilder(
+        int index) {
+      return getPostingsFieldBuilder().getBuilder(index);
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerPosting postings = 7;
+     */
+    public name.abuchen.portfolio.model.proto.v1.PLedgerPostingOrBuilder getPostingsOrBuilder(
+        int index) {
+      if (postingsBuilder_ == null) {
+        return postings_.get(index);  } else {
+        return postingsBuilder_.getMessageOrBuilder(index);
+      }
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerPosting postings = 7;
+     */
+    public java.util.List
+         getPostingsOrBuilderList() {
+      if (postingsBuilder_ != null) {
+        return postingsBuilder_.getMessageOrBuilderList();
+      } else {
+        return java.util.Collections.unmodifiableList(postings_);
+      }
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerPosting postings = 7;
+     */
+    public name.abuchen.portfolio.model.proto.v1.PLedgerPosting.Builder addPostingsBuilder() {
+      return getPostingsFieldBuilder().addBuilder(
+          name.abuchen.portfolio.model.proto.v1.PLedgerPosting.getDefaultInstance());
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerPosting postings = 7;
+     */
+    public name.abuchen.portfolio.model.proto.v1.PLedgerPosting.Builder addPostingsBuilder(
+        int index) {
+      return getPostingsFieldBuilder().addBuilder(
+          index, name.abuchen.portfolio.model.proto.v1.PLedgerPosting.getDefaultInstance());
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerPosting postings = 7;
+     */
+    public java.util.List
+         getPostingsBuilderList() {
+      return getPostingsFieldBuilder().getBuilderList();
+    }
+    private com.google.protobuf.RepeatedFieldBuilderV3<
+        name.abuchen.portfolio.model.proto.v1.PLedgerPosting, name.abuchen.portfolio.model.proto.v1.PLedgerPosting.Builder, name.abuchen.portfolio.model.proto.v1.PLedgerPostingOrBuilder>
+        getPostingsFieldBuilder() {
+      if (postingsBuilder_ == null) {
+        postingsBuilder_ = new com.google.protobuf.RepeatedFieldBuilderV3<
+            name.abuchen.portfolio.model.proto.v1.PLedgerPosting, name.abuchen.portfolio.model.proto.v1.PLedgerPosting.Builder, name.abuchen.portfolio.model.proto.v1.PLedgerPostingOrBuilder>(
+                postings_,
+                ((bitField0_ & 0x00000020) != 0),
+                getParentForChildren(),
+                isClean());
+        postings_ = null;
+      }
+      return postingsBuilder_;
+    }
+
+    private java.util.List projectionRefs_ =
+      java.util.Collections.emptyList();
+    private void ensureProjectionRefsIsMutable() {
+      if (!((bitField0_ & 0x00000040) != 0)) {
+        projectionRefs_ = new java.util.ArrayList(projectionRefs_);
+        bitField0_ |= 0x00000040;
+       }
+    }
+
+    private com.google.protobuf.RepeatedFieldBuilderV3<
+        name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef.Builder, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRefOrBuilder> projectionRefsBuilder_;
+
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8;
+     */
+    public java.util.List getProjectionRefsList() {
+      if (projectionRefsBuilder_ == null) {
+        return java.util.Collections.unmodifiableList(projectionRefs_);
+      } else {
+        return projectionRefsBuilder_.getMessageList();
+      }
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8;
+     */
+    public int getProjectionRefsCount() {
+      if (projectionRefsBuilder_ == null) {
+        return projectionRefs_.size();
+      } else {
+        return projectionRefsBuilder_.getCount();
+      }
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8;
+     */
+    public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef getProjectionRefs(int index) {
+      if (projectionRefsBuilder_ == null) {
+        return projectionRefs_.get(index);
+      } else {
+        return projectionRefsBuilder_.getMessage(index);
+      }
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8;
+     */
+    public Builder setProjectionRefs(
+        int index, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef value) {
+      if (projectionRefsBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensureProjectionRefsIsMutable();
+        projectionRefs_.set(index, value);
+        onChanged();
+      } else {
+        projectionRefsBuilder_.setMessage(index, value);
+      }
+      return this;
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8;
+     */
+    public Builder setProjectionRefs(
+        int index, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef.Builder builderForValue) {
+      if (projectionRefsBuilder_ == null) {
+        ensureProjectionRefsIsMutable();
+        projectionRefs_.set(index, builderForValue.build());
+        onChanged();
+      } else {
+        projectionRefsBuilder_.setMessage(index, builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8;
+     */
+    public Builder addProjectionRefs(name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef value) {
+      if (projectionRefsBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensureProjectionRefsIsMutable();
+        projectionRefs_.add(value);
+        onChanged();
+      } else {
+        projectionRefsBuilder_.addMessage(value);
+      }
+      return this;
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8;
+     */
+    public Builder addProjectionRefs(
+        int index, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef value) {
+      if (projectionRefsBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensureProjectionRefsIsMutable();
+        projectionRefs_.add(index, value);
+        onChanged();
+      } else {
+        projectionRefsBuilder_.addMessage(index, value);
+      }
+      return this;
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8;
+     */
+    public Builder addProjectionRefs(
+        name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef.Builder builderForValue) {
+      if (projectionRefsBuilder_ == null) {
+        ensureProjectionRefsIsMutable();
+        projectionRefs_.add(builderForValue.build());
+        onChanged();
+      } else {
+        projectionRefsBuilder_.addMessage(builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8;
+     */
+    public Builder addProjectionRefs(
+        int index, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef.Builder builderForValue) {
+      if (projectionRefsBuilder_ == null) {
+        ensureProjectionRefsIsMutable();
+        projectionRefs_.add(index, builderForValue.build());
+        onChanged();
+      } else {
+        projectionRefsBuilder_.addMessage(index, builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8;
+     */
+    public Builder addAllProjectionRefs(
+        java.lang.Iterable values) {
+      if (projectionRefsBuilder_ == null) {
+        ensureProjectionRefsIsMutable();
+        com.google.protobuf.AbstractMessageLite.Builder.addAll(
+            values, projectionRefs_);
+        onChanged();
+      } else {
+        projectionRefsBuilder_.addAllMessages(values);
+      }
+      return this;
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8;
+     */
+    public Builder clearProjectionRefs() {
+      if (projectionRefsBuilder_ == null) {
+        projectionRefs_ = java.util.Collections.emptyList();
+        bitField0_ = (bitField0_ & ~0x00000040);
+        onChanged();
+      } else {
+        projectionRefsBuilder_.clear();
+      }
+      return this;
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8;
+     */
+    public Builder removeProjectionRefs(int index) {
+      if (projectionRefsBuilder_ == null) {
+        ensureProjectionRefsIsMutable();
+        projectionRefs_.remove(index);
+        onChanged();
+      } else {
+        projectionRefsBuilder_.remove(index);
+      }
+      return this;
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8;
+     */
+    public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef.Builder getProjectionRefsBuilder(
+        int index) {
+      return getProjectionRefsFieldBuilder().getBuilder(index);
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8;
+     */
+    public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRefOrBuilder getProjectionRefsOrBuilder(
+        int index) {
+      if (projectionRefsBuilder_ == null) {
+        return projectionRefs_.get(index);  } else {
+        return projectionRefsBuilder_.getMessageOrBuilder(index);
+      }
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8;
+     */
+    public java.util.List
+         getProjectionRefsOrBuilderList() {
+      if (projectionRefsBuilder_ != null) {
+        return projectionRefsBuilder_.getMessageOrBuilderList();
+      } else {
+        return java.util.Collections.unmodifiableList(projectionRefs_);
+      }
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8;
+     */
+    public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef.Builder addProjectionRefsBuilder() {
+      return getProjectionRefsFieldBuilder().addBuilder(
+          name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef.getDefaultInstance());
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8;
+     */
+    public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef.Builder addProjectionRefsBuilder(
+        int index) {
+      return getProjectionRefsFieldBuilder().addBuilder(
+          index, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef.getDefaultInstance());
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8;
+     */
+    public java.util.List
+         getProjectionRefsBuilderList() {
+      return getProjectionRefsFieldBuilder().getBuilderList();
+    }
+    private com.google.protobuf.RepeatedFieldBuilderV3<
+        name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef.Builder, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRefOrBuilder>
+        getProjectionRefsFieldBuilder() {
+      if (projectionRefsBuilder_ == null) {
+        projectionRefsBuilder_ = new com.google.protobuf.RepeatedFieldBuilderV3<
+            name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef.Builder, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRefOrBuilder>(
+                projectionRefs_,
+                ((bitField0_ & 0x00000040) != 0),
+                getParentForChildren(),
+                isClean());
+        projectionRefs_ = null;
+      }
+      return projectionRefsBuilder_;
+    }
+
+    private int typeId_ ;
+    /**
+     * optional uint32 typeId = 9;
+     * @return Whether the typeId field is set.
+     */
+    @java.lang.Override
+    public boolean hasTypeId() {
+      return ((bitField0_ & 0x00000080) != 0);
+    }
+    /**
+     * optional uint32 typeId = 9;
+     * @return The typeId.
+     */
+    @java.lang.Override
+    public int getTypeId() {
+      return typeId_;
+    }
+    /**
+     * optional uint32 typeId = 9;
+     * @param value The typeId to set.
+     * @return This builder for chaining.
+     */
+    public Builder setTypeId(int value) {
+
+      typeId_ = value;
+      bitField0_ |= 0x00000080;
+      onChanged();
+      return this;
+    }
+    /**
+     * optional uint32 typeId = 9;
+     * @return This builder for chaining.
+     */
+    public Builder clearTypeId() {
+      bitField0_ = (bitField0_ & ~0x00000080);
+      typeId_ = 0;
+      onChanged();
+      return this;
+    }
+
+    private java.util.List parameters_ =
+      java.util.Collections.emptyList();
+    private void ensureParametersIsMutable() {
+      if (!((bitField0_ & 0x00000100) != 0)) {
+        parameters_ = new java.util.ArrayList(parameters_);
+        bitField0_ |= 0x00000100;
+       }
+    }
+
+    private com.google.protobuf.RepeatedFieldBuilderV3<
+        name.abuchen.portfolio.model.proto.v1.PLedgerParameter, name.abuchen.portfolio.model.proto.v1.PLedgerParameter.Builder, name.abuchen.portfolio.model.proto.v1.PLedgerParameterOrBuilder> parametersBuilder_;
+
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 10;
+     */
+    public java.util.List getParametersList() {
+      if (parametersBuilder_ == null) {
+        return java.util.Collections.unmodifiableList(parameters_);
+      } else {
+        return parametersBuilder_.getMessageList();
+      }
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 10;
+     */
+    public int getParametersCount() {
+      if (parametersBuilder_ == null) {
+        return parameters_.size();
+      } else {
+        return parametersBuilder_.getCount();
+      }
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 10;
+     */
+    public name.abuchen.portfolio.model.proto.v1.PLedgerParameter getParameters(int index) {
+      if (parametersBuilder_ == null) {
+        return parameters_.get(index);
+      } else {
+        return parametersBuilder_.getMessage(index);
+      }
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 10;
+     */
+    public Builder setParameters(
+        int index, name.abuchen.portfolio.model.proto.v1.PLedgerParameter value) {
+      if (parametersBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensureParametersIsMutable();
+        parameters_.set(index, value);
+        onChanged();
+      } else {
+        parametersBuilder_.setMessage(index, value);
+      }
+      return this;
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 10;
+     */
+    public Builder setParameters(
+        int index, name.abuchen.portfolio.model.proto.v1.PLedgerParameter.Builder builderForValue) {
+      if (parametersBuilder_ == null) {
+        ensureParametersIsMutable();
+        parameters_.set(index, builderForValue.build());
+        onChanged();
+      } else {
+        parametersBuilder_.setMessage(index, builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 10;
+     */
+    public Builder addParameters(name.abuchen.portfolio.model.proto.v1.PLedgerParameter value) {
+      if (parametersBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensureParametersIsMutable();
+        parameters_.add(value);
+        onChanged();
+      } else {
+        parametersBuilder_.addMessage(value);
+      }
+      return this;
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 10;
+     */
+    public Builder addParameters(
+        int index, name.abuchen.portfolio.model.proto.v1.PLedgerParameter value) {
+      if (parametersBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensureParametersIsMutable();
+        parameters_.add(index, value);
+        onChanged();
+      } else {
+        parametersBuilder_.addMessage(index, value);
+      }
+      return this;
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 10;
+     */
+    public Builder addParameters(
+        name.abuchen.portfolio.model.proto.v1.PLedgerParameter.Builder builderForValue) {
+      if (parametersBuilder_ == null) {
+        ensureParametersIsMutable();
+        parameters_.add(builderForValue.build());
+        onChanged();
+      } else {
+        parametersBuilder_.addMessage(builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 10;
+     */
+    public Builder addParameters(
+        int index, name.abuchen.portfolio.model.proto.v1.PLedgerParameter.Builder builderForValue) {
+      if (parametersBuilder_ == null) {
+        ensureParametersIsMutable();
+        parameters_.add(index, builderForValue.build());
+        onChanged();
+      } else {
+        parametersBuilder_.addMessage(index, builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 10;
+     */
+    public Builder addAllParameters(
+        java.lang.Iterable values) {
+      if (parametersBuilder_ == null) {
+        ensureParametersIsMutable();
+        com.google.protobuf.AbstractMessageLite.Builder.addAll(
+            values, parameters_);
+        onChanged();
+      } else {
+        parametersBuilder_.addAllMessages(values);
+      }
+      return this;
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 10;
+     */
+    public Builder clearParameters() {
+      if (parametersBuilder_ == null) {
+        parameters_ = java.util.Collections.emptyList();
+        bitField0_ = (bitField0_ & ~0x00000100);
+        onChanged();
+      } else {
+        parametersBuilder_.clear();
+      }
+      return this;
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 10;
+     */
+    public Builder removeParameters(int index) {
+      if (parametersBuilder_ == null) {
+        ensureParametersIsMutable();
+        parameters_.remove(index);
+        onChanged();
+      } else {
+        parametersBuilder_.remove(index);
+      }
+      return this;
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 10;
+     */
+    public name.abuchen.portfolio.model.proto.v1.PLedgerParameter.Builder getParametersBuilder(
+        int index) {
+      return getParametersFieldBuilder().getBuilder(index);
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 10;
+     */
+    public name.abuchen.portfolio.model.proto.v1.PLedgerParameterOrBuilder getParametersOrBuilder(
+        int index) {
+      if (parametersBuilder_ == null) {
+        return parameters_.get(index);  } else {
+        return parametersBuilder_.getMessageOrBuilder(index);
+      }
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 10;
+     */
+    public java.util.List
+         getParametersOrBuilderList() {
+      if (parametersBuilder_ != null) {
+        return parametersBuilder_.getMessageOrBuilderList();
+      } else {
+        return java.util.Collections.unmodifiableList(parameters_);
+      }
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 10;
+     */
+    public name.abuchen.portfolio.model.proto.v1.PLedgerParameter.Builder addParametersBuilder() {
+      return getParametersFieldBuilder().addBuilder(
+          name.abuchen.portfolio.model.proto.v1.PLedgerParameter.getDefaultInstance());
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 10;
+     */
+    public name.abuchen.portfolio.model.proto.v1.PLedgerParameter.Builder addParametersBuilder(
+        int index) {
+      return getParametersFieldBuilder().addBuilder(
+          index, name.abuchen.portfolio.model.proto.v1.PLedgerParameter.getDefaultInstance());
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 10;
+     */
+    public java.util.List
+         getParametersBuilderList() {
+      return getParametersFieldBuilder().getBuilderList();
+    }
+    private com.google.protobuf.RepeatedFieldBuilderV3<
+        name.abuchen.portfolio.model.proto.v1.PLedgerParameter, name.abuchen.portfolio.model.proto.v1.PLedgerParameter.Builder, name.abuchen.portfolio.model.proto.v1.PLedgerParameterOrBuilder>
+        getParametersFieldBuilder() {
+      if (parametersBuilder_ == null) {
+        parametersBuilder_ = new com.google.protobuf.RepeatedFieldBuilderV3<
+            name.abuchen.portfolio.model.proto.v1.PLedgerParameter, name.abuchen.portfolio.model.proto.v1.PLedgerParameter.Builder, name.abuchen.portfolio.model.proto.v1.PLedgerParameterOrBuilder>(
+                parameters_,
+                ((bitField0_ & 0x00000100) != 0),
+                getParentForChildren(),
+                isClean());
+        parameters_ = null;
+      }
+      return parametersBuilder_;
+    }
+    @java.lang.Override
+    public final Builder setUnknownFields(
+        final com.google.protobuf.UnknownFieldSet unknownFields) {
+      return super.setUnknownFields(unknownFields);
+    }
+
+    @java.lang.Override
+    public final Builder mergeUnknownFields(
+        final com.google.protobuf.UnknownFieldSet unknownFields) {
+      return super.mergeUnknownFields(unknownFields);
+    }
+
+
+    // @@protoc_insertion_point(builder_scope:name.abuchen.portfolio.PLedgerEntry)
+  }
+
+  // @@protoc_insertion_point(class_scope:name.abuchen.portfolio.PLedgerEntry)
+  private static final name.abuchen.portfolio.model.proto.v1.PLedgerEntry DEFAULT_INSTANCE;
+  static {
+    DEFAULT_INSTANCE = new name.abuchen.portfolio.model.proto.v1.PLedgerEntry();
+  }
+
+  public static name.abuchen.portfolio.model.proto.v1.PLedgerEntry getDefaultInstance() {
+    return DEFAULT_INSTANCE;
+  }
+
+  private static final com.google.protobuf.Parser
+      PARSER = new com.google.protobuf.AbstractParser() {
+    @java.lang.Override
+    public PLedgerEntry parsePartialFrom(
+        com.google.protobuf.CodedInputStream input,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws com.google.protobuf.InvalidProtocolBufferException {
+      Builder builder = newBuilder();
+      try {
+        builder.mergeFrom(input, extensionRegistry);
+      } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+        throw e.setUnfinishedMessage(builder.buildPartial());
+      } catch (com.google.protobuf.UninitializedMessageException e) {
+        throw e.asInvalidProtocolBufferException().setUnfinishedMessage(builder.buildPartial());
+      } catch (java.io.IOException e) {
+        throw new com.google.protobuf.InvalidProtocolBufferException(e)
+            .setUnfinishedMessage(builder.buildPartial());
+      }
+      return builder.buildPartial();
+    }
+  };
+
+  public static com.google.protobuf.Parser parser() {
+    return PARSER;
+  }
+
+  @java.lang.Override
+  public com.google.protobuf.Parser getParserForType() {
+    return PARSER;
+  }
+
+  @java.lang.Override
+  public name.abuchen.portfolio.model.proto.v1.PLedgerEntry getDefaultInstanceForType() {
+    return DEFAULT_INSTANCE;
+  }
+
+}
diff --git a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerEntryOrBuilder.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerEntryOrBuilder.java
new file mode 100644
index 0000000000..f2b95c856d
--- /dev/null
+++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerEntryOrBuilder.java
@@ -0,0 +1,168 @@
+// Generated by the protocol buffer compiler.  DO NOT EDIT!
+// source: client.proto
+
+package name.abuchen.portfolio.model.proto.v1;
+
+public interface PLedgerEntryOrBuilder extends
+    // @@protoc_insertion_point(interface_extends:name.abuchen.portfolio.PLedgerEntry)
+    com.google.protobuf.MessageOrBuilder {
+
+  /**
+   * string uuid = 1;
+   * @return The uuid.
+   */
+  java.lang.String getUuid();
+  /**
+   * string uuid = 1;
+   * @return The bytes for uuid.
+   */
+  com.google.protobuf.ByteString
+      getUuidBytes();
+
+  /**
+   * .google.protobuf.Timestamp dateTime = 3;
+   * @return Whether the dateTime field is set.
+   */
+  boolean hasDateTime();
+  /**
+   * .google.protobuf.Timestamp dateTime = 3;
+   * @return The dateTime.
+   */
+  com.google.protobuf.Timestamp getDateTime();
+  /**
+   * .google.protobuf.Timestamp dateTime = 3;
+   */
+  com.google.protobuf.TimestampOrBuilder getDateTimeOrBuilder();
+
+  /**
+   * optional string note = 4;
+   * @return Whether the note field is set.
+   */
+  boolean hasNote();
+  /**
+   * optional string note = 4;
+   * @return The note.
+   */
+  java.lang.String getNote();
+  /**
+   * optional string note = 4;
+   * @return The bytes for note.
+   */
+  com.google.protobuf.ByteString
+      getNoteBytes();
+
+  /**
+   * optional string source = 5;
+   * @return Whether the source field is set.
+   */
+  boolean hasSource();
+  /**
+   * optional string source = 5;
+   * @return The source.
+   */
+  java.lang.String getSource();
+  /**
+   * optional string source = 5;
+   * @return The bytes for source.
+   */
+  com.google.protobuf.ByteString
+      getSourceBytes();
+
+  /**
+   * .google.protobuf.Timestamp updatedAt = 6;
+   * @return Whether the updatedAt field is set.
+   */
+  boolean hasUpdatedAt();
+  /**
+   * .google.protobuf.Timestamp updatedAt = 6;
+   * @return The updatedAt.
+   */
+  com.google.protobuf.Timestamp getUpdatedAt();
+  /**
+   * .google.protobuf.Timestamp updatedAt = 6;
+   */
+  com.google.protobuf.TimestampOrBuilder getUpdatedAtOrBuilder();
+
+  /**
+   * repeated .name.abuchen.portfolio.PLedgerPosting postings = 7;
+   */
+  java.util.List
+      getPostingsList();
+  /**
+   * repeated .name.abuchen.portfolio.PLedgerPosting postings = 7;
+   */
+  name.abuchen.portfolio.model.proto.v1.PLedgerPosting getPostings(int index);
+  /**
+   * repeated .name.abuchen.portfolio.PLedgerPosting postings = 7;
+   */
+  int getPostingsCount();
+  /**
+   * repeated .name.abuchen.portfolio.PLedgerPosting postings = 7;
+   */
+  java.util.List
+      getPostingsOrBuilderList();
+  /**
+   * repeated .name.abuchen.portfolio.PLedgerPosting postings = 7;
+   */
+  name.abuchen.portfolio.model.proto.v1.PLedgerPostingOrBuilder getPostingsOrBuilder(
+      int index);
+
+  /**
+   * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8;
+   */
+  java.util.List
+      getProjectionRefsList();
+  /**
+   * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8;
+   */
+  name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef getProjectionRefs(int index);
+  /**
+   * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8;
+   */
+  int getProjectionRefsCount();
+  /**
+   * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8;
+   */
+  java.util.List
+      getProjectionRefsOrBuilderList();
+  /**
+   * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8;
+   */
+  name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRefOrBuilder getProjectionRefsOrBuilder(
+      int index);
+
+  /**
+   * optional uint32 typeId = 9;
+   * @return Whether the typeId field is set.
+   */
+  boolean hasTypeId();
+  /**
+   * optional uint32 typeId = 9;
+   * @return The typeId.
+   */
+  int getTypeId();
+
+  /**
+   * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 10;
+   */
+  java.util.List
+      getParametersList();
+  /**
+   * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 10;
+   */
+  name.abuchen.portfolio.model.proto.v1.PLedgerParameter getParameters(int index);
+  /**
+   * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 10;
+   */
+  int getParametersCount();
+  /**
+   * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 10;
+   */
+  java.util.List
+      getParametersOrBuilderList();
+  /**
+   * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 10;
+   */
+  name.abuchen.portfolio.model.proto.v1.PLedgerParameterOrBuilder getParametersOrBuilder(
+      int index);
+}
diff --git a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerOrBuilder.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerOrBuilder.java
new file mode 100644
index 0000000000..d29892f280
--- /dev/null
+++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerOrBuilder.java
@@ -0,0 +1,33 @@
+// Generated by the protocol buffer compiler.  DO NOT EDIT!
+// source: client.proto
+
+package name.abuchen.portfolio.model.proto.v1;
+
+public interface PLedgerOrBuilder extends
+    // @@protoc_insertion_point(interface_extends:name.abuchen.portfolio.PLedger)
+    com.google.protobuf.MessageOrBuilder {
+
+  /**
+   * repeated .name.abuchen.portfolio.PLedgerEntry entries = 1;
+   */
+  java.util.List 
+      getEntriesList();
+  /**
+   * repeated .name.abuchen.portfolio.PLedgerEntry entries = 1;
+   */
+  name.abuchen.portfolio.model.proto.v1.PLedgerEntry getEntries(int index);
+  /**
+   * repeated .name.abuchen.portfolio.PLedgerEntry entries = 1;
+   */
+  int getEntriesCount();
+  /**
+   * repeated .name.abuchen.portfolio.PLedgerEntry entries = 1;
+   */
+  java.util.List 
+      getEntriesOrBuilderList();
+  /**
+   * repeated .name.abuchen.portfolio.PLedgerEntry entries = 1;
+   */
+  name.abuchen.portfolio.model.proto.v1.PLedgerEntryOrBuilder getEntriesOrBuilder(
+      int index);
+}
diff --git a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerParameter.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerParameter.java
new file mode 100644
index 0000000000..610e66baac
--- /dev/null
+++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerParameter.java
@@ -0,0 +1,2160 @@
+// Generated by the protocol buffer compiler.  DO NOT EDIT!
+// source: client.proto
+
+package name.abuchen.portfolio.model.proto.v1;
+
+/**
+ * Protobuf type {@code name.abuchen.portfolio.PLedgerParameter}
+ */
+public final class PLedgerParameter extends
+    com.google.protobuf.GeneratedMessageV3 implements
+    // @@protoc_insertion_point(message_implements:name.abuchen.portfolio.PLedgerParameter)
+    PLedgerParameterOrBuilder {
+private static final long serialVersionUID = 0L;
+  // Use PLedgerParameter.newBuilder() to construct.
+  private PLedgerParameter(com.google.protobuf.GeneratedMessageV3.Builder builder) {
+    super(builder);
+  }
+  private PLedgerParameter() {
+    valueKind_ = 0;
+    stringValue_ = "";
+    moneyCurrency_ = "";
+    security_ = "";
+    account_ = "";
+    portfolio_ = "";
+    typeCode_ = "";
+  }
+
+  @java.lang.Override
+  @SuppressWarnings({"unused"})
+  protected java.lang.Object newInstance(
+      UnusedPrivateParameter unused) {
+    return new PLedgerParameter();
+  }
+
+  public static final com.google.protobuf.Descriptors.Descriptor
+      getDescriptor() {
+    return name.abuchen.portfolio.model.proto.v1.ClientProtos.internal_static_name_abuchen_portfolio_PLedgerParameter_descriptor;
+  }
+
+  @java.lang.Override
+  protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable
+      internalGetFieldAccessorTable() {
+    return name.abuchen.portfolio.model.proto.v1.ClientProtos.internal_static_name_abuchen_portfolio_PLedgerParameter_fieldAccessorTable
+        .ensureFieldAccessorsInitialized(
+            name.abuchen.portfolio.model.proto.v1.PLedgerParameter.class, name.abuchen.portfolio.model.proto.v1.PLedgerParameter.Builder.class);
+  }
+
+  private int bitField0_;
+  public static final int VALUEKIND_FIELD_NUMBER = 2;
+  private int valueKind_ = 0;
+  /**
+   * .name.abuchen.portfolio.PLedgerParameterValueKind valueKind = 2;
+   * @return The enum numeric value on the wire for valueKind.
+   */
+  @java.lang.Override public int getValueKindValue() {
+    return valueKind_;
+  }
+  /**
+   * .name.abuchen.portfolio.PLedgerParameterValueKind valueKind = 2;
+   * @return The valueKind.
+   */
+  @java.lang.Override public name.abuchen.portfolio.model.proto.v1.PLedgerParameterValueKind getValueKind() {
+    name.abuchen.portfolio.model.proto.v1.PLedgerParameterValueKind result = name.abuchen.portfolio.model.proto.v1.PLedgerParameterValueKind.forNumber(valueKind_);
+    return result == null ? name.abuchen.portfolio.model.proto.v1.PLedgerParameterValueKind.UNRECOGNIZED : result;
+  }
+
+  public static final int STRINGVALUE_FIELD_NUMBER = 3;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object stringValue_ = "";
+  /**
+   * optional string stringValue = 3;
+   * @return Whether the stringValue field is set.
+   */
+  @java.lang.Override
+  public boolean hasStringValue() {
+    return ((bitField0_ & 0x00000001) != 0);
+  }
+  /**
+   * optional string stringValue = 3;
+   * @return The stringValue.
+   */
+  @java.lang.Override
+  public java.lang.String getStringValue() {
+    java.lang.Object ref = stringValue_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs =
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      stringValue_ = s;
+      return s;
+    }
+  }
+  /**
+   * optional string stringValue = 3;
+   * @return The bytes for stringValue.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getStringValueBytes() {
+    java.lang.Object ref = stringValue_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b =
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      stringValue_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int DECIMALVALUE_FIELD_NUMBER = 4;
+  private name.abuchen.portfolio.model.proto.v1.PDecimalValue decimalValue_;
+  /**
+   * optional .name.abuchen.portfolio.PDecimalValue decimalValue = 4;
+   * @return Whether the decimalValue field is set.
+   */
+  @java.lang.Override
+  public boolean hasDecimalValue() {
+    return ((bitField0_ & 0x00000002) != 0);
+  }
+  /**
+   * optional .name.abuchen.portfolio.PDecimalValue decimalValue = 4;
+   * @return The decimalValue.
+   */
+  @java.lang.Override
+  public name.abuchen.portfolio.model.proto.v1.PDecimalValue getDecimalValue() {
+    return decimalValue_ == null ? name.abuchen.portfolio.model.proto.v1.PDecimalValue.getDefaultInstance() : decimalValue_;
+  }
+  /**
+   * optional .name.abuchen.portfolio.PDecimalValue decimalValue = 4;
+   */
+  @java.lang.Override
+  public name.abuchen.portfolio.model.proto.v1.PDecimalValueOrBuilder getDecimalValueOrBuilder() {
+    return decimalValue_ == null ? name.abuchen.portfolio.model.proto.v1.PDecimalValue.getDefaultInstance() : decimalValue_;
+  }
+
+  public static final int LONGVALUE_FIELD_NUMBER = 5;
+  private long longValue_ = 0L;
+  /**
+   * optional int64 longValue = 5;
+   * @return Whether the longValue field is set.
+   */
+  @java.lang.Override
+  public boolean hasLongValue() {
+    return ((bitField0_ & 0x00000004) != 0);
+  }
+  /**
+   * optional int64 longValue = 5;
+   * @return The longValue.
+   */
+  @java.lang.Override
+  public long getLongValue() {
+    return longValue_;
+  }
+
+  public static final int MONEYAMOUNT_FIELD_NUMBER = 6;
+  private long moneyAmount_ = 0L;
+  /**
+   * optional int64 moneyAmount = 6;
+   * @return Whether the moneyAmount field is set.
+   */
+  @java.lang.Override
+  public boolean hasMoneyAmount() {
+    return ((bitField0_ & 0x00000008) != 0);
+  }
+  /**
+   * optional int64 moneyAmount = 6;
+   * @return The moneyAmount.
+   */
+  @java.lang.Override
+  public long getMoneyAmount() {
+    return moneyAmount_;
+  }
+
+  public static final int MONEYCURRENCY_FIELD_NUMBER = 7;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object moneyCurrency_ = "";
+  /**
+   * optional string moneyCurrency = 7;
+   * @return Whether the moneyCurrency field is set.
+   */
+  @java.lang.Override
+  public boolean hasMoneyCurrency() {
+    return ((bitField0_ & 0x00000010) != 0);
+  }
+  /**
+   * optional string moneyCurrency = 7;
+   * @return The moneyCurrency.
+   */
+  @java.lang.Override
+  public java.lang.String getMoneyCurrency() {
+    java.lang.Object ref = moneyCurrency_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs =
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      moneyCurrency_ = s;
+      return s;
+    }
+  }
+  /**
+   * optional string moneyCurrency = 7;
+   * @return The bytes for moneyCurrency.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getMoneyCurrencyBytes() {
+    java.lang.Object ref = moneyCurrency_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b =
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      moneyCurrency_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int SECURITY_FIELD_NUMBER = 8;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object security_ = "";
+  /**
+   * optional string security = 8;
+   * @return Whether the security field is set.
+   */
+  @java.lang.Override
+  public boolean hasSecurity() {
+    return ((bitField0_ & 0x00000020) != 0);
+  }
+  /**
+   * optional string security = 8;
+   * @return The security.
+   */
+  @java.lang.Override
+  public java.lang.String getSecurity() {
+    java.lang.Object ref = security_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs =
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      security_ = s;
+      return s;
+    }
+  }
+  /**
+   * optional string security = 8;
+   * @return The bytes for security.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getSecurityBytes() {
+    java.lang.Object ref = security_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b =
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      security_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int ACCOUNT_FIELD_NUMBER = 9;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object account_ = "";
+  /**
+   * optional string account = 9;
+   * @return Whether the account field is set.
+   */
+  @java.lang.Override
+  public boolean hasAccount() {
+    return ((bitField0_ & 0x00000040) != 0);
+  }
+  /**
+   * optional string account = 9;
+   * @return The account.
+   */
+  @java.lang.Override
+  public java.lang.String getAccount() {
+    java.lang.Object ref = account_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs =
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      account_ = s;
+      return s;
+    }
+  }
+  /**
+   * optional string account = 9;
+   * @return The bytes for account.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getAccountBytes() {
+    java.lang.Object ref = account_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b =
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      account_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int PORTFOLIO_FIELD_NUMBER = 10;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object portfolio_ = "";
+  /**
+   * optional string portfolio = 10;
+   * @return Whether the portfolio field is set.
+   */
+  @java.lang.Override
+  public boolean hasPortfolio() {
+    return ((bitField0_ & 0x00000080) != 0);
+  }
+  /**
+   * optional string portfolio = 10;
+   * @return The portfolio.
+   */
+  @java.lang.Override
+  public java.lang.String getPortfolio() {
+    java.lang.Object ref = portfolio_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs =
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      portfolio_ = s;
+      return s;
+    }
+  }
+  /**
+   * optional string portfolio = 10;
+   * @return The bytes for portfolio.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getPortfolioBytes() {
+    java.lang.Object ref = portfolio_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b =
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      portfolio_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int LOCALDATETIMEVALUE_FIELD_NUMBER = 12;
+  private name.abuchen.portfolio.model.proto.v1.PLocalDateTime localDateTimeValue_;
+  /**
+   * optional .name.abuchen.portfolio.PLocalDateTime localDateTimeValue = 12;
+   * @return Whether the localDateTimeValue field is set.
+   */
+  @java.lang.Override
+  public boolean hasLocalDateTimeValue() {
+    return ((bitField0_ & 0x00000100) != 0);
+  }
+  /**
+   * optional .name.abuchen.portfolio.PLocalDateTime localDateTimeValue = 12;
+   * @return The localDateTimeValue.
+   */
+  @java.lang.Override
+  public name.abuchen.portfolio.model.proto.v1.PLocalDateTime getLocalDateTimeValue() {
+    return localDateTimeValue_ == null ? name.abuchen.portfolio.model.proto.v1.PLocalDateTime.getDefaultInstance() : localDateTimeValue_;
+  }
+  /**
+   * optional .name.abuchen.portfolio.PLocalDateTime localDateTimeValue = 12;
+   */
+  @java.lang.Override
+  public name.abuchen.portfolio.model.proto.v1.PLocalDateTimeOrBuilder getLocalDateTimeValueOrBuilder() {
+    return localDateTimeValue_ == null ? name.abuchen.portfolio.model.proto.v1.PLocalDateTime.getDefaultInstance() : localDateTimeValue_;
+  }
+
+  public static final int TYPECODE_FIELD_NUMBER = 13;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object typeCode_ = "";
+  /**
+   * optional string typeCode = 13;
+   * @return Whether the typeCode field is set.
+   */
+  @java.lang.Override
+  public boolean hasTypeCode() {
+    return ((bitField0_ & 0x00000200) != 0);
+  }
+  /**
+   * optional string typeCode = 13;
+   * @return The typeCode.
+   */
+  @java.lang.Override
+  public java.lang.String getTypeCode() {
+    java.lang.Object ref = typeCode_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs =
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      typeCode_ = s;
+      return s;
+    }
+  }
+  /**
+   * optional string typeCode = 13;
+   * @return The bytes for typeCode.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getTypeCodeBytes() {
+    java.lang.Object ref = typeCode_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b =
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      typeCode_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int BOOLEANVALUE_FIELD_NUMBER = 14;
+  private boolean booleanValue_ = false;
+  /**
+   * optional bool booleanValue = 14;
+   * @return Whether the booleanValue field is set.
+   */
+  @java.lang.Override
+  public boolean hasBooleanValue() {
+    return ((bitField0_ & 0x00000400) != 0);
+  }
+  /**
+   * optional bool booleanValue = 14;
+   * @return The booleanValue.
+   */
+  @java.lang.Override
+  public boolean getBooleanValue() {
+    return booleanValue_;
+  }
+
+  public static final int LOCALDATEVALUE_FIELD_NUMBER = 15;
+  private long localDateValue_ = 0L;
+  /**
+   * 
+   * epoch day, based on the epoch 1970-01-01
+   * 
+ * + * optional int64 localDateValue = 15; + * @return Whether the localDateValue field is set. + */ + @java.lang.Override + public boolean hasLocalDateValue() { + return ((bitField0_ & 0x00000800) != 0); + } + /** + *
+   * epoch day, based on the epoch 1970-01-01
+   * 
+ * + * optional int64 localDateValue = 15; + * @return The localDateValue. + */ + @java.lang.Override + public long getLocalDateValue() { + return localDateValue_; + } + + private byte memoizedIsInitialized = -1; + @java.lang.Override + public final boolean isInitialized() { + byte isInitialized = memoizedIsInitialized; + if (isInitialized == 1) return true; + if (isInitialized == 0) return false; + + memoizedIsInitialized = 1; + return true; + } + + @java.lang.Override + public void writeTo(com.google.protobuf.CodedOutputStream output) + throws java.io.IOException { + if (valueKind_ != name.abuchen.portfolio.model.proto.v1.PLedgerParameterValueKind.LEDGER_PARAMETER_VALUE_KIND_UNSPECIFIED.getNumber()) { + output.writeEnum(2, valueKind_); + } + if (((bitField0_ & 0x00000001) != 0)) { + com.google.protobuf.GeneratedMessageV3.writeString(output, 3, stringValue_); + } + if (((bitField0_ & 0x00000002) != 0)) { + output.writeMessage(4, getDecimalValue()); + } + if (((bitField0_ & 0x00000004) != 0)) { + output.writeInt64(5, longValue_); + } + if (((bitField0_ & 0x00000008) != 0)) { + output.writeInt64(6, moneyAmount_); + } + if (((bitField0_ & 0x00000010) != 0)) { + com.google.protobuf.GeneratedMessageV3.writeString(output, 7, moneyCurrency_); + } + if (((bitField0_ & 0x00000020) != 0)) { + com.google.protobuf.GeneratedMessageV3.writeString(output, 8, security_); + } + if (((bitField0_ & 0x00000040) != 0)) { + com.google.protobuf.GeneratedMessageV3.writeString(output, 9, account_); + } + if (((bitField0_ & 0x00000080) != 0)) { + com.google.protobuf.GeneratedMessageV3.writeString(output, 10, portfolio_); + } + if (((bitField0_ & 0x00000100) != 0)) { + output.writeMessage(12, getLocalDateTimeValue()); + } + if (((bitField0_ & 0x00000200) != 0)) { + com.google.protobuf.GeneratedMessageV3.writeString(output, 13, typeCode_); + } + if (((bitField0_ & 0x00000400) != 0)) { + output.writeBool(14, booleanValue_); + } + if (((bitField0_ & 0x00000800) != 0)) { + output.writeInt64(15, localDateValue_); + } + getUnknownFields().writeTo(output); + } + + @java.lang.Override + public int getSerializedSize() { + int size = memoizedSize; + if (size != -1) return size; + + size = 0; + if (valueKind_ != name.abuchen.portfolio.model.proto.v1.PLedgerParameterValueKind.LEDGER_PARAMETER_VALUE_KIND_UNSPECIFIED.getNumber()) { + size += com.google.protobuf.CodedOutputStream + .computeEnumSize(2, valueKind_); + } + if (((bitField0_ & 0x00000001) != 0)) { + size += com.google.protobuf.GeneratedMessageV3.computeStringSize(3, stringValue_); + } + if (((bitField0_ & 0x00000002) != 0)) { + size += com.google.protobuf.CodedOutputStream + .computeMessageSize(4, getDecimalValue()); + } + if (((bitField0_ & 0x00000004) != 0)) { + size += com.google.protobuf.CodedOutputStream + .computeInt64Size(5, longValue_); + } + if (((bitField0_ & 0x00000008) != 0)) { + size += com.google.protobuf.CodedOutputStream + .computeInt64Size(6, moneyAmount_); + } + if (((bitField0_ & 0x00000010) != 0)) { + size += com.google.protobuf.GeneratedMessageV3.computeStringSize(7, moneyCurrency_); + } + if (((bitField0_ & 0x00000020) != 0)) { + size += com.google.protobuf.GeneratedMessageV3.computeStringSize(8, security_); + } + if (((bitField0_ & 0x00000040) != 0)) { + size += com.google.protobuf.GeneratedMessageV3.computeStringSize(9, account_); + } + if (((bitField0_ & 0x00000080) != 0)) { + size += com.google.protobuf.GeneratedMessageV3.computeStringSize(10, portfolio_); + } + if (((bitField0_ & 0x00000100) != 0)) { + size += com.google.protobuf.CodedOutputStream + .computeMessageSize(12, getLocalDateTimeValue()); + } + if (((bitField0_ & 0x00000200) != 0)) { + size += com.google.protobuf.GeneratedMessageV3.computeStringSize(13, typeCode_); + } + if (((bitField0_ & 0x00000400) != 0)) { + size += com.google.protobuf.CodedOutputStream + .computeBoolSize(14, booleanValue_); + } + if (((bitField0_ & 0x00000800) != 0)) { + size += com.google.protobuf.CodedOutputStream + .computeInt64Size(15, localDateValue_); + } + size += getUnknownFields().getSerializedSize(); + memoizedSize = size; + return size; + } + + @java.lang.Override + public boolean equals(final java.lang.Object obj) { + if (obj == this) { + return true; + } + if (!(obj instanceof name.abuchen.portfolio.model.proto.v1.PLedgerParameter)) { + return super.equals(obj); + } + name.abuchen.portfolio.model.proto.v1.PLedgerParameter other = (name.abuchen.portfolio.model.proto.v1.PLedgerParameter) obj; + + if (valueKind_ != other.valueKind_) return false; + if (hasStringValue() != other.hasStringValue()) return false; + if (hasStringValue()) { + if (!getStringValue() + .equals(other.getStringValue())) return false; + } + if (hasDecimalValue() != other.hasDecimalValue()) return false; + if (hasDecimalValue()) { + if (!getDecimalValue() + .equals(other.getDecimalValue())) return false; + } + if (hasLongValue() != other.hasLongValue()) return false; + if (hasLongValue()) { + if (getLongValue() + != other.getLongValue()) return false; + } + if (hasMoneyAmount() != other.hasMoneyAmount()) return false; + if (hasMoneyAmount()) { + if (getMoneyAmount() + != other.getMoneyAmount()) return false; + } + if (hasMoneyCurrency() != other.hasMoneyCurrency()) return false; + if (hasMoneyCurrency()) { + if (!getMoneyCurrency() + .equals(other.getMoneyCurrency())) return false; + } + if (hasSecurity() != other.hasSecurity()) return false; + if (hasSecurity()) { + if (!getSecurity() + .equals(other.getSecurity())) return false; + } + if (hasAccount() != other.hasAccount()) return false; + if (hasAccount()) { + if (!getAccount() + .equals(other.getAccount())) return false; + } + if (hasPortfolio() != other.hasPortfolio()) return false; + if (hasPortfolio()) { + if (!getPortfolio() + .equals(other.getPortfolio())) return false; + } + if (hasLocalDateTimeValue() != other.hasLocalDateTimeValue()) return false; + if (hasLocalDateTimeValue()) { + if (!getLocalDateTimeValue() + .equals(other.getLocalDateTimeValue())) return false; + } + if (hasTypeCode() != other.hasTypeCode()) return false; + if (hasTypeCode()) { + if (!getTypeCode() + .equals(other.getTypeCode())) return false; + } + if (hasBooleanValue() != other.hasBooleanValue()) return false; + if (hasBooleanValue()) { + if (getBooleanValue() + != other.getBooleanValue()) return false; + } + if (hasLocalDateValue() != other.hasLocalDateValue()) return false; + if (hasLocalDateValue()) { + if (getLocalDateValue() + != other.getLocalDateValue()) return false; + } + if (!getUnknownFields().equals(other.getUnknownFields())) return false; + return true; + } + + @java.lang.Override + public int hashCode() { + if (memoizedHashCode != 0) { + return memoizedHashCode; + } + int hash = 41; + hash = (19 * hash) + getDescriptor().hashCode(); + hash = (37 * hash) + VALUEKIND_FIELD_NUMBER; + hash = (53 * hash) + valueKind_; + if (hasStringValue()) { + hash = (37 * hash) + STRINGVALUE_FIELD_NUMBER; + hash = (53 * hash) + getStringValue().hashCode(); + } + if (hasDecimalValue()) { + hash = (37 * hash) + DECIMALVALUE_FIELD_NUMBER; + hash = (53 * hash) + getDecimalValue().hashCode(); + } + if (hasLongValue()) { + hash = (37 * hash) + LONGVALUE_FIELD_NUMBER; + hash = (53 * hash) + com.google.protobuf.Internal.hashLong( + getLongValue()); + } + if (hasMoneyAmount()) { + hash = (37 * hash) + MONEYAMOUNT_FIELD_NUMBER; + hash = (53 * hash) + com.google.protobuf.Internal.hashLong( + getMoneyAmount()); + } + if (hasMoneyCurrency()) { + hash = (37 * hash) + MONEYCURRENCY_FIELD_NUMBER; + hash = (53 * hash) + getMoneyCurrency().hashCode(); + } + if (hasSecurity()) { + hash = (37 * hash) + SECURITY_FIELD_NUMBER; + hash = (53 * hash) + getSecurity().hashCode(); + } + if (hasAccount()) { + hash = (37 * hash) + ACCOUNT_FIELD_NUMBER; + hash = (53 * hash) + getAccount().hashCode(); + } + if (hasPortfolio()) { + hash = (37 * hash) + PORTFOLIO_FIELD_NUMBER; + hash = (53 * hash) + getPortfolio().hashCode(); + } + if (hasLocalDateTimeValue()) { + hash = (37 * hash) + LOCALDATETIMEVALUE_FIELD_NUMBER; + hash = (53 * hash) + getLocalDateTimeValue().hashCode(); + } + if (hasTypeCode()) { + hash = (37 * hash) + TYPECODE_FIELD_NUMBER; + hash = (53 * hash) + getTypeCode().hashCode(); + } + if (hasBooleanValue()) { + hash = (37 * hash) + BOOLEANVALUE_FIELD_NUMBER; + hash = (53 * hash) + com.google.protobuf.Internal.hashBoolean( + getBooleanValue()); + } + if (hasLocalDateValue()) { + hash = (37 * hash) + LOCALDATEVALUE_FIELD_NUMBER; + hash = (53 * hash) + com.google.protobuf.Internal.hashLong( + getLocalDateValue()); + } + hash = (29 * hash) + getUnknownFields().hashCode(); + memoizedHashCode = hash; + return hash; + } + + public static name.abuchen.portfolio.model.proto.v1.PLedgerParameter parseFrom( + java.nio.ByteBuffer data) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data); + } + public static name.abuchen.portfolio.model.proto.v1.PLedgerParameter parseFrom( + java.nio.ByteBuffer data, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data, extensionRegistry); + } + public static name.abuchen.portfolio.model.proto.v1.PLedgerParameter parseFrom( + com.google.protobuf.ByteString data) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data); + } + public static name.abuchen.portfolio.model.proto.v1.PLedgerParameter parseFrom( + com.google.protobuf.ByteString data, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data, extensionRegistry); + } + public static name.abuchen.portfolio.model.proto.v1.PLedgerParameter parseFrom(byte[] data) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data); + } + public static name.abuchen.portfolio.model.proto.v1.PLedgerParameter parseFrom( + byte[] data, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data, extensionRegistry); + } + public static name.abuchen.portfolio.model.proto.v1.PLedgerParameter parseFrom(java.io.InputStream input) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageV3 + .parseWithIOException(PARSER, input); + } + public static name.abuchen.portfolio.model.proto.v1.PLedgerParameter parseFrom( + java.io.InputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageV3 + .parseWithIOException(PARSER, input, extensionRegistry); + } + public static name.abuchen.portfolio.model.proto.v1.PLedgerParameter parseDelimitedFrom(java.io.InputStream input) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageV3 + .parseDelimitedWithIOException(PARSER, input); + } + public static name.abuchen.portfolio.model.proto.v1.PLedgerParameter parseDelimitedFrom( + java.io.InputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageV3 + .parseDelimitedWithIOException(PARSER, input, extensionRegistry); + } + public static name.abuchen.portfolio.model.proto.v1.PLedgerParameter parseFrom( + com.google.protobuf.CodedInputStream input) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageV3 + .parseWithIOException(PARSER, input); + } + public static name.abuchen.portfolio.model.proto.v1.PLedgerParameter parseFrom( + com.google.protobuf.CodedInputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageV3 + .parseWithIOException(PARSER, input, extensionRegistry); + } + + @java.lang.Override + public Builder newBuilderForType() { return newBuilder(); } + public static Builder newBuilder() { + return DEFAULT_INSTANCE.toBuilder(); + } + public static Builder newBuilder(name.abuchen.portfolio.model.proto.v1.PLedgerParameter prototype) { + return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype); + } + @java.lang.Override + public Builder toBuilder() { + return this == DEFAULT_INSTANCE + ? new Builder() : new Builder().mergeFrom(this); + } + + @java.lang.Override + protected Builder newBuilderForType( + com.google.protobuf.GeneratedMessageV3.BuilderParent parent) { + Builder builder = new Builder(parent); + return builder; + } + /** + * Protobuf type {@code name.abuchen.portfolio.PLedgerParameter} + */ + public static final class Builder extends + com.google.protobuf.GeneratedMessageV3.Builder implements + // @@protoc_insertion_point(builder_implements:name.abuchen.portfolio.PLedgerParameter) + name.abuchen.portfolio.model.proto.v1.PLedgerParameterOrBuilder { + public static final com.google.protobuf.Descriptors.Descriptor + getDescriptor() { + return name.abuchen.portfolio.model.proto.v1.ClientProtos.internal_static_name_abuchen_portfolio_PLedgerParameter_descriptor; + } + + @java.lang.Override + protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable + internalGetFieldAccessorTable() { + return name.abuchen.portfolio.model.proto.v1.ClientProtos.internal_static_name_abuchen_portfolio_PLedgerParameter_fieldAccessorTable + .ensureFieldAccessorsInitialized( + name.abuchen.portfolio.model.proto.v1.PLedgerParameter.class, name.abuchen.portfolio.model.proto.v1.PLedgerParameter.Builder.class); + } + + // Construct using name.abuchen.portfolio.model.proto.v1.PLedgerParameter.newBuilder() + private Builder() { + maybeForceBuilderInitialization(); + } + + private Builder( + com.google.protobuf.GeneratedMessageV3.BuilderParent parent) { + super(parent); + maybeForceBuilderInitialization(); + } + private void maybeForceBuilderInitialization() { + if (com.google.protobuf.GeneratedMessageV3 + .alwaysUseFieldBuilders) { + getDecimalValueFieldBuilder(); + getLocalDateTimeValueFieldBuilder(); + } + } + @java.lang.Override + public Builder clear() { + super.clear(); + bitField0_ = 0; + valueKind_ = 0; + stringValue_ = ""; + decimalValue_ = null; + if (decimalValueBuilder_ != null) { + decimalValueBuilder_.dispose(); + decimalValueBuilder_ = null; + } + longValue_ = 0L; + moneyAmount_ = 0L; + moneyCurrency_ = ""; + security_ = ""; + account_ = ""; + portfolio_ = ""; + localDateTimeValue_ = null; + if (localDateTimeValueBuilder_ != null) { + localDateTimeValueBuilder_.dispose(); + localDateTimeValueBuilder_ = null; + } + typeCode_ = ""; + booleanValue_ = false; + localDateValue_ = 0L; + return this; + } + + @java.lang.Override + public com.google.protobuf.Descriptors.Descriptor + getDescriptorForType() { + return name.abuchen.portfolio.model.proto.v1.ClientProtos.internal_static_name_abuchen_portfolio_PLedgerParameter_descriptor; + } + + @java.lang.Override + public name.abuchen.portfolio.model.proto.v1.PLedgerParameter getDefaultInstanceForType() { + return name.abuchen.portfolio.model.proto.v1.PLedgerParameter.getDefaultInstance(); + } + + @java.lang.Override + public name.abuchen.portfolio.model.proto.v1.PLedgerParameter build() { + name.abuchen.portfolio.model.proto.v1.PLedgerParameter result = buildPartial(); + if (!result.isInitialized()) { + throw newUninitializedMessageException(result); + } + return result; + } + + @java.lang.Override + public name.abuchen.portfolio.model.proto.v1.PLedgerParameter buildPartial() { + name.abuchen.portfolio.model.proto.v1.PLedgerParameter result = new name.abuchen.portfolio.model.proto.v1.PLedgerParameter(this); + if (bitField0_ != 0) { buildPartial0(result); } + onBuilt(); + return result; + } + + private void buildPartial0(name.abuchen.portfolio.model.proto.v1.PLedgerParameter result) { + int from_bitField0_ = bitField0_; + if (((from_bitField0_ & 0x00000001) != 0)) { + result.valueKind_ = valueKind_; + } + int to_bitField0_ = 0; + if (((from_bitField0_ & 0x00000002) != 0)) { + result.stringValue_ = stringValue_; + to_bitField0_ |= 0x00000001; + } + if (((from_bitField0_ & 0x00000004) != 0)) { + result.decimalValue_ = decimalValueBuilder_ == null + ? decimalValue_ + : decimalValueBuilder_.build(); + to_bitField0_ |= 0x00000002; + } + if (((from_bitField0_ & 0x00000008) != 0)) { + result.longValue_ = longValue_; + to_bitField0_ |= 0x00000004; + } + if (((from_bitField0_ & 0x00000010) != 0)) { + result.moneyAmount_ = moneyAmount_; + to_bitField0_ |= 0x00000008; + } + if (((from_bitField0_ & 0x00000020) != 0)) { + result.moneyCurrency_ = moneyCurrency_; + to_bitField0_ |= 0x00000010; + } + if (((from_bitField0_ & 0x00000040) != 0)) { + result.security_ = security_; + to_bitField0_ |= 0x00000020; + } + if (((from_bitField0_ & 0x00000080) != 0)) { + result.account_ = account_; + to_bitField0_ |= 0x00000040; + } + if (((from_bitField0_ & 0x00000100) != 0)) { + result.portfolio_ = portfolio_; + to_bitField0_ |= 0x00000080; + } + if (((from_bitField0_ & 0x00000200) != 0)) { + result.localDateTimeValue_ = localDateTimeValueBuilder_ == null + ? localDateTimeValue_ + : localDateTimeValueBuilder_.build(); + to_bitField0_ |= 0x00000100; + } + if (((from_bitField0_ & 0x00000400) != 0)) { + result.typeCode_ = typeCode_; + to_bitField0_ |= 0x00000200; + } + if (((from_bitField0_ & 0x00000800) != 0)) { + result.booleanValue_ = booleanValue_; + to_bitField0_ |= 0x00000400; + } + if (((from_bitField0_ & 0x00001000) != 0)) { + result.localDateValue_ = localDateValue_; + to_bitField0_ |= 0x00000800; + } + result.bitField0_ |= to_bitField0_; + } + + @java.lang.Override + public Builder mergeFrom(com.google.protobuf.Message other) { + if (other instanceof name.abuchen.portfolio.model.proto.v1.PLedgerParameter) { + return mergeFrom((name.abuchen.portfolio.model.proto.v1.PLedgerParameter)other); + } else { + super.mergeFrom(other); + return this; + } + } + + public Builder mergeFrom(name.abuchen.portfolio.model.proto.v1.PLedgerParameter other) { + if (other == name.abuchen.portfolio.model.proto.v1.PLedgerParameter.getDefaultInstance()) return this; + if (other.valueKind_ != 0) { + setValueKindValue(other.getValueKindValue()); + } + if (other.hasStringValue()) { + stringValue_ = other.stringValue_; + bitField0_ |= 0x00000002; + onChanged(); + } + if (other.hasDecimalValue()) { + mergeDecimalValue(other.getDecimalValue()); + } + if (other.hasLongValue()) { + setLongValue(other.getLongValue()); + } + if (other.hasMoneyAmount()) { + setMoneyAmount(other.getMoneyAmount()); + } + if (other.hasMoneyCurrency()) { + moneyCurrency_ = other.moneyCurrency_; + bitField0_ |= 0x00000020; + onChanged(); + } + if (other.hasSecurity()) { + security_ = other.security_; + bitField0_ |= 0x00000040; + onChanged(); + } + if (other.hasAccount()) { + account_ = other.account_; + bitField0_ |= 0x00000080; + onChanged(); + } + if (other.hasPortfolio()) { + portfolio_ = other.portfolio_; + bitField0_ |= 0x00000100; + onChanged(); + } + if (other.hasLocalDateTimeValue()) { + mergeLocalDateTimeValue(other.getLocalDateTimeValue()); + } + if (other.hasTypeCode()) { + typeCode_ = other.typeCode_; + bitField0_ |= 0x00000400; + onChanged(); + } + if (other.hasBooleanValue()) { + setBooleanValue(other.getBooleanValue()); + } + if (other.hasLocalDateValue()) { + setLocalDateValue(other.getLocalDateValue()); + } + this.mergeUnknownFields(other.getUnknownFields()); + onChanged(); + return this; + } + + @java.lang.Override + public final boolean isInitialized() { + return true; + } + + @java.lang.Override + public Builder mergeFrom( + com.google.protobuf.CodedInputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + if (extensionRegistry == null) { + throw new java.lang.NullPointerException(); + } + try { + boolean done = false; + while (!done) { + int tag = input.readTag(); + switch (tag) { + case 0: + done = true; + break; + case 16: { + valueKind_ = input.readEnum(); + bitField0_ |= 0x00000001; + break; + } // case 16 + case 26: { + stringValue_ = input.readStringRequireUtf8(); + bitField0_ |= 0x00000002; + break; + } // case 26 + case 34: { + input.readMessage( + getDecimalValueFieldBuilder().getBuilder(), + extensionRegistry); + bitField0_ |= 0x00000004; + break; + } // case 34 + case 40: { + longValue_ = input.readInt64(); + bitField0_ |= 0x00000008; + break; + } // case 40 + case 48: { + moneyAmount_ = input.readInt64(); + bitField0_ |= 0x00000010; + break; + } // case 48 + case 58: { + moneyCurrency_ = input.readStringRequireUtf8(); + bitField0_ |= 0x00000020; + break; + } // case 58 + case 66: { + security_ = input.readStringRequireUtf8(); + bitField0_ |= 0x00000040; + break; + } // case 66 + case 74: { + account_ = input.readStringRequireUtf8(); + bitField0_ |= 0x00000080; + break; + } // case 74 + case 82: { + portfolio_ = input.readStringRequireUtf8(); + bitField0_ |= 0x00000100; + break; + } // case 82 + case 98: { + input.readMessage( + getLocalDateTimeValueFieldBuilder().getBuilder(), + extensionRegistry); + bitField0_ |= 0x00000200; + break; + } // case 98 + case 106: { + typeCode_ = input.readStringRequireUtf8(); + bitField0_ |= 0x00000400; + break; + } // case 106 + case 112: { + booleanValue_ = input.readBool(); + bitField0_ |= 0x00000800; + break; + } // case 112 + case 120: { + localDateValue_ = input.readInt64(); + bitField0_ |= 0x00001000; + break; + } // case 120 + default: { + if (!super.parseUnknownField(input, extensionRegistry, tag)) { + done = true; // was an endgroup tag + } + break; + } // default: + } // switch (tag) + } // while (!done) + } catch (com.google.protobuf.InvalidProtocolBufferException e) { + throw e.unwrapIOException(); + } finally { + onChanged(); + } // finally + return this; + } + private int bitField0_; + + private int valueKind_ = 0; + /** + * .name.abuchen.portfolio.PLedgerParameterValueKind valueKind = 2; + * @return The enum numeric value on the wire for valueKind. + */ + @java.lang.Override public int getValueKindValue() { + return valueKind_; + } + /** + * .name.abuchen.portfolio.PLedgerParameterValueKind valueKind = 2; + * @param value The enum numeric value on the wire for valueKind to set. + * @return This builder for chaining. + */ + public Builder setValueKindValue(int value) { + valueKind_ = value; + bitField0_ |= 0x00000001; + onChanged(); + return this; + } + /** + * .name.abuchen.portfolio.PLedgerParameterValueKind valueKind = 2; + * @return The valueKind. + */ + @java.lang.Override + public name.abuchen.portfolio.model.proto.v1.PLedgerParameterValueKind getValueKind() { + name.abuchen.portfolio.model.proto.v1.PLedgerParameterValueKind result = name.abuchen.portfolio.model.proto.v1.PLedgerParameterValueKind.forNumber(valueKind_); + return result == null ? name.abuchen.portfolio.model.proto.v1.PLedgerParameterValueKind.UNRECOGNIZED : result; + } + /** + * .name.abuchen.portfolio.PLedgerParameterValueKind valueKind = 2; + * @param value The valueKind to set. + * @return This builder for chaining. + */ + public Builder setValueKind(name.abuchen.portfolio.model.proto.v1.PLedgerParameterValueKind value) { + if (value == null) { + throw new NullPointerException(); + } + bitField0_ |= 0x00000001; + valueKind_ = value.getNumber(); + onChanged(); + return this; + } + /** + * .name.abuchen.portfolio.PLedgerParameterValueKind valueKind = 2; + * @return This builder for chaining. + */ + public Builder clearValueKind() { + bitField0_ = (bitField0_ & ~0x00000001); + valueKind_ = 0; + onChanged(); + return this; + } + + private java.lang.Object stringValue_ = ""; + /** + * optional string stringValue = 3; + * @return Whether the stringValue field is set. + */ + public boolean hasStringValue() { + return ((bitField0_ & 0x00000002) != 0); + } + /** + * optional string stringValue = 3; + * @return The stringValue. + */ + public java.lang.String getStringValue() { + java.lang.Object ref = stringValue_; + if (!(ref instanceof java.lang.String)) { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + stringValue_ = s; + return s; + } else { + return (java.lang.String) ref; + } + } + /** + * optional string stringValue = 3; + * @return The bytes for stringValue. + */ + public com.google.protobuf.ByteString + getStringValueBytes() { + java.lang.Object ref = stringValue_; + if (ref instanceof String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + stringValue_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + /** + * optional string stringValue = 3; + * @param value The stringValue to set. + * @return This builder for chaining. + */ + public Builder setStringValue( + java.lang.String value) { + if (value == null) { throw new NullPointerException(); } + stringValue_ = value; + bitField0_ |= 0x00000002; + onChanged(); + return this; + } + /** + * optional string stringValue = 3; + * @return This builder for chaining. + */ + public Builder clearStringValue() { + stringValue_ = getDefaultInstance().getStringValue(); + bitField0_ = (bitField0_ & ~0x00000002); + onChanged(); + return this; + } + /** + * optional string stringValue = 3; + * @param value The bytes for stringValue to set. + * @return This builder for chaining. + */ + public Builder setStringValueBytes( + com.google.protobuf.ByteString value) { + if (value == null) { throw new NullPointerException(); } + checkByteStringIsUtf8(value); + stringValue_ = value; + bitField0_ |= 0x00000002; + onChanged(); + return this; + } + + private name.abuchen.portfolio.model.proto.v1.PDecimalValue decimalValue_; + private com.google.protobuf.SingleFieldBuilderV3< + name.abuchen.portfolio.model.proto.v1.PDecimalValue, name.abuchen.portfolio.model.proto.v1.PDecimalValue.Builder, name.abuchen.portfolio.model.proto.v1.PDecimalValueOrBuilder> decimalValueBuilder_; + /** + * optional .name.abuchen.portfolio.PDecimalValue decimalValue = 4; + * @return Whether the decimalValue field is set. + */ + public boolean hasDecimalValue() { + return ((bitField0_ & 0x00000004) != 0); + } + /** + * optional .name.abuchen.portfolio.PDecimalValue decimalValue = 4; + * @return The decimalValue. + */ + public name.abuchen.portfolio.model.proto.v1.PDecimalValue getDecimalValue() { + if (decimalValueBuilder_ == null) { + return decimalValue_ == null ? name.abuchen.portfolio.model.proto.v1.PDecimalValue.getDefaultInstance() : decimalValue_; + } else { + return decimalValueBuilder_.getMessage(); + } + } + /** + * optional .name.abuchen.portfolio.PDecimalValue decimalValue = 4; + */ + public Builder setDecimalValue(name.abuchen.portfolio.model.proto.v1.PDecimalValue value) { + if (decimalValueBuilder_ == null) { + if (value == null) { + throw new NullPointerException(); + } + decimalValue_ = value; + } else { + decimalValueBuilder_.setMessage(value); + } + bitField0_ |= 0x00000004; + onChanged(); + return this; + } + /** + * optional .name.abuchen.portfolio.PDecimalValue decimalValue = 4; + */ + public Builder setDecimalValue( + name.abuchen.portfolio.model.proto.v1.PDecimalValue.Builder builderForValue) { + if (decimalValueBuilder_ == null) { + decimalValue_ = builderForValue.build(); + } else { + decimalValueBuilder_.setMessage(builderForValue.build()); + } + bitField0_ |= 0x00000004; + onChanged(); + return this; + } + /** + * optional .name.abuchen.portfolio.PDecimalValue decimalValue = 4; + */ + public Builder mergeDecimalValue(name.abuchen.portfolio.model.proto.v1.PDecimalValue value) { + if (decimalValueBuilder_ == null) { + if (((bitField0_ & 0x00000004) != 0) && + decimalValue_ != null && + decimalValue_ != name.abuchen.portfolio.model.proto.v1.PDecimalValue.getDefaultInstance()) { + getDecimalValueBuilder().mergeFrom(value); + } else { + decimalValue_ = value; + } + } else { + decimalValueBuilder_.mergeFrom(value); + } + bitField0_ |= 0x00000004; + onChanged(); + return this; + } + /** + * optional .name.abuchen.portfolio.PDecimalValue decimalValue = 4; + */ + public Builder clearDecimalValue() { + bitField0_ = (bitField0_ & ~0x00000004); + decimalValue_ = null; + if (decimalValueBuilder_ != null) { + decimalValueBuilder_.dispose(); + decimalValueBuilder_ = null; + } + onChanged(); + return this; + } + /** + * optional .name.abuchen.portfolio.PDecimalValue decimalValue = 4; + */ + public name.abuchen.portfolio.model.proto.v1.PDecimalValue.Builder getDecimalValueBuilder() { + bitField0_ |= 0x00000004; + onChanged(); + return getDecimalValueFieldBuilder().getBuilder(); + } + /** + * optional .name.abuchen.portfolio.PDecimalValue decimalValue = 4; + */ + public name.abuchen.portfolio.model.proto.v1.PDecimalValueOrBuilder getDecimalValueOrBuilder() { + if (decimalValueBuilder_ != null) { + return decimalValueBuilder_.getMessageOrBuilder(); + } else { + return decimalValue_ == null ? + name.abuchen.portfolio.model.proto.v1.PDecimalValue.getDefaultInstance() : decimalValue_; + } + } + /** + * optional .name.abuchen.portfolio.PDecimalValue decimalValue = 4; + */ + private com.google.protobuf.SingleFieldBuilderV3< + name.abuchen.portfolio.model.proto.v1.PDecimalValue, name.abuchen.portfolio.model.proto.v1.PDecimalValue.Builder, name.abuchen.portfolio.model.proto.v1.PDecimalValueOrBuilder> + getDecimalValueFieldBuilder() { + if (decimalValueBuilder_ == null) { + decimalValueBuilder_ = new com.google.protobuf.SingleFieldBuilderV3< + name.abuchen.portfolio.model.proto.v1.PDecimalValue, name.abuchen.portfolio.model.proto.v1.PDecimalValue.Builder, name.abuchen.portfolio.model.proto.v1.PDecimalValueOrBuilder>( + getDecimalValue(), + getParentForChildren(), + isClean()); + decimalValue_ = null; + } + return decimalValueBuilder_; + } + + private long longValue_ ; + /** + * optional int64 longValue = 5; + * @return Whether the longValue field is set. + */ + @java.lang.Override + public boolean hasLongValue() { + return ((bitField0_ & 0x00000008) != 0); + } + /** + * optional int64 longValue = 5; + * @return The longValue. + */ + @java.lang.Override + public long getLongValue() { + return longValue_; + } + /** + * optional int64 longValue = 5; + * @param value The longValue to set. + * @return This builder for chaining. + */ + public Builder setLongValue(long value) { + + longValue_ = value; + bitField0_ |= 0x00000008; + onChanged(); + return this; + } + /** + * optional int64 longValue = 5; + * @return This builder for chaining. + */ + public Builder clearLongValue() { + bitField0_ = (bitField0_ & ~0x00000008); + longValue_ = 0L; + onChanged(); + return this; + } + + private long moneyAmount_ ; + /** + * optional int64 moneyAmount = 6; + * @return Whether the moneyAmount field is set. + */ + @java.lang.Override + public boolean hasMoneyAmount() { + return ((bitField0_ & 0x00000010) != 0); + } + /** + * optional int64 moneyAmount = 6; + * @return The moneyAmount. + */ + @java.lang.Override + public long getMoneyAmount() { + return moneyAmount_; + } + /** + * optional int64 moneyAmount = 6; + * @param value The moneyAmount to set. + * @return This builder for chaining. + */ + public Builder setMoneyAmount(long value) { + + moneyAmount_ = value; + bitField0_ |= 0x00000010; + onChanged(); + return this; + } + /** + * optional int64 moneyAmount = 6; + * @return This builder for chaining. + */ + public Builder clearMoneyAmount() { + bitField0_ = (bitField0_ & ~0x00000010); + moneyAmount_ = 0L; + onChanged(); + return this; + } + + private java.lang.Object moneyCurrency_ = ""; + /** + * optional string moneyCurrency = 7; + * @return Whether the moneyCurrency field is set. + */ + public boolean hasMoneyCurrency() { + return ((bitField0_ & 0x00000020) != 0); + } + /** + * optional string moneyCurrency = 7; + * @return The moneyCurrency. + */ + public java.lang.String getMoneyCurrency() { + java.lang.Object ref = moneyCurrency_; + if (!(ref instanceof java.lang.String)) { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + moneyCurrency_ = s; + return s; + } else { + return (java.lang.String) ref; + } + } + /** + * optional string moneyCurrency = 7; + * @return The bytes for moneyCurrency. + */ + public com.google.protobuf.ByteString + getMoneyCurrencyBytes() { + java.lang.Object ref = moneyCurrency_; + if (ref instanceof String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + moneyCurrency_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + /** + * optional string moneyCurrency = 7; + * @param value The moneyCurrency to set. + * @return This builder for chaining. + */ + public Builder setMoneyCurrency( + java.lang.String value) { + if (value == null) { throw new NullPointerException(); } + moneyCurrency_ = value; + bitField0_ |= 0x00000020; + onChanged(); + return this; + } + /** + * optional string moneyCurrency = 7; + * @return This builder for chaining. + */ + public Builder clearMoneyCurrency() { + moneyCurrency_ = getDefaultInstance().getMoneyCurrency(); + bitField0_ = (bitField0_ & ~0x00000020); + onChanged(); + return this; + } + /** + * optional string moneyCurrency = 7; + * @param value The bytes for moneyCurrency to set. + * @return This builder for chaining. + */ + public Builder setMoneyCurrencyBytes( + com.google.protobuf.ByteString value) { + if (value == null) { throw new NullPointerException(); } + checkByteStringIsUtf8(value); + moneyCurrency_ = value; + bitField0_ |= 0x00000020; + onChanged(); + return this; + } + + private java.lang.Object security_ = ""; + /** + * optional string security = 8; + * @return Whether the security field is set. + */ + public boolean hasSecurity() { + return ((bitField0_ & 0x00000040) != 0); + } + /** + * optional string security = 8; + * @return The security. + */ + public java.lang.String getSecurity() { + java.lang.Object ref = security_; + if (!(ref instanceof java.lang.String)) { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + security_ = s; + return s; + } else { + return (java.lang.String) ref; + } + } + /** + * optional string security = 8; + * @return The bytes for security. + */ + public com.google.protobuf.ByteString + getSecurityBytes() { + java.lang.Object ref = security_; + if (ref instanceof String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + security_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + /** + * optional string security = 8; + * @param value The security to set. + * @return This builder for chaining. + */ + public Builder setSecurity( + java.lang.String value) { + if (value == null) { throw new NullPointerException(); } + security_ = value; + bitField0_ |= 0x00000040; + onChanged(); + return this; + } + /** + * optional string security = 8; + * @return This builder for chaining. + */ + public Builder clearSecurity() { + security_ = getDefaultInstance().getSecurity(); + bitField0_ = (bitField0_ & ~0x00000040); + onChanged(); + return this; + } + /** + * optional string security = 8; + * @param value The bytes for security to set. + * @return This builder for chaining. + */ + public Builder setSecurityBytes( + com.google.protobuf.ByteString value) { + if (value == null) { throw new NullPointerException(); } + checkByteStringIsUtf8(value); + security_ = value; + bitField0_ |= 0x00000040; + onChanged(); + return this; + } + + private java.lang.Object account_ = ""; + /** + * optional string account = 9; + * @return Whether the account field is set. + */ + public boolean hasAccount() { + return ((bitField0_ & 0x00000080) != 0); + } + /** + * optional string account = 9; + * @return The account. + */ + public java.lang.String getAccount() { + java.lang.Object ref = account_; + if (!(ref instanceof java.lang.String)) { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + account_ = s; + return s; + } else { + return (java.lang.String) ref; + } + } + /** + * optional string account = 9; + * @return The bytes for account. + */ + public com.google.protobuf.ByteString + getAccountBytes() { + java.lang.Object ref = account_; + if (ref instanceof String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + account_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + /** + * optional string account = 9; + * @param value The account to set. + * @return This builder for chaining. + */ + public Builder setAccount( + java.lang.String value) { + if (value == null) { throw new NullPointerException(); } + account_ = value; + bitField0_ |= 0x00000080; + onChanged(); + return this; + } + /** + * optional string account = 9; + * @return This builder for chaining. + */ + public Builder clearAccount() { + account_ = getDefaultInstance().getAccount(); + bitField0_ = (bitField0_ & ~0x00000080); + onChanged(); + return this; + } + /** + * optional string account = 9; + * @param value The bytes for account to set. + * @return This builder for chaining. + */ + public Builder setAccountBytes( + com.google.protobuf.ByteString value) { + if (value == null) { throw new NullPointerException(); } + checkByteStringIsUtf8(value); + account_ = value; + bitField0_ |= 0x00000080; + onChanged(); + return this; + } + + private java.lang.Object portfolio_ = ""; + /** + * optional string portfolio = 10; + * @return Whether the portfolio field is set. + */ + public boolean hasPortfolio() { + return ((bitField0_ & 0x00000100) != 0); + } + /** + * optional string portfolio = 10; + * @return The portfolio. + */ + public java.lang.String getPortfolio() { + java.lang.Object ref = portfolio_; + if (!(ref instanceof java.lang.String)) { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + portfolio_ = s; + return s; + } else { + return (java.lang.String) ref; + } + } + /** + * optional string portfolio = 10; + * @return The bytes for portfolio. + */ + public com.google.protobuf.ByteString + getPortfolioBytes() { + java.lang.Object ref = portfolio_; + if (ref instanceof String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + portfolio_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + /** + * optional string portfolio = 10; + * @param value The portfolio to set. + * @return This builder for chaining. + */ + public Builder setPortfolio( + java.lang.String value) { + if (value == null) { throw new NullPointerException(); } + portfolio_ = value; + bitField0_ |= 0x00000100; + onChanged(); + return this; + } + /** + * optional string portfolio = 10; + * @return This builder for chaining. + */ + public Builder clearPortfolio() { + portfolio_ = getDefaultInstance().getPortfolio(); + bitField0_ = (bitField0_ & ~0x00000100); + onChanged(); + return this; + } + /** + * optional string portfolio = 10; + * @param value The bytes for portfolio to set. + * @return This builder for chaining. + */ + public Builder setPortfolioBytes( + com.google.protobuf.ByteString value) { + if (value == null) { throw new NullPointerException(); } + checkByteStringIsUtf8(value); + portfolio_ = value; + bitField0_ |= 0x00000100; + onChanged(); + return this; + } + + private name.abuchen.portfolio.model.proto.v1.PLocalDateTime localDateTimeValue_; + private com.google.protobuf.SingleFieldBuilderV3< + name.abuchen.portfolio.model.proto.v1.PLocalDateTime, name.abuchen.portfolio.model.proto.v1.PLocalDateTime.Builder, name.abuchen.portfolio.model.proto.v1.PLocalDateTimeOrBuilder> localDateTimeValueBuilder_; + /** + * optional .name.abuchen.portfolio.PLocalDateTime localDateTimeValue = 12; + * @return Whether the localDateTimeValue field is set. + */ + public boolean hasLocalDateTimeValue() { + return ((bitField0_ & 0x00000200) != 0); + } + /** + * optional .name.abuchen.portfolio.PLocalDateTime localDateTimeValue = 12; + * @return The localDateTimeValue. + */ + public name.abuchen.portfolio.model.proto.v1.PLocalDateTime getLocalDateTimeValue() { + if (localDateTimeValueBuilder_ == null) { + return localDateTimeValue_ == null ? name.abuchen.portfolio.model.proto.v1.PLocalDateTime.getDefaultInstance() : localDateTimeValue_; + } else { + return localDateTimeValueBuilder_.getMessage(); + } + } + /** + * optional .name.abuchen.portfolio.PLocalDateTime localDateTimeValue = 12; + */ + public Builder setLocalDateTimeValue(name.abuchen.portfolio.model.proto.v1.PLocalDateTime value) { + if (localDateTimeValueBuilder_ == null) { + if (value == null) { + throw new NullPointerException(); + } + localDateTimeValue_ = value; + } else { + localDateTimeValueBuilder_.setMessage(value); + } + bitField0_ |= 0x00000200; + onChanged(); + return this; + } + /** + * optional .name.abuchen.portfolio.PLocalDateTime localDateTimeValue = 12; + */ + public Builder setLocalDateTimeValue( + name.abuchen.portfolio.model.proto.v1.PLocalDateTime.Builder builderForValue) { + if (localDateTimeValueBuilder_ == null) { + localDateTimeValue_ = builderForValue.build(); + } else { + localDateTimeValueBuilder_.setMessage(builderForValue.build()); + } + bitField0_ |= 0x00000200; + onChanged(); + return this; + } + /** + * optional .name.abuchen.portfolio.PLocalDateTime localDateTimeValue = 12; + */ + public Builder mergeLocalDateTimeValue(name.abuchen.portfolio.model.proto.v1.PLocalDateTime value) { + if (localDateTimeValueBuilder_ == null) { + if (((bitField0_ & 0x00000200) != 0) && + localDateTimeValue_ != null && + localDateTimeValue_ != name.abuchen.portfolio.model.proto.v1.PLocalDateTime.getDefaultInstance()) { + getLocalDateTimeValueBuilder().mergeFrom(value); + } else { + localDateTimeValue_ = value; + } + } else { + localDateTimeValueBuilder_.mergeFrom(value); + } + bitField0_ |= 0x00000200; + onChanged(); + return this; + } + /** + * optional .name.abuchen.portfolio.PLocalDateTime localDateTimeValue = 12; + */ + public Builder clearLocalDateTimeValue() { + bitField0_ = (bitField0_ & ~0x00000200); + localDateTimeValue_ = null; + if (localDateTimeValueBuilder_ != null) { + localDateTimeValueBuilder_.dispose(); + localDateTimeValueBuilder_ = null; + } + onChanged(); + return this; + } + /** + * optional .name.abuchen.portfolio.PLocalDateTime localDateTimeValue = 12; + */ + public name.abuchen.portfolio.model.proto.v1.PLocalDateTime.Builder getLocalDateTimeValueBuilder() { + bitField0_ |= 0x00000200; + onChanged(); + return getLocalDateTimeValueFieldBuilder().getBuilder(); + } + /** + * optional .name.abuchen.portfolio.PLocalDateTime localDateTimeValue = 12; + */ + public name.abuchen.portfolio.model.proto.v1.PLocalDateTimeOrBuilder getLocalDateTimeValueOrBuilder() { + if (localDateTimeValueBuilder_ != null) { + return localDateTimeValueBuilder_.getMessageOrBuilder(); + } else { + return localDateTimeValue_ == null ? + name.abuchen.portfolio.model.proto.v1.PLocalDateTime.getDefaultInstance() : localDateTimeValue_; + } + } + /** + * optional .name.abuchen.portfolio.PLocalDateTime localDateTimeValue = 12; + */ + private com.google.protobuf.SingleFieldBuilderV3< + name.abuchen.portfolio.model.proto.v1.PLocalDateTime, name.abuchen.portfolio.model.proto.v1.PLocalDateTime.Builder, name.abuchen.portfolio.model.proto.v1.PLocalDateTimeOrBuilder> + getLocalDateTimeValueFieldBuilder() { + if (localDateTimeValueBuilder_ == null) { + localDateTimeValueBuilder_ = new com.google.protobuf.SingleFieldBuilderV3< + name.abuchen.portfolio.model.proto.v1.PLocalDateTime, name.abuchen.portfolio.model.proto.v1.PLocalDateTime.Builder, name.abuchen.portfolio.model.proto.v1.PLocalDateTimeOrBuilder>( + getLocalDateTimeValue(), + getParentForChildren(), + isClean()); + localDateTimeValue_ = null; + } + return localDateTimeValueBuilder_; + } + + private java.lang.Object typeCode_ = ""; + /** + * optional string typeCode = 13; + * @return Whether the typeCode field is set. + */ + public boolean hasTypeCode() { + return ((bitField0_ & 0x00000400) != 0); + } + /** + * optional string typeCode = 13; + * @return The typeCode. + */ + public java.lang.String getTypeCode() { + java.lang.Object ref = typeCode_; + if (!(ref instanceof java.lang.String)) { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + typeCode_ = s; + return s; + } else { + return (java.lang.String) ref; + } + } + /** + * optional string typeCode = 13; + * @return The bytes for typeCode. + */ + public com.google.protobuf.ByteString + getTypeCodeBytes() { + java.lang.Object ref = typeCode_; + if (ref instanceof String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + typeCode_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + /** + * optional string typeCode = 13; + * @param value The typeCode to set. + * @return This builder for chaining. + */ + public Builder setTypeCode( + java.lang.String value) { + if (value == null) { throw new NullPointerException(); } + typeCode_ = value; + bitField0_ |= 0x00000400; + onChanged(); + return this; + } + /** + * optional string typeCode = 13; + * @return This builder for chaining. + */ + public Builder clearTypeCode() { + typeCode_ = getDefaultInstance().getTypeCode(); + bitField0_ = (bitField0_ & ~0x00000400); + onChanged(); + return this; + } + /** + * optional string typeCode = 13; + * @param value The bytes for typeCode to set. + * @return This builder for chaining. + */ + public Builder setTypeCodeBytes( + com.google.protobuf.ByteString value) { + if (value == null) { throw new NullPointerException(); } + checkByteStringIsUtf8(value); + typeCode_ = value; + bitField0_ |= 0x00000400; + onChanged(); + return this; + } + + private boolean booleanValue_ ; + /** + * optional bool booleanValue = 14; + * @return Whether the booleanValue field is set. + */ + @java.lang.Override + public boolean hasBooleanValue() { + return ((bitField0_ & 0x00000800) != 0); + } + /** + * optional bool booleanValue = 14; + * @return The booleanValue. + */ + @java.lang.Override + public boolean getBooleanValue() { + return booleanValue_; + } + /** + * optional bool booleanValue = 14; + * @param value The booleanValue to set. + * @return This builder for chaining. + */ + public Builder setBooleanValue(boolean value) { + + booleanValue_ = value; + bitField0_ |= 0x00000800; + onChanged(); + return this; + } + /** + * optional bool booleanValue = 14; + * @return This builder for chaining. + */ + public Builder clearBooleanValue() { + bitField0_ = (bitField0_ & ~0x00000800); + booleanValue_ = false; + onChanged(); + return this; + } + + private long localDateValue_ ; + /** + *
+     * epoch day, based on the epoch 1970-01-01
+     * 
+ * + * optional int64 localDateValue = 15; + * @return Whether the localDateValue field is set. + */ + @java.lang.Override + public boolean hasLocalDateValue() { + return ((bitField0_ & 0x00001000) != 0); + } + /** + *
+     * epoch day, based on the epoch 1970-01-01
+     * 
+ * + * optional int64 localDateValue = 15; + * @return The localDateValue. + */ + @java.lang.Override + public long getLocalDateValue() { + return localDateValue_; + } + /** + *
+     * epoch day, based on the epoch 1970-01-01
+     * 
+ * + * optional int64 localDateValue = 15; + * @param value The localDateValue to set. + * @return This builder for chaining. + */ + public Builder setLocalDateValue(long value) { + + localDateValue_ = value; + bitField0_ |= 0x00001000; + onChanged(); + return this; + } + /** + *
+     * epoch day, based on the epoch 1970-01-01
+     * 
+ * + * optional int64 localDateValue = 15; + * @return This builder for chaining. + */ + public Builder clearLocalDateValue() { + bitField0_ = (bitField0_ & ~0x00001000); + localDateValue_ = 0L; + onChanged(); + return this; + } + @java.lang.Override + public final Builder setUnknownFields( + final com.google.protobuf.UnknownFieldSet unknownFields) { + return super.setUnknownFields(unknownFields); + } + + @java.lang.Override + public final Builder mergeUnknownFields( + final com.google.protobuf.UnknownFieldSet unknownFields) { + return super.mergeUnknownFields(unknownFields); + } + + + // @@protoc_insertion_point(builder_scope:name.abuchen.portfolio.PLedgerParameter) + } + + // @@protoc_insertion_point(class_scope:name.abuchen.portfolio.PLedgerParameter) + private static final name.abuchen.portfolio.model.proto.v1.PLedgerParameter DEFAULT_INSTANCE; + static { + DEFAULT_INSTANCE = new name.abuchen.portfolio.model.proto.v1.PLedgerParameter(); + } + + public static name.abuchen.portfolio.model.proto.v1.PLedgerParameter getDefaultInstance() { + return DEFAULT_INSTANCE; + } + + private static final com.google.protobuf.Parser + PARSER = new com.google.protobuf.AbstractParser() { + @java.lang.Override + public PLedgerParameter parsePartialFrom( + com.google.protobuf.CodedInputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + Builder builder = newBuilder(); + try { + builder.mergeFrom(input, extensionRegistry); + } catch (com.google.protobuf.InvalidProtocolBufferException e) { + throw e.setUnfinishedMessage(builder.buildPartial()); + } catch (com.google.protobuf.UninitializedMessageException e) { + throw e.asInvalidProtocolBufferException().setUnfinishedMessage(builder.buildPartial()); + } catch (java.io.IOException e) { + throw new com.google.protobuf.InvalidProtocolBufferException(e) + .setUnfinishedMessage(builder.buildPartial()); + } + return builder.buildPartial(); + } + }; + + public static com.google.protobuf.Parser parser() { + return PARSER; + } + + @java.lang.Override + public com.google.protobuf.Parser getParserForType() { + return PARSER; + } + + @java.lang.Override + public name.abuchen.portfolio.model.proto.v1.PLedgerParameter getDefaultInstanceForType() { + return DEFAULT_INSTANCE; + } + +} diff --git a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerParameterOrBuilder.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerParameterOrBuilder.java new file mode 100644 index 0000000000..fb86be9757 --- /dev/null +++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerParameterOrBuilder.java @@ -0,0 +1,204 @@ +// Generated by the protocol buffer compiler. DO NOT EDIT! +// source: client.proto + +package name.abuchen.portfolio.model.proto.v1; + +public interface PLedgerParameterOrBuilder extends + // @@protoc_insertion_point(interface_extends:name.abuchen.portfolio.PLedgerParameter) + com.google.protobuf.MessageOrBuilder { + + /** + * .name.abuchen.portfolio.PLedgerParameterValueKind valueKind = 2; + * @return The enum numeric value on the wire for valueKind. + */ + int getValueKindValue(); + /** + * .name.abuchen.portfolio.PLedgerParameterValueKind valueKind = 2; + * @return The valueKind. + */ + name.abuchen.portfolio.model.proto.v1.PLedgerParameterValueKind getValueKind(); + + /** + * optional string stringValue = 3; + * @return Whether the stringValue field is set. + */ + boolean hasStringValue(); + /** + * optional string stringValue = 3; + * @return The stringValue. + */ + java.lang.String getStringValue(); + /** + * optional string stringValue = 3; + * @return The bytes for stringValue. + */ + com.google.protobuf.ByteString + getStringValueBytes(); + + /** + * optional .name.abuchen.portfolio.PDecimalValue decimalValue = 4; + * @return Whether the decimalValue field is set. + */ + boolean hasDecimalValue(); + /** + * optional .name.abuchen.portfolio.PDecimalValue decimalValue = 4; + * @return The decimalValue. + */ + name.abuchen.portfolio.model.proto.v1.PDecimalValue getDecimalValue(); + /** + * optional .name.abuchen.portfolio.PDecimalValue decimalValue = 4; + */ + name.abuchen.portfolio.model.proto.v1.PDecimalValueOrBuilder getDecimalValueOrBuilder(); + + /** + * optional int64 longValue = 5; + * @return Whether the longValue field is set. + */ + boolean hasLongValue(); + /** + * optional int64 longValue = 5; + * @return The longValue. + */ + long getLongValue(); + + /** + * optional int64 moneyAmount = 6; + * @return Whether the moneyAmount field is set. + */ + boolean hasMoneyAmount(); + /** + * optional int64 moneyAmount = 6; + * @return The moneyAmount. + */ + long getMoneyAmount(); + + /** + * optional string moneyCurrency = 7; + * @return Whether the moneyCurrency field is set. + */ + boolean hasMoneyCurrency(); + /** + * optional string moneyCurrency = 7; + * @return The moneyCurrency. + */ + java.lang.String getMoneyCurrency(); + /** + * optional string moneyCurrency = 7; + * @return The bytes for moneyCurrency. + */ + com.google.protobuf.ByteString + getMoneyCurrencyBytes(); + + /** + * optional string security = 8; + * @return Whether the security field is set. + */ + boolean hasSecurity(); + /** + * optional string security = 8; + * @return The security. + */ + java.lang.String getSecurity(); + /** + * optional string security = 8; + * @return The bytes for security. + */ + com.google.protobuf.ByteString + getSecurityBytes(); + + /** + * optional string account = 9; + * @return Whether the account field is set. + */ + boolean hasAccount(); + /** + * optional string account = 9; + * @return The account. + */ + java.lang.String getAccount(); + /** + * optional string account = 9; + * @return The bytes for account. + */ + com.google.protobuf.ByteString + getAccountBytes(); + + /** + * optional string portfolio = 10; + * @return Whether the portfolio field is set. + */ + boolean hasPortfolio(); + /** + * optional string portfolio = 10; + * @return The portfolio. + */ + java.lang.String getPortfolio(); + /** + * optional string portfolio = 10; + * @return The bytes for portfolio. + */ + com.google.protobuf.ByteString + getPortfolioBytes(); + + /** + * optional .name.abuchen.portfolio.PLocalDateTime localDateTimeValue = 12; + * @return Whether the localDateTimeValue field is set. + */ + boolean hasLocalDateTimeValue(); + /** + * optional .name.abuchen.portfolio.PLocalDateTime localDateTimeValue = 12; + * @return The localDateTimeValue. + */ + name.abuchen.portfolio.model.proto.v1.PLocalDateTime getLocalDateTimeValue(); + /** + * optional .name.abuchen.portfolio.PLocalDateTime localDateTimeValue = 12; + */ + name.abuchen.portfolio.model.proto.v1.PLocalDateTimeOrBuilder getLocalDateTimeValueOrBuilder(); + + /** + * optional string typeCode = 13; + * @return Whether the typeCode field is set. + */ + boolean hasTypeCode(); + /** + * optional string typeCode = 13; + * @return The typeCode. + */ + java.lang.String getTypeCode(); + /** + * optional string typeCode = 13; + * @return The bytes for typeCode. + */ + com.google.protobuf.ByteString + getTypeCodeBytes(); + + /** + * optional bool booleanValue = 14; + * @return Whether the booleanValue field is set. + */ + boolean hasBooleanValue(); + /** + * optional bool booleanValue = 14; + * @return The booleanValue. + */ + boolean getBooleanValue(); + + /** + *
+   * epoch day, based on the epoch 1970-01-01
+   * 
+ * + * optional int64 localDateValue = 15; + * @return Whether the localDateValue field is set. + */ + boolean hasLocalDateValue(); + /** + *
+   * epoch day, based on the epoch 1970-01-01
+   * 
+ * + * optional int64 localDateValue = 15; + * @return The localDateValue. + */ + long getLocalDateValue(); +} diff --git a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerParameterValueKind.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerParameterValueKind.java new file mode 100644 index 0000000000..d5c7538fa1 --- /dev/null +++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerParameterValueKind.java @@ -0,0 +1,193 @@ +// Generated by the protocol buffer compiler. DO NOT EDIT! +// source: client.proto + +package name.abuchen.portfolio.model.proto.v1; + +/** + * Protobuf enum {@code name.abuchen.portfolio.PLedgerParameterValueKind} + */ +public enum PLedgerParameterValueKind + implements com.google.protobuf.ProtocolMessageEnum { + /** + * LEDGER_PARAMETER_VALUE_KIND_UNSPECIFIED = 0; + */ + LEDGER_PARAMETER_VALUE_KIND_UNSPECIFIED(0), + /** + * LEDGER_PARAMETER_VALUE_KIND_STRING = 1; + */ + LEDGER_PARAMETER_VALUE_KIND_STRING(1), + /** + * LEDGER_PARAMETER_VALUE_KIND_DECIMAL = 2; + */ + LEDGER_PARAMETER_VALUE_KIND_DECIMAL(2), + /** + * LEDGER_PARAMETER_VALUE_KIND_LONG = 3; + */ + LEDGER_PARAMETER_VALUE_KIND_LONG(3), + /** + * LEDGER_PARAMETER_VALUE_KIND_MONEY = 4; + */ + LEDGER_PARAMETER_VALUE_KIND_MONEY(4), + /** + * LEDGER_PARAMETER_VALUE_KIND_SECURITY = 5; + */ + LEDGER_PARAMETER_VALUE_KIND_SECURITY(5), + /** + * LEDGER_PARAMETER_VALUE_KIND_ACCOUNT = 6; + */ + LEDGER_PARAMETER_VALUE_KIND_ACCOUNT(6), + /** + * LEDGER_PARAMETER_VALUE_KIND_PORTFOLIO = 7; + */ + LEDGER_PARAMETER_VALUE_KIND_PORTFOLIO(7), + /** + * LEDGER_PARAMETER_VALUE_KIND_LOCAL_DATE_TIME = 10; + */ + LEDGER_PARAMETER_VALUE_KIND_LOCAL_DATE_TIME(10), + /** + * LEDGER_PARAMETER_VALUE_KIND_BOOLEAN = 11; + */ + LEDGER_PARAMETER_VALUE_KIND_BOOLEAN(11), + /** + * LEDGER_PARAMETER_VALUE_KIND_LOCAL_DATE = 12; + */ + LEDGER_PARAMETER_VALUE_KIND_LOCAL_DATE(12), + UNRECOGNIZED(-1), + ; + + /** + * LEDGER_PARAMETER_VALUE_KIND_UNSPECIFIED = 0; + */ + public static final int LEDGER_PARAMETER_VALUE_KIND_UNSPECIFIED_VALUE = 0; + /** + * LEDGER_PARAMETER_VALUE_KIND_STRING = 1; + */ + public static final int LEDGER_PARAMETER_VALUE_KIND_STRING_VALUE = 1; + /** + * LEDGER_PARAMETER_VALUE_KIND_DECIMAL = 2; + */ + public static final int LEDGER_PARAMETER_VALUE_KIND_DECIMAL_VALUE = 2; + /** + * LEDGER_PARAMETER_VALUE_KIND_LONG = 3; + */ + public static final int LEDGER_PARAMETER_VALUE_KIND_LONG_VALUE = 3; + /** + * LEDGER_PARAMETER_VALUE_KIND_MONEY = 4; + */ + public static final int LEDGER_PARAMETER_VALUE_KIND_MONEY_VALUE = 4; + /** + * LEDGER_PARAMETER_VALUE_KIND_SECURITY = 5; + */ + public static final int LEDGER_PARAMETER_VALUE_KIND_SECURITY_VALUE = 5; + /** + * LEDGER_PARAMETER_VALUE_KIND_ACCOUNT = 6; + */ + public static final int LEDGER_PARAMETER_VALUE_KIND_ACCOUNT_VALUE = 6; + /** + * LEDGER_PARAMETER_VALUE_KIND_PORTFOLIO = 7; + */ + public static final int LEDGER_PARAMETER_VALUE_KIND_PORTFOLIO_VALUE = 7; + /** + * LEDGER_PARAMETER_VALUE_KIND_LOCAL_DATE_TIME = 10; + */ + public static final int LEDGER_PARAMETER_VALUE_KIND_LOCAL_DATE_TIME_VALUE = 10; + /** + * LEDGER_PARAMETER_VALUE_KIND_BOOLEAN = 11; + */ + public static final int LEDGER_PARAMETER_VALUE_KIND_BOOLEAN_VALUE = 11; + /** + * LEDGER_PARAMETER_VALUE_KIND_LOCAL_DATE = 12; + */ + public static final int LEDGER_PARAMETER_VALUE_KIND_LOCAL_DATE_VALUE = 12; + + + public final int getNumber() { + if (this == UNRECOGNIZED) { + throw new java.lang.IllegalArgumentException( + "Can't get the number of an unknown enum value."); + } + return value; + } + + /** + * @param value The numeric wire value of the corresponding enum entry. + * @return The enum associated with the given numeric wire value. + * @deprecated Use {@link #forNumber(int)} instead. + */ + @java.lang.Deprecated + public static PLedgerParameterValueKind valueOf(int value) { + return forNumber(value); + } + + /** + * @param value The numeric wire value of the corresponding enum entry. + * @return The enum associated with the given numeric wire value. + */ + public static PLedgerParameterValueKind forNumber(int value) { + switch (value) { + case 0: return LEDGER_PARAMETER_VALUE_KIND_UNSPECIFIED; + case 1: return LEDGER_PARAMETER_VALUE_KIND_STRING; + case 2: return LEDGER_PARAMETER_VALUE_KIND_DECIMAL; + case 3: return LEDGER_PARAMETER_VALUE_KIND_LONG; + case 4: return LEDGER_PARAMETER_VALUE_KIND_MONEY; + case 5: return LEDGER_PARAMETER_VALUE_KIND_SECURITY; + case 6: return LEDGER_PARAMETER_VALUE_KIND_ACCOUNT; + case 7: return LEDGER_PARAMETER_VALUE_KIND_PORTFOLIO; + case 10: return LEDGER_PARAMETER_VALUE_KIND_LOCAL_DATE_TIME; + case 11: return LEDGER_PARAMETER_VALUE_KIND_BOOLEAN; + case 12: return LEDGER_PARAMETER_VALUE_KIND_LOCAL_DATE; + default: return null; + } + } + + public static com.google.protobuf.Internal.EnumLiteMap + internalGetValueMap() { + return internalValueMap; + } + private static final com.google.protobuf.Internal.EnumLiteMap< + PLedgerParameterValueKind> internalValueMap = + new com.google.protobuf.Internal.EnumLiteMap() { + public PLedgerParameterValueKind findValueByNumber(int number) { + return PLedgerParameterValueKind.forNumber(number); + } + }; + + public final com.google.protobuf.Descriptors.EnumValueDescriptor + getValueDescriptor() { + if (this == UNRECOGNIZED) { + throw new java.lang.IllegalStateException( + "Can't get the descriptor of an unrecognized enum value."); + } + return getDescriptor().getValues().get(ordinal()); + } + public final com.google.protobuf.Descriptors.EnumDescriptor + getDescriptorForType() { + return getDescriptor(); + } + public static final com.google.protobuf.Descriptors.EnumDescriptor + getDescriptor() { + return name.abuchen.portfolio.model.proto.v1.ClientProtos.getDescriptor().getEnumTypes().get(2); + } + + private static final PLedgerParameterValueKind[] VALUES = values(); + + public static PLedgerParameterValueKind valueOf( + com.google.protobuf.Descriptors.EnumValueDescriptor desc) { + if (desc.getType() != getDescriptor()) { + throw new java.lang.IllegalArgumentException( + "EnumValueDescriptor is not for this type."); + } + if (desc.getIndex() == -1) { + return UNRECOGNIZED; + } + return VALUES[desc.getIndex()]; + } + + private final int value; + + private PLedgerParameterValueKind(int value) { + this.value = value; + } + + // @@protoc_insertion_point(enum_scope:name.abuchen.portfolio.PLedgerParameterValueKind) +} diff --git a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerPosting.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerPosting.java new file mode 100644 index 0000000000..a98c8d662c --- /dev/null +++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerPosting.java @@ -0,0 +1,2217 @@ +// Generated by the protocol buffer compiler. DO NOT EDIT! +// source: client.proto + +package name.abuchen.portfolio.model.proto.v1; + +/** + * Protobuf type {@code name.abuchen.portfolio.PLedgerPosting} + */ +public final class PLedgerPosting extends + com.google.protobuf.GeneratedMessageV3 implements + // @@protoc_insertion_point(message_implements:name.abuchen.portfolio.PLedgerPosting) + PLedgerPostingOrBuilder { +private static final long serialVersionUID = 0L; + // Use PLedgerPosting.newBuilder() to construct. + private PLedgerPosting(com.google.protobuf.GeneratedMessageV3.Builder builder) { + super(builder); + } + private PLedgerPosting() { + uuid_ = ""; + currency_ = ""; + forexCurrency_ = ""; + security_ = ""; + account_ = ""; + portfolio_ = ""; + parameters_ = java.util.Collections.emptyList(); + typeCode_ = ""; + } + + @java.lang.Override + @SuppressWarnings({"unused"}) + protected java.lang.Object newInstance( + UnusedPrivateParameter unused) { + return new PLedgerPosting(); + } + + public static final com.google.protobuf.Descriptors.Descriptor + getDescriptor() { + return name.abuchen.portfolio.model.proto.v1.ClientProtos.internal_static_name_abuchen_portfolio_PLedgerPosting_descriptor; + } + + @java.lang.Override + protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable + internalGetFieldAccessorTable() { + return name.abuchen.portfolio.model.proto.v1.ClientProtos.internal_static_name_abuchen_portfolio_PLedgerPosting_fieldAccessorTable + .ensureFieldAccessorsInitialized( + name.abuchen.portfolio.model.proto.v1.PLedgerPosting.class, name.abuchen.portfolio.model.proto.v1.PLedgerPosting.Builder.class); + } + + private int bitField0_; + public static final int UUID_FIELD_NUMBER = 1; + @SuppressWarnings("serial") + private volatile java.lang.Object uuid_ = ""; + /** + * string uuid = 1; + * @return The uuid. + */ + @java.lang.Override + public java.lang.String getUuid() { + java.lang.Object ref = uuid_; + if (ref instanceof java.lang.String) { + return (java.lang.String) ref; + } else { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + uuid_ = s; + return s; + } + } + /** + * string uuid = 1; + * @return The bytes for uuid. + */ + @java.lang.Override + public com.google.protobuf.ByteString + getUuidBytes() { + java.lang.Object ref = uuid_; + if (ref instanceof java.lang.String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + uuid_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + + public static final int AMOUNT_FIELD_NUMBER = 3; + private long amount_ = 0L; + /** + * int64 amount = 3; + * @return The amount. + */ + @java.lang.Override + public long getAmount() { + return amount_; + } + + public static final int CURRENCY_FIELD_NUMBER = 4; + @SuppressWarnings("serial") + private volatile java.lang.Object currency_ = ""; + /** + * optional string currency = 4; + * @return Whether the currency field is set. + */ + @java.lang.Override + public boolean hasCurrency() { + return ((bitField0_ & 0x00000001) != 0); + } + /** + * optional string currency = 4; + * @return The currency. + */ + @java.lang.Override + public java.lang.String getCurrency() { + java.lang.Object ref = currency_; + if (ref instanceof java.lang.String) { + return (java.lang.String) ref; + } else { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + currency_ = s; + return s; + } + } + /** + * optional string currency = 4; + * @return The bytes for currency. + */ + @java.lang.Override + public com.google.protobuf.ByteString + getCurrencyBytes() { + java.lang.Object ref = currency_; + if (ref instanceof java.lang.String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + currency_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + + public static final int FOREXAMOUNT_FIELD_NUMBER = 5; + private long forexAmount_ = 0L; + /** + * optional int64 forexAmount = 5; + * @return Whether the forexAmount field is set. + */ + @java.lang.Override + public boolean hasForexAmount() { + return ((bitField0_ & 0x00000002) != 0); + } + /** + * optional int64 forexAmount = 5; + * @return The forexAmount. + */ + @java.lang.Override + public long getForexAmount() { + return forexAmount_; + } + + public static final int FOREXCURRENCY_FIELD_NUMBER = 6; + @SuppressWarnings("serial") + private volatile java.lang.Object forexCurrency_ = ""; + /** + * optional string forexCurrency = 6; + * @return Whether the forexCurrency field is set. + */ + @java.lang.Override + public boolean hasForexCurrency() { + return ((bitField0_ & 0x00000004) != 0); + } + /** + * optional string forexCurrency = 6; + * @return The forexCurrency. + */ + @java.lang.Override + public java.lang.String getForexCurrency() { + java.lang.Object ref = forexCurrency_; + if (ref instanceof java.lang.String) { + return (java.lang.String) ref; + } else { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + forexCurrency_ = s; + return s; + } + } + /** + * optional string forexCurrency = 6; + * @return The bytes for forexCurrency. + */ + @java.lang.Override + public com.google.protobuf.ByteString + getForexCurrencyBytes() { + java.lang.Object ref = forexCurrency_; + if (ref instanceof java.lang.String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + forexCurrency_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + + public static final int EXCHANGERATE_FIELD_NUMBER = 7; + private name.abuchen.portfolio.model.proto.v1.PDecimalValue exchangeRate_; + /** + * optional .name.abuchen.portfolio.PDecimalValue exchangeRate = 7; + * @return Whether the exchangeRate field is set. + */ + @java.lang.Override + public boolean hasExchangeRate() { + return ((bitField0_ & 0x00000008) != 0); + } + /** + * optional .name.abuchen.portfolio.PDecimalValue exchangeRate = 7; + * @return The exchangeRate. + */ + @java.lang.Override + public name.abuchen.portfolio.model.proto.v1.PDecimalValue getExchangeRate() { + return exchangeRate_ == null ? name.abuchen.portfolio.model.proto.v1.PDecimalValue.getDefaultInstance() : exchangeRate_; + } + /** + * optional .name.abuchen.portfolio.PDecimalValue exchangeRate = 7; + */ + @java.lang.Override + public name.abuchen.portfolio.model.proto.v1.PDecimalValueOrBuilder getExchangeRateOrBuilder() { + return exchangeRate_ == null ? name.abuchen.portfolio.model.proto.v1.PDecimalValue.getDefaultInstance() : exchangeRate_; + } + + public static final int SECURITY_FIELD_NUMBER = 8; + @SuppressWarnings("serial") + private volatile java.lang.Object security_ = ""; + /** + * optional string security = 8; + * @return Whether the security field is set. + */ + @java.lang.Override + public boolean hasSecurity() { + return ((bitField0_ & 0x00000010) != 0); + } + /** + * optional string security = 8; + * @return The security. + */ + @java.lang.Override + public java.lang.String getSecurity() { + java.lang.Object ref = security_; + if (ref instanceof java.lang.String) { + return (java.lang.String) ref; + } else { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + security_ = s; + return s; + } + } + /** + * optional string security = 8; + * @return The bytes for security. + */ + @java.lang.Override + public com.google.protobuf.ByteString + getSecurityBytes() { + java.lang.Object ref = security_; + if (ref instanceof java.lang.String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + security_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + + public static final int SHARES_FIELD_NUMBER = 9; + private long shares_ = 0L; + /** + * int64 shares = 9; + * @return The shares. + */ + @java.lang.Override + public long getShares() { + return shares_; + } + + public static final int ACCOUNT_FIELD_NUMBER = 10; + @SuppressWarnings("serial") + private volatile java.lang.Object account_ = ""; + /** + * optional string account = 10; + * @return Whether the account field is set. + */ + @java.lang.Override + public boolean hasAccount() { + return ((bitField0_ & 0x00000020) != 0); + } + /** + * optional string account = 10; + * @return The account. + */ + @java.lang.Override + public java.lang.String getAccount() { + java.lang.Object ref = account_; + if (ref instanceof java.lang.String) { + return (java.lang.String) ref; + } else { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + account_ = s; + return s; + } + } + /** + * optional string account = 10; + * @return The bytes for account. + */ + @java.lang.Override + public com.google.protobuf.ByteString + getAccountBytes() { + java.lang.Object ref = account_; + if (ref instanceof java.lang.String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + account_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + + public static final int PORTFOLIO_FIELD_NUMBER = 11; + @SuppressWarnings("serial") + private volatile java.lang.Object portfolio_ = ""; + /** + * optional string portfolio = 11; + * @return Whether the portfolio field is set. + */ + @java.lang.Override + public boolean hasPortfolio() { + return ((bitField0_ & 0x00000040) != 0); + } + /** + * optional string portfolio = 11; + * @return The portfolio. + */ + @java.lang.Override + public java.lang.String getPortfolio() { + java.lang.Object ref = portfolio_; + if (ref instanceof java.lang.String) { + return (java.lang.String) ref; + } else { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + portfolio_ = s; + return s; + } + } + /** + * optional string portfolio = 11; + * @return The bytes for portfolio. + */ + @java.lang.Override + public com.google.protobuf.ByteString + getPortfolioBytes() { + java.lang.Object ref = portfolio_; + if (ref instanceof java.lang.String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + portfolio_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + + public static final int PARAMETERS_FIELD_NUMBER = 12; + @SuppressWarnings("serial") + private java.util.List parameters_; + /** + * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 12; + */ + @java.lang.Override + public java.util.List getParametersList() { + return parameters_; + } + /** + * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 12; + */ + @java.lang.Override + public java.util.List + getParametersOrBuilderList() { + return parameters_; + } + /** + * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 12; + */ + @java.lang.Override + public int getParametersCount() { + return parameters_.size(); + } + /** + * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 12; + */ + @java.lang.Override + public name.abuchen.portfolio.model.proto.v1.PLedgerParameter getParameters(int index) { + return parameters_.get(index); + } + /** + * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 12; + */ + @java.lang.Override + public name.abuchen.portfolio.model.proto.v1.PLedgerParameterOrBuilder getParametersOrBuilder( + int index) { + return parameters_.get(index); + } + + public static final int TYPECODE_FIELD_NUMBER = 13; + @SuppressWarnings("serial") + private volatile java.lang.Object typeCode_ = ""; + /** + * optional string typeCode = 13; + * @return Whether the typeCode field is set. + */ + @java.lang.Override + public boolean hasTypeCode() { + return ((bitField0_ & 0x00000080) != 0); + } + /** + * optional string typeCode = 13; + * @return The typeCode. + */ + @java.lang.Override + public java.lang.String getTypeCode() { + java.lang.Object ref = typeCode_; + if (ref instanceof java.lang.String) { + return (java.lang.String) ref; + } else { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + typeCode_ = s; + return s; + } + } + /** + * optional string typeCode = 13; + * @return The bytes for typeCode. + */ + @java.lang.Override + public com.google.protobuf.ByteString + getTypeCodeBytes() { + java.lang.Object ref = typeCode_; + if (ref instanceof java.lang.String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + typeCode_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + + private byte memoizedIsInitialized = -1; + @java.lang.Override + public final boolean isInitialized() { + byte isInitialized = memoizedIsInitialized; + if (isInitialized == 1) return true; + if (isInitialized == 0) return false; + + memoizedIsInitialized = 1; + return true; + } + + @java.lang.Override + public void writeTo(com.google.protobuf.CodedOutputStream output) + throws java.io.IOException { + if (!com.google.protobuf.GeneratedMessageV3.isStringEmpty(uuid_)) { + com.google.protobuf.GeneratedMessageV3.writeString(output, 1, uuid_); + } + if (amount_ != 0L) { + output.writeInt64(3, amount_); + } + if (((bitField0_ & 0x00000001) != 0)) { + com.google.protobuf.GeneratedMessageV3.writeString(output, 4, currency_); + } + if (((bitField0_ & 0x00000002) != 0)) { + output.writeInt64(5, forexAmount_); + } + if (((bitField0_ & 0x00000004) != 0)) { + com.google.protobuf.GeneratedMessageV3.writeString(output, 6, forexCurrency_); + } + if (((bitField0_ & 0x00000008) != 0)) { + output.writeMessage(7, getExchangeRate()); + } + if (((bitField0_ & 0x00000010) != 0)) { + com.google.protobuf.GeneratedMessageV3.writeString(output, 8, security_); + } + if (shares_ != 0L) { + output.writeInt64(9, shares_); + } + if (((bitField0_ & 0x00000020) != 0)) { + com.google.protobuf.GeneratedMessageV3.writeString(output, 10, account_); + } + if (((bitField0_ & 0x00000040) != 0)) { + com.google.protobuf.GeneratedMessageV3.writeString(output, 11, portfolio_); + } + for (int i = 0; i < parameters_.size(); i++) { + output.writeMessage(12, parameters_.get(i)); + } + if (((bitField0_ & 0x00000080) != 0)) { + com.google.protobuf.GeneratedMessageV3.writeString(output, 13, typeCode_); + } + getUnknownFields().writeTo(output); + } + + @java.lang.Override + public int getSerializedSize() { + int size = memoizedSize; + if (size != -1) return size; + + size = 0; + if (!com.google.protobuf.GeneratedMessageV3.isStringEmpty(uuid_)) { + size += com.google.protobuf.GeneratedMessageV3.computeStringSize(1, uuid_); + } + if (amount_ != 0L) { + size += com.google.protobuf.CodedOutputStream + .computeInt64Size(3, amount_); + } + if (((bitField0_ & 0x00000001) != 0)) { + size += com.google.protobuf.GeneratedMessageV3.computeStringSize(4, currency_); + } + if (((bitField0_ & 0x00000002) != 0)) { + size += com.google.protobuf.CodedOutputStream + .computeInt64Size(5, forexAmount_); + } + if (((bitField0_ & 0x00000004) != 0)) { + size += com.google.protobuf.GeneratedMessageV3.computeStringSize(6, forexCurrency_); + } + if (((bitField0_ & 0x00000008) != 0)) { + size += com.google.protobuf.CodedOutputStream + .computeMessageSize(7, getExchangeRate()); + } + if (((bitField0_ & 0x00000010) != 0)) { + size += com.google.protobuf.GeneratedMessageV3.computeStringSize(8, security_); + } + if (shares_ != 0L) { + size += com.google.protobuf.CodedOutputStream + .computeInt64Size(9, shares_); + } + if (((bitField0_ & 0x00000020) != 0)) { + size += com.google.protobuf.GeneratedMessageV3.computeStringSize(10, account_); + } + if (((bitField0_ & 0x00000040) != 0)) { + size += com.google.protobuf.GeneratedMessageV3.computeStringSize(11, portfolio_); + } + for (int i = 0; i < parameters_.size(); i++) { + size += com.google.protobuf.CodedOutputStream + .computeMessageSize(12, parameters_.get(i)); + } + if (((bitField0_ & 0x00000080) != 0)) { + size += com.google.protobuf.GeneratedMessageV3.computeStringSize(13, typeCode_); + } + size += getUnknownFields().getSerializedSize(); + memoizedSize = size; + return size; + } + + @java.lang.Override + public boolean equals(final java.lang.Object obj) { + if (obj == this) { + return true; + } + if (!(obj instanceof name.abuchen.portfolio.model.proto.v1.PLedgerPosting)) { + return super.equals(obj); + } + name.abuchen.portfolio.model.proto.v1.PLedgerPosting other = (name.abuchen.portfolio.model.proto.v1.PLedgerPosting) obj; + + if (!getUuid() + .equals(other.getUuid())) return false; + if (getAmount() + != other.getAmount()) return false; + if (hasCurrency() != other.hasCurrency()) return false; + if (hasCurrency()) { + if (!getCurrency() + .equals(other.getCurrency())) return false; + } + if (hasForexAmount() != other.hasForexAmount()) return false; + if (hasForexAmount()) { + if (getForexAmount() + != other.getForexAmount()) return false; + } + if (hasForexCurrency() != other.hasForexCurrency()) return false; + if (hasForexCurrency()) { + if (!getForexCurrency() + .equals(other.getForexCurrency())) return false; + } + if (hasExchangeRate() != other.hasExchangeRate()) return false; + if (hasExchangeRate()) { + if (!getExchangeRate() + .equals(other.getExchangeRate())) return false; + } + if (hasSecurity() != other.hasSecurity()) return false; + if (hasSecurity()) { + if (!getSecurity() + .equals(other.getSecurity())) return false; + } + if (getShares() + != other.getShares()) return false; + if (hasAccount() != other.hasAccount()) return false; + if (hasAccount()) { + if (!getAccount() + .equals(other.getAccount())) return false; + } + if (hasPortfolio() != other.hasPortfolio()) return false; + if (hasPortfolio()) { + if (!getPortfolio() + .equals(other.getPortfolio())) return false; + } + if (!getParametersList() + .equals(other.getParametersList())) return false; + if (hasTypeCode() != other.hasTypeCode()) return false; + if (hasTypeCode()) { + if (!getTypeCode() + .equals(other.getTypeCode())) return false; + } + if (!getUnknownFields().equals(other.getUnknownFields())) return false; + return true; + } + + @java.lang.Override + public int hashCode() { + if (memoizedHashCode != 0) { + return memoizedHashCode; + } + int hash = 41; + hash = (19 * hash) + getDescriptor().hashCode(); + hash = (37 * hash) + UUID_FIELD_NUMBER; + hash = (53 * hash) + getUuid().hashCode(); + hash = (37 * hash) + AMOUNT_FIELD_NUMBER; + hash = (53 * hash) + com.google.protobuf.Internal.hashLong( + getAmount()); + if (hasCurrency()) { + hash = (37 * hash) + CURRENCY_FIELD_NUMBER; + hash = (53 * hash) + getCurrency().hashCode(); + } + if (hasForexAmount()) { + hash = (37 * hash) + FOREXAMOUNT_FIELD_NUMBER; + hash = (53 * hash) + com.google.protobuf.Internal.hashLong( + getForexAmount()); + } + if (hasForexCurrency()) { + hash = (37 * hash) + FOREXCURRENCY_FIELD_NUMBER; + hash = (53 * hash) + getForexCurrency().hashCode(); + } + if (hasExchangeRate()) { + hash = (37 * hash) + EXCHANGERATE_FIELD_NUMBER; + hash = (53 * hash) + getExchangeRate().hashCode(); + } + if (hasSecurity()) { + hash = (37 * hash) + SECURITY_FIELD_NUMBER; + hash = (53 * hash) + getSecurity().hashCode(); + } + hash = (37 * hash) + SHARES_FIELD_NUMBER; + hash = (53 * hash) + com.google.protobuf.Internal.hashLong( + getShares()); + if (hasAccount()) { + hash = (37 * hash) + ACCOUNT_FIELD_NUMBER; + hash = (53 * hash) + getAccount().hashCode(); + } + if (hasPortfolio()) { + hash = (37 * hash) + PORTFOLIO_FIELD_NUMBER; + hash = (53 * hash) + getPortfolio().hashCode(); + } + if (getParametersCount() > 0) { + hash = (37 * hash) + PARAMETERS_FIELD_NUMBER; + hash = (53 * hash) + getParametersList().hashCode(); + } + if (hasTypeCode()) { + hash = (37 * hash) + TYPECODE_FIELD_NUMBER; + hash = (53 * hash) + getTypeCode().hashCode(); + } + hash = (29 * hash) + getUnknownFields().hashCode(); + memoizedHashCode = hash; + return hash; + } + + public static name.abuchen.portfolio.model.proto.v1.PLedgerPosting parseFrom( + java.nio.ByteBuffer data) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data); + } + public static name.abuchen.portfolio.model.proto.v1.PLedgerPosting parseFrom( + java.nio.ByteBuffer data, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data, extensionRegistry); + } + public static name.abuchen.portfolio.model.proto.v1.PLedgerPosting parseFrom( + com.google.protobuf.ByteString data) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data); + } + public static name.abuchen.portfolio.model.proto.v1.PLedgerPosting parseFrom( + com.google.protobuf.ByteString data, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data, extensionRegistry); + } + public static name.abuchen.portfolio.model.proto.v1.PLedgerPosting parseFrom(byte[] data) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data); + } + public static name.abuchen.portfolio.model.proto.v1.PLedgerPosting parseFrom( + byte[] data, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data, extensionRegistry); + } + public static name.abuchen.portfolio.model.proto.v1.PLedgerPosting parseFrom(java.io.InputStream input) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageV3 + .parseWithIOException(PARSER, input); + } + public static name.abuchen.portfolio.model.proto.v1.PLedgerPosting parseFrom( + java.io.InputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageV3 + .parseWithIOException(PARSER, input, extensionRegistry); + } + public static name.abuchen.portfolio.model.proto.v1.PLedgerPosting parseDelimitedFrom(java.io.InputStream input) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageV3 + .parseDelimitedWithIOException(PARSER, input); + } + public static name.abuchen.portfolio.model.proto.v1.PLedgerPosting parseDelimitedFrom( + java.io.InputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageV3 + .parseDelimitedWithIOException(PARSER, input, extensionRegistry); + } + public static name.abuchen.portfolio.model.proto.v1.PLedgerPosting parseFrom( + com.google.protobuf.CodedInputStream input) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageV3 + .parseWithIOException(PARSER, input); + } + public static name.abuchen.portfolio.model.proto.v1.PLedgerPosting parseFrom( + com.google.protobuf.CodedInputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageV3 + .parseWithIOException(PARSER, input, extensionRegistry); + } + + @java.lang.Override + public Builder newBuilderForType() { return newBuilder(); } + public static Builder newBuilder() { + return DEFAULT_INSTANCE.toBuilder(); + } + public static Builder newBuilder(name.abuchen.portfolio.model.proto.v1.PLedgerPosting prototype) { + return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype); + } + @java.lang.Override + public Builder toBuilder() { + return this == DEFAULT_INSTANCE + ? new Builder() : new Builder().mergeFrom(this); + } + + @java.lang.Override + protected Builder newBuilderForType( + com.google.protobuf.GeneratedMessageV3.BuilderParent parent) { + Builder builder = new Builder(parent); + return builder; + } + /** + * Protobuf type {@code name.abuchen.portfolio.PLedgerPosting} + */ + public static final class Builder extends + com.google.protobuf.GeneratedMessageV3.Builder implements + // @@protoc_insertion_point(builder_implements:name.abuchen.portfolio.PLedgerPosting) + name.abuchen.portfolio.model.proto.v1.PLedgerPostingOrBuilder { + public static final com.google.protobuf.Descriptors.Descriptor + getDescriptor() { + return name.abuchen.portfolio.model.proto.v1.ClientProtos.internal_static_name_abuchen_portfolio_PLedgerPosting_descriptor; + } + + @java.lang.Override + protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable + internalGetFieldAccessorTable() { + return name.abuchen.portfolio.model.proto.v1.ClientProtos.internal_static_name_abuchen_portfolio_PLedgerPosting_fieldAccessorTable + .ensureFieldAccessorsInitialized( + name.abuchen.portfolio.model.proto.v1.PLedgerPosting.class, name.abuchen.portfolio.model.proto.v1.PLedgerPosting.Builder.class); + } + + // Construct using name.abuchen.portfolio.model.proto.v1.PLedgerPosting.newBuilder() + private Builder() { + maybeForceBuilderInitialization(); + } + + private Builder( + com.google.protobuf.GeneratedMessageV3.BuilderParent parent) { + super(parent); + maybeForceBuilderInitialization(); + } + private void maybeForceBuilderInitialization() { + if (com.google.protobuf.GeneratedMessageV3 + .alwaysUseFieldBuilders) { + getExchangeRateFieldBuilder(); + getParametersFieldBuilder(); + } + } + @java.lang.Override + public Builder clear() { + super.clear(); + bitField0_ = 0; + uuid_ = ""; + amount_ = 0L; + currency_ = ""; + forexAmount_ = 0L; + forexCurrency_ = ""; + exchangeRate_ = null; + if (exchangeRateBuilder_ != null) { + exchangeRateBuilder_.dispose(); + exchangeRateBuilder_ = null; + } + security_ = ""; + shares_ = 0L; + account_ = ""; + portfolio_ = ""; + if (parametersBuilder_ == null) { + parameters_ = java.util.Collections.emptyList(); + } else { + parameters_ = null; + parametersBuilder_.clear(); + } + bitField0_ = (bitField0_ & ~0x00000400); + typeCode_ = ""; + return this; + } + + @java.lang.Override + public com.google.protobuf.Descriptors.Descriptor + getDescriptorForType() { + return name.abuchen.portfolio.model.proto.v1.ClientProtos.internal_static_name_abuchen_portfolio_PLedgerPosting_descriptor; + } + + @java.lang.Override + public name.abuchen.portfolio.model.proto.v1.PLedgerPosting getDefaultInstanceForType() { + return name.abuchen.portfolio.model.proto.v1.PLedgerPosting.getDefaultInstance(); + } + + @java.lang.Override + public name.abuchen.portfolio.model.proto.v1.PLedgerPosting build() { + name.abuchen.portfolio.model.proto.v1.PLedgerPosting result = buildPartial(); + if (!result.isInitialized()) { + throw newUninitializedMessageException(result); + } + return result; + } + + @java.lang.Override + public name.abuchen.portfolio.model.proto.v1.PLedgerPosting buildPartial() { + name.abuchen.portfolio.model.proto.v1.PLedgerPosting result = new name.abuchen.portfolio.model.proto.v1.PLedgerPosting(this); + buildPartialRepeatedFields(result); + if (bitField0_ != 0) { buildPartial0(result); } + onBuilt(); + return result; + } + + private void buildPartialRepeatedFields(name.abuchen.portfolio.model.proto.v1.PLedgerPosting result) { + if (parametersBuilder_ == null) { + if (((bitField0_ & 0x00000400) != 0)) { + parameters_ = java.util.Collections.unmodifiableList(parameters_); + bitField0_ = (bitField0_ & ~0x00000400); + } + result.parameters_ = parameters_; + } else { + result.parameters_ = parametersBuilder_.build(); + } + } + + private void buildPartial0(name.abuchen.portfolio.model.proto.v1.PLedgerPosting result) { + int from_bitField0_ = bitField0_; + if (((from_bitField0_ & 0x00000001) != 0)) { + result.uuid_ = uuid_; + } + if (((from_bitField0_ & 0x00000002) != 0)) { + result.amount_ = amount_; + } + int to_bitField0_ = 0; + if (((from_bitField0_ & 0x00000004) != 0)) { + result.currency_ = currency_; + to_bitField0_ |= 0x00000001; + } + if (((from_bitField0_ & 0x00000008) != 0)) { + result.forexAmount_ = forexAmount_; + to_bitField0_ |= 0x00000002; + } + if (((from_bitField0_ & 0x00000010) != 0)) { + result.forexCurrency_ = forexCurrency_; + to_bitField0_ |= 0x00000004; + } + if (((from_bitField0_ & 0x00000020) != 0)) { + result.exchangeRate_ = exchangeRateBuilder_ == null + ? exchangeRate_ + : exchangeRateBuilder_.build(); + to_bitField0_ |= 0x00000008; + } + if (((from_bitField0_ & 0x00000040) != 0)) { + result.security_ = security_; + to_bitField0_ |= 0x00000010; + } + if (((from_bitField0_ & 0x00000080) != 0)) { + result.shares_ = shares_; + } + if (((from_bitField0_ & 0x00000100) != 0)) { + result.account_ = account_; + to_bitField0_ |= 0x00000020; + } + if (((from_bitField0_ & 0x00000200) != 0)) { + result.portfolio_ = portfolio_; + to_bitField0_ |= 0x00000040; + } + if (((from_bitField0_ & 0x00000800) != 0)) { + result.typeCode_ = typeCode_; + to_bitField0_ |= 0x00000080; + } + result.bitField0_ |= to_bitField0_; + } + + @java.lang.Override + public Builder mergeFrom(com.google.protobuf.Message other) { + if (other instanceof name.abuchen.portfolio.model.proto.v1.PLedgerPosting) { + return mergeFrom((name.abuchen.portfolio.model.proto.v1.PLedgerPosting)other); + } else { + super.mergeFrom(other); + return this; + } + } + + public Builder mergeFrom(name.abuchen.portfolio.model.proto.v1.PLedgerPosting other) { + if (other == name.abuchen.portfolio.model.proto.v1.PLedgerPosting.getDefaultInstance()) return this; + if (!other.getUuid().isEmpty()) { + uuid_ = other.uuid_; + bitField0_ |= 0x00000001; + onChanged(); + } + if (other.getAmount() != 0L) { + setAmount(other.getAmount()); + } + if (other.hasCurrency()) { + currency_ = other.currency_; + bitField0_ |= 0x00000004; + onChanged(); + } + if (other.hasForexAmount()) { + setForexAmount(other.getForexAmount()); + } + if (other.hasForexCurrency()) { + forexCurrency_ = other.forexCurrency_; + bitField0_ |= 0x00000010; + onChanged(); + } + if (other.hasExchangeRate()) { + mergeExchangeRate(other.getExchangeRate()); + } + if (other.hasSecurity()) { + security_ = other.security_; + bitField0_ |= 0x00000040; + onChanged(); + } + if (other.getShares() != 0L) { + setShares(other.getShares()); + } + if (other.hasAccount()) { + account_ = other.account_; + bitField0_ |= 0x00000100; + onChanged(); + } + if (other.hasPortfolio()) { + portfolio_ = other.portfolio_; + bitField0_ |= 0x00000200; + onChanged(); + } + if (parametersBuilder_ == null) { + if (!other.parameters_.isEmpty()) { + if (parameters_.isEmpty()) { + parameters_ = other.parameters_; + bitField0_ = (bitField0_ & ~0x00000400); + } else { + ensureParametersIsMutable(); + parameters_.addAll(other.parameters_); + } + onChanged(); + } + } else { + if (!other.parameters_.isEmpty()) { + if (parametersBuilder_.isEmpty()) { + parametersBuilder_.dispose(); + parametersBuilder_ = null; + parameters_ = other.parameters_; + bitField0_ = (bitField0_ & ~0x00000400); + parametersBuilder_ = + com.google.protobuf.GeneratedMessageV3.alwaysUseFieldBuilders ? + getParametersFieldBuilder() : null; + } else { + parametersBuilder_.addAllMessages(other.parameters_); + } + } + } + if (other.hasTypeCode()) { + typeCode_ = other.typeCode_; + bitField0_ |= 0x00000800; + onChanged(); + } + this.mergeUnknownFields(other.getUnknownFields()); + onChanged(); + return this; + } + + @java.lang.Override + public final boolean isInitialized() { + return true; + } + + @java.lang.Override + public Builder mergeFrom( + com.google.protobuf.CodedInputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + if (extensionRegistry == null) { + throw new java.lang.NullPointerException(); + } + try { + boolean done = false; + while (!done) { + int tag = input.readTag(); + switch (tag) { + case 0: + done = true; + break; + case 10: { + uuid_ = input.readStringRequireUtf8(); + bitField0_ |= 0x00000001; + break; + } // case 10 + case 24: { + amount_ = input.readInt64(); + bitField0_ |= 0x00000002; + break; + } // case 24 + case 34: { + currency_ = input.readStringRequireUtf8(); + bitField0_ |= 0x00000004; + break; + } // case 34 + case 40: { + forexAmount_ = input.readInt64(); + bitField0_ |= 0x00000008; + break; + } // case 40 + case 50: { + forexCurrency_ = input.readStringRequireUtf8(); + bitField0_ |= 0x00000010; + break; + } // case 50 + case 58: { + input.readMessage( + getExchangeRateFieldBuilder().getBuilder(), + extensionRegistry); + bitField0_ |= 0x00000020; + break; + } // case 58 + case 66: { + security_ = input.readStringRequireUtf8(); + bitField0_ |= 0x00000040; + break; + } // case 66 + case 72: { + shares_ = input.readInt64(); + bitField0_ |= 0x00000080; + break; + } // case 72 + case 82: { + account_ = input.readStringRequireUtf8(); + bitField0_ |= 0x00000100; + break; + } // case 82 + case 90: { + portfolio_ = input.readStringRequireUtf8(); + bitField0_ |= 0x00000200; + break; + } // case 90 + case 98: { + name.abuchen.portfolio.model.proto.v1.PLedgerParameter m = + input.readMessage( + name.abuchen.portfolio.model.proto.v1.PLedgerParameter.parser(), + extensionRegistry); + if (parametersBuilder_ == null) { + ensureParametersIsMutable(); + parameters_.add(m); + } else { + parametersBuilder_.addMessage(m); + } + break; + } // case 98 + case 106: { + typeCode_ = input.readStringRequireUtf8(); + bitField0_ |= 0x00000800; + break; + } // case 106 + default: { + if (!super.parseUnknownField(input, extensionRegistry, tag)) { + done = true; // was an endgroup tag + } + break; + } // default: + } // switch (tag) + } // while (!done) + } catch (com.google.protobuf.InvalidProtocolBufferException e) { + throw e.unwrapIOException(); + } finally { + onChanged(); + } // finally + return this; + } + private int bitField0_; + + private java.lang.Object uuid_ = ""; + /** + * string uuid = 1; + * @return The uuid. + */ + public java.lang.String getUuid() { + java.lang.Object ref = uuid_; + if (!(ref instanceof java.lang.String)) { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + uuid_ = s; + return s; + } else { + return (java.lang.String) ref; + } + } + /** + * string uuid = 1; + * @return The bytes for uuid. + */ + public com.google.protobuf.ByteString + getUuidBytes() { + java.lang.Object ref = uuid_; + if (ref instanceof String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + uuid_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + /** + * string uuid = 1; + * @param value The uuid to set. + * @return This builder for chaining. + */ + public Builder setUuid( + java.lang.String value) { + if (value == null) { throw new NullPointerException(); } + uuid_ = value; + bitField0_ |= 0x00000001; + onChanged(); + return this; + } + /** + * string uuid = 1; + * @return This builder for chaining. + */ + public Builder clearUuid() { + uuid_ = getDefaultInstance().getUuid(); + bitField0_ = (bitField0_ & ~0x00000001); + onChanged(); + return this; + } + /** + * string uuid = 1; + * @param value The bytes for uuid to set. + * @return This builder for chaining. + */ + public Builder setUuidBytes( + com.google.protobuf.ByteString value) { + if (value == null) { throw new NullPointerException(); } + checkByteStringIsUtf8(value); + uuid_ = value; + bitField0_ |= 0x00000001; + onChanged(); + return this; + } + + private long amount_ ; + /** + * int64 amount = 3; + * @return The amount. + */ + @java.lang.Override + public long getAmount() { + return amount_; + } + /** + * int64 amount = 3; + * @param value The amount to set. + * @return This builder for chaining. + */ + public Builder setAmount(long value) { + + amount_ = value; + bitField0_ |= 0x00000002; + onChanged(); + return this; + } + /** + * int64 amount = 3; + * @return This builder for chaining. + */ + public Builder clearAmount() { + bitField0_ = (bitField0_ & ~0x00000002); + amount_ = 0L; + onChanged(); + return this; + } + + private java.lang.Object currency_ = ""; + /** + * optional string currency = 4; + * @return Whether the currency field is set. + */ + public boolean hasCurrency() { + return ((bitField0_ & 0x00000004) != 0); + } + /** + * optional string currency = 4; + * @return The currency. + */ + public java.lang.String getCurrency() { + java.lang.Object ref = currency_; + if (!(ref instanceof java.lang.String)) { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + currency_ = s; + return s; + } else { + return (java.lang.String) ref; + } + } + /** + * optional string currency = 4; + * @return The bytes for currency. + */ + public com.google.protobuf.ByteString + getCurrencyBytes() { + java.lang.Object ref = currency_; + if (ref instanceof String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + currency_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + /** + * optional string currency = 4; + * @param value The currency to set. + * @return This builder for chaining. + */ + public Builder setCurrency( + java.lang.String value) { + if (value == null) { throw new NullPointerException(); } + currency_ = value; + bitField0_ |= 0x00000004; + onChanged(); + return this; + } + /** + * optional string currency = 4; + * @return This builder for chaining. + */ + public Builder clearCurrency() { + currency_ = getDefaultInstance().getCurrency(); + bitField0_ = (bitField0_ & ~0x00000004); + onChanged(); + return this; + } + /** + * optional string currency = 4; + * @param value The bytes for currency to set. + * @return This builder for chaining. + */ + public Builder setCurrencyBytes( + com.google.protobuf.ByteString value) { + if (value == null) { throw new NullPointerException(); } + checkByteStringIsUtf8(value); + currency_ = value; + bitField0_ |= 0x00000004; + onChanged(); + return this; + } + + private long forexAmount_ ; + /** + * optional int64 forexAmount = 5; + * @return Whether the forexAmount field is set. + */ + @java.lang.Override + public boolean hasForexAmount() { + return ((bitField0_ & 0x00000008) != 0); + } + /** + * optional int64 forexAmount = 5; + * @return The forexAmount. + */ + @java.lang.Override + public long getForexAmount() { + return forexAmount_; + } + /** + * optional int64 forexAmount = 5; + * @param value The forexAmount to set. + * @return This builder for chaining. + */ + public Builder setForexAmount(long value) { + + forexAmount_ = value; + bitField0_ |= 0x00000008; + onChanged(); + return this; + } + /** + * optional int64 forexAmount = 5; + * @return This builder for chaining. + */ + public Builder clearForexAmount() { + bitField0_ = (bitField0_ & ~0x00000008); + forexAmount_ = 0L; + onChanged(); + return this; + } + + private java.lang.Object forexCurrency_ = ""; + /** + * optional string forexCurrency = 6; + * @return Whether the forexCurrency field is set. + */ + public boolean hasForexCurrency() { + return ((bitField0_ & 0x00000010) != 0); + } + /** + * optional string forexCurrency = 6; + * @return The forexCurrency. + */ + public java.lang.String getForexCurrency() { + java.lang.Object ref = forexCurrency_; + if (!(ref instanceof java.lang.String)) { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + forexCurrency_ = s; + return s; + } else { + return (java.lang.String) ref; + } + } + /** + * optional string forexCurrency = 6; + * @return The bytes for forexCurrency. + */ + public com.google.protobuf.ByteString + getForexCurrencyBytes() { + java.lang.Object ref = forexCurrency_; + if (ref instanceof String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + forexCurrency_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + /** + * optional string forexCurrency = 6; + * @param value The forexCurrency to set. + * @return This builder for chaining. + */ + public Builder setForexCurrency( + java.lang.String value) { + if (value == null) { throw new NullPointerException(); } + forexCurrency_ = value; + bitField0_ |= 0x00000010; + onChanged(); + return this; + } + /** + * optional string forexCurrency = 6; + * @return This builder for chaining. + */ + public Builder clearForexCurrency() { + forexCurrency_ = getDefaultInstance().getForexCurrency(); + bitField0_ = (bitField0_ & ~0x00000010); + onChanged(); + return this; + } + /** + * optional string forexCurrency = 6; + * @param value The bytes for forexCurrency to set. + * @return This builder for chaining. + */ + public Builder setForexCurrencyBytes( + com.google.protobuf.ByteString value) { + if (value == null) { throw new NullPointerException(); } + checkByteStringIsUtf8(value); + forexCurrency_ = value; + bitField0_ |= 0x00000010; + onChanged(); + return this; + } + + private name.abuchen.portfolio.model.proto.v1.PDecimalValue exchangeRate_; + private com.google.protobuf.SingleFieldBuilderV3< + name.abuchen.portfolio.model.proto.v1.PDecimalValue, name.abuchen.portfolio.model.proto.v1.PDecimalValue.Builder, name.abuchen.portfolio.model.proto.v1.PDecimalValueOrBuilder> exchangeRateBuilder_; + /** + * optional .name.abuchen.portfolio.PDecimalValue exchangeRate = 7; + * @return Whether the exchangeRate field is set. + */ + public boolean hasExchangeRate() { + return ((bitField0_ & 0x00000020) != 0); + } + /** + * optional .name.abuchen.portfolio.PDecimalValue exchangeRate = 7; + * @return The exchangeRate. + */ + public name.abuchen.portfolio.model.proto.v1.PDecimalValue getExchangeRate() { + if (exchangeRateBuilder_ == null) { + return exchangeRate_ == null ? name.abuchen.portfolio.model.proto.v1.PDecimalValue.getDefaultInstance() : exchangeRate_; + } else { + return exchangeRateBuilder_.getMessage(); + } + } + /** + * optional .name.abuchen.portfolio.PDecimalValue exchangeRate = 7; + */ + public Builder setExchangeRate(name.abuchen.portfolio.model.proto.v1.PDecimalValue value) { + if (exchangeRateBuilder_ == null) { + if (value == null) { + throw new NullPointerException(); + } + exchangeRate_ = value; + } else { + exchangeRateBuilder_.setMessage(value); + } + bitField0_ |= 0x00000020; + onChanged(); + return this; + } + /** + * optional .name.abuchen.portfolio.PDecimalValue exchangeRate = 7; + */ + public Builder setExchangeRate( + name.abuchen.portfolio.model.proto.v1.PDecimalValue.Builder builderForValue) { + if (exchangeRateBuilder_ == null) { + exchangeRate_ = builderForValue.build(); + } else { + exchangeRateBuilder_.setMessage(builderForValue.build()); + } + bitField0_ |= 0x00000020; + onChanged(); + return this; + } + /** + * optional .name.abuchen.portfolio.PDecimalValue exchangeRate = 7; + */ + public Builder mergeExchangeRate(name.abuchen.portfolio.model.proto.v1.PDecimalValue value) { + if (exchangeRateBuilder_ == null) { + if (((bitField0_ & 0x00000020) != 0) && + exchangeRate_ != null && + exchangeRate_ != name.abuchen.portfolio.model.proto.v1.PDecimalValue.getDefaultInstance()) { + getExchangeRateBuilder().mergeFrom(value); + } else { + exchangeRate_ = value; + } + } else { + exchangeRateBuilder_.mergeFrom(value); + } + bitField0_ |= 0x00000020; + onChanged(); + return this; + } + /** + * optional .name.abuchen.portfolio.PDecimalValue exchangeRate = 7; + */ + public Builder clearExchangeRate() { + bitField0_ = (bitField0_ & ~0x00000020); + exchangeRate_ = null; + if (exchangeRateBuilder_ != null) { + exchangeRateBuilder_.dispose(); + exchangeRateBuilder_ = null; + } + onChanged(); + return this; + } + /** + * optional .name.abuchen.portfolio.PDecimalValue exchangeRate = 7; + */ + public name.abuchen.portfolio.model.proto.v1.PDecimalValue.Builder getExchangeRateBuilder() { + bitField0_ |= 0x00000020; + onChanged(); + return getExchangeRateFieldBuilder().getBuilder(); + } + /** + * optional .name.abuchen.portfolio.PDecimalValue exchangeRate = 7; + */ + public name.abuchen.portfolio.model.proto.v1.PDecimalValueOrBuilder getExchangeRateOrBuilder() { + if (exchangeRateBuilder_ != null) { + return exchangeRateBuilder_.getMessageOrBuilder(); + } else { + return exchangeRate_ == null ? + name.abuchen.portfolio.model.proto.v1.PDecimalValue.getDefaultInstance() : exchangeRate_; + } + } + /** + * optional .name.abuchen.portfolio.PDecimalValue exchangeRate = 7; + */ + private com.google.protobuf.SingleFieldBuilderV3< + name.abuchen.portfolio.model.proto.v1.PDecimalValue, name.abuchen.portfolio.model.proto.v1.PDecimalValue.Builder, name.abuchen.portfolio.model.proto.v1.PDecimalValueOrBuilder> + getExchangeRateFieldBuilder() { + if (exchangeRateBuilder_ == null) { + exchangeRateBuilder_ = new com.google.protobuf.SingleFieldBuilderV3< + name.abuchen.portfolio.model.proto.v1.PDecimalValue, name.abuchen.portfolio.model.proto.v1.PDecimalValue.Builder, name.abuchen.portfolio.model.proto.v1.PDecimalValueOrBuilder>( + getExchangeRate(), + getParentForChildren(), + isClean()); + exchangeRate_ = null; + } + return exchangeRateBuilder_; + } + + private java.lang.Object security_ = ""; + /** + * optional string security = 8; + * @return Whether the security field is set. + */ + public boolean hasSecurity() { + return ((bitField0_ & 0x00000040) != 0); + } + /** + * optional string security = 8; + * @return The security. + */ + public java.lang.String getSecurity() { + java.lang.Object ref = security_; + if (!(ref instanceof java.lang.String)) { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + security_ = s; + return s; + } else { + return (java.lang.String) ref; + } + } + /** + * optional string security = 8; + * @return The bytes for security. + */ + public com.google.protobuf.ByteString + getSecurityBytes() { + java.lang.Object ref = security_; + if (ref instanceof String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + security_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + /** + * optional string security = 8; + * @param value The security to set. + * @return This builder for chaining. + */ + public Builder setSecurity( + java.lang.String value) { + if (value == null) { throw new NullPointerException(); } + security_ = value; + bitField0_ |= 0x00000040; + onChanged(); + return this; + } + /** + * optional string security = 8; + * @return This builder for chaining. + */ + public Builder clearSecurity() { + security_ = getDefaultInstance().getSecurity(); + bitField0_ = (bitField0_ & ~0x00000040); + onChanged(); + return this; + } + /** + * optional string security = 8; + * @param value The bytes for security to set. + * @return This builder for chaining. + */ + public Builder setSecurityBytes( + com.google.protobuf.ByteString value) { + if (value == null) { throw new NullPointerException(); } + checkByteStringIsUtf8(value); + security_ = value; + bitField0_ |= 0x00000040; + onChanged(); + return this; + } + + private long shares_ ; + /** + * int64 shares = 9; + * @return The shares. + */ + @java.lang.Override + public long getShares() { + return shares_; + } + /** + * int64 shares = 9; + * @param value The shares to set. + * @return This builder for chaining. + */ + public Builder setShares(long value) { + + shares_ = value; + bitField0_ |= 0x00000080; + onChanged(); + return this; + } + /** + * int64 shares = 9; + * @return This builder for chaining. + */ + public Builder clearShares() { + bitField0_ = (bitField0_ & ~0x00000080); + shares_ = 0L; + onChanged(); + return this; + } + + private java.lang.Object account_ = ""; + /** + * optional string account = 10; + * @return Whether the account field is set. + */ + public boolean hasAccount() { + return ((bitField0_ & 0x00000100) != 0); + } + /** + * optional string account = 10; + * @return The account. + */ + public java.lang.String getAccount() { + java.lang.Object ref = account_; + if (!(ref instanceof java.lang.String)) { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + account_ = s; + return s; + } else { + return (java.lang.String) ref; + } + } + /** + * optional string account = 10; + * @return The bytes for account. + */ + public com.google.protobuf.ByteString + getAccountBytes() { + java.lang.Object ref = account_; + if (ref instanceof String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + account_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + /** + * optional string account = 10; + * @param value The account to set. + * @return This builder for chaining. + */ + public Builder setAccount( + java.lang.String value) { + if (value == null) { throw new NullPointerException(); } + account_ = value; + bitField0_ |= 0x00000100; + onChanged(); + return this; + } + /** + * optional string account = 10; + * @return This builder for chaining. + */ + public Builder clearAccount() { + account_ = getDefaultInstance().getAccount(); + bitField0_ = (bitField0_ & ~0x00000100); + onChanged(); + return this; + } + /** + * optional string account = 10; + * @param value The bytes for account to set. + * @return This builder for chaining. + */ + public Builder setAccountBytes( + com.google.protobuf.ByteString value) { + if (value == null) { throw new NullPointerException(); } + checkByteStringIsUtf8(value); + account_ = value; + bitField0_ |= 0x00000100; + onChanged(); + return this; + } + + private java.lang.Object portfolio_ = ""; + /** + * optional string portfolio = 11; + * @return Whether the portfolio field is set. + */ + public boolean hasPortfolio() { + return ((bitField0_ & 0x00000200) != 0); + } + /** + * optional string portfolio = 11; + * @return The portfolio. + */ + public java.lang.String getPortfolio() { + java.lang.Object ref = portfolio_; + if (!(ref instanceof java.lang.String)) { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + portfolio_ = s; + return s; + } else { + return (java.lang.String) ref; + } + } + /** + * optional string portfolio = 11; + * @return The bytes for portfolio. + */ + public com.google.protobuf.ByteString + getPortfolioBytes() { + java.lang.Object ref = portfolio_; + if (ref instanceof String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + portfolio_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + /** + * optional string portfolio = 11; + * @param value The portfolio to set. + * @return This builder for chaining. + */ + public Builder setPortfolio( + java.lang.String value) { + if (value == null) { throw new NullPointerException(); } + portfolio_ = value; + bitField0_ |= 0x00000200; + onChanged(); + return this; + } + /** + * optional string portfolio = 11; + * @return This builder for chaining. + */ + public Builder clearPortfolio() { + portfolio_ = getDefaultInstance().getPortfolio(); + bitField0_ = (bitField0_ & ~0x00000200); + onChanged(); + return this; + } + /** + * optional string portfolio = 11; + * @param value The bytes for portfolio to set. + * @return This builder for chaining. + */ + public Builder setPortfolioBytes( + com.google.protobuf.ByteString value) { + if (value == null) { throw new NullPointerException(); } + checkByteStringIsUtf8(value); + portfolio_ = value; + bitField0_ |= 0x00000200; + onChanged(); + return this; + } + + private java.util.List parameters_ = + java.util.Collections.emptyList(); + private void ensureParametersIsMutable() { + if (!((bitField0_ & 0x00000400) != 0)) { + parameters_ = new java.util.ArrayList(parameters_); + bitField0_ |= 0x00000400; + } + } + + private com.google.protobuf.RepeatedFieldBuilderV3< + name.abuchen.portfolio.model.proto.v1.PLedgerParameter, name.abuchen.portfolio.model.proto.v1.PLedgerParameter.Builder, name.abuchen.portfolio.model.proto.v1.PLedgerParameterOrBuilder> parametersBuilder_; + + /** + * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 12; + */ + public java.util.List getParametersList() { + if (parametersBuilder_ == null) { + return java.util.Collections.unmodifiableList(parameters_); + } else { + return parametersBuilder_.getMessageList(); + } + } + /** + * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 12; + */ + public int getParametersCount() { + if (parametersBuilder_ == null) { + return parameters_.size(); + } else { + return parametersBuilder_.getCount(); + } + } + /** + * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 12; + */ + public name.abuchen.portfolio.model.proto.v1.PLedgerParameter getParameters(int index) { + if (parametersBuilder_ == null) { + return parameters_.get(index); + } else { + return parametersBuilder_.getMessage(index); + } + } + /** + * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 12; + */ + public Builder setParameters( + int index, name.abuchen.portfolio.model.proto.v1.PLedgerParameter value) { + if (parametersBuilder_ == null) { + if (value == null) { + throw new NullPointerException(); + } + ensureParametersIsMutable(); + parameters_.set(index, value); + onChanged(); + } else { + parametersBuilder_.setMessage(index, value); + } + return this; + } + /** + * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 12; + */ + public Builder setParameters( + int index, name.abuchen.portfolio.model.proto.v1.PLedgerParameter.Builder builderForValue) { + if (parametersBuilder_ == null) { + ensureParametersIsMutable(); + parameters_.set(index, builderForValue.build()); + onChanged(); + } else { + parametersBuilder_.setMessage(index, builderForValue.build()); + } + return this; + } + /** + * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 12; + */ + public Builder addParameters(name.abuchen.portfolio.model.proto.v1.PLedgerParameter value) { + if (parametersBuilder_ == null) { + if (value == null) { + throw new NullPointerException(); + } + ensureParametersIsMutable(); + parameters_.add(value); + onChanged(); + } else { + parametersBuilder_.addMessage(value); + } + return this; + } + /** + * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 12; + */ + public Builder addParameters( + int index, name.abuchen.portfolio.model.proto.v1.PLedgerParameter value) { + if (parametersBuilder_ == null) { + if (value == null) { + throw new NullPointerException(); + } + ensureParametersIsMutable(); + parameters_.add(index, value); + onChanged(); + } else { + parametersBuilder_.addMessage(index, value); + } + return this; + } + /** + * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 12; + */ + public Builder addParameters( + name.abuchen.portfolio.model.proto.v1.PLedgerParameter.Builder builderForValue) { + if (parametersBuilder_ == null) { + ensureParametersIsMutable(); + parameters_.add(builderForValue.build()); + onChanged(); + } else { + parametersBuilder_.addMessage(builderForValue.build()); + } + return this; + } + /** + * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 12; + */ + public Builder addParameters( + int index, name.abuchen.portfolio.model.proto.v1.PLedgerParameter.Builder builderForValue) { + if (parametersBuilder_ == null) { + ensureParametersIsMutable(); + parameters_.add(index, builderForValue.build()); + onChanged(); + } else { + parametersBuilder_.addMessage(index, builderForValue.build()); + } + return this; + } + /** + * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 12; + */ + public Builder addAllParameters( + java.lang.Iterable values) { + if (parametersBuilder_ == null) { + ensureParametersIsMutable(); + com.google.protobuf.AbstractMessageLite.Builder.addAll( + values, parameters_); + onChanged(); + } else { + parametersBuilder_.addAllMessages(values); + } + return this; + } + /** + * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 12; + */ + public Builder clearParameters() { + if (parametersBuilder_ == null) { + parameters_ = java.util.Collections.emptyList(); + bitField0_ = (bitField0_ & ~0x00000400); + onChanged(); + } else { + parametersBuilder_.clear(); + } + return this; + } + /** + * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 12; + */ + public Builder removeParameters(int index) { + if (parametersBuilder_ == null) { + ensureParametersIsMutable(); + parameters_.remove(index); + onChanged(); + } else { + parametersBuilder_.remove(index); + } + return this; + } + /** + * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 12; + */ + public name.abuchen.portfolio.model.proto.v1.PLedgerParameter.Builder getParametersBuilder( + int index) { + return getParametersFieldBuilder().getBuilder(index); + } + /** + * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 12; + */ + public name.abuchen.portfolio.model.proto.v1.PLedgerParameterOrBuilder getParametersOrBuilder( + int index) { + if (parametersBuilder_ == null) { + return parameters_.get(index); } else { + return parametersBuilder_.getMessageOrBuilder(index); + } + } + /** + * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 12; + */ + public java.util.List + getParametersOrBuilderList() { + if (parametersBuilder_ != null) { + return parametersBuilder_.getMessageOrBuilderList(); + } else { + return java.util.Collections.unmodifiableList(parameters_); + } + } + /** + * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 12; + */ + public name.abuchen.portfolio.model.proto.v1.PLedgerParameter.Builder addParametersBuilder() { + return getParametersFieldBuilder().addBuilder( + name.abuchen.portfolio.model.proto.v1.PLedgerParameter.getDefaultInstance()); + } + /** + * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 12; + */ + public name.abuchen.portfolio.model.proto.v1.PLedgerParameter.Builder addParametersBuilder( + int index) { + return getParametersFieldBuilder().addBuilder( + index, name.abuchen.portfolio.model.proto.v1.PLedgerParameter.getDefaultInstance()); + } + /** + * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 12; + */ + public java.util.List + getParametersBuilderList() { + return getParametersFieldBuilder().getBuilderList(); + } + private com.google.protobuf.RepeatedFieldBuilderV3< + name.abuchen.portfolio.model.proto.v1.PLedgerParameter, name.abuchen.portfolio.model.proto.v1.PLedgerParameter.Builder, name.abuchen.portfolio.model.proto.v1.PLedgerParameterOrBuilder> + getParametersFieldBuilder() { + if (parametersBuilder_ == null) { + parametersBuilder_ = new com.google.protobuf.RepeatedFieldBuilderV3< + name.abuchen.portfolio.model.proto.v1.PLedgerParameter, name.abuchen.portfolio.model.proto.v1.PLedgerParameter.Builder, name.abuchen.portfolio.model.proto.v1.PLedgerParameterOrBuilder>( + parameters_, + ((bitField0_ & 0x00000400) != 0), + getParentForChildren(), + isClean()); + parameters_ = null; + } + return parametersBuilder_; + } + + private java.lang.Object typeCode_ = ""; + /** + * optional string typeCode = 13; + * @return Whether the typeCode field is set. + */ + public boolean hasTypeCode() { + return ((bitField0_ & 0x00000800) != 0); + } + /** + * optional string typeCode = 13; + * @return The typeCode. + */ + public java.lang.String getTypeCode() { + java.lang.Object ref = typeCode_; + if (!(ref instanceof java.lang.String)) { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + typeCode_ = s; + return s; + } else { + return (java.lang.String) ref; + } + } + /** + * optional string typeCode = 13; + * @return The bytes for typeCode. + */ + public com.google.protobuf.ByteString + getTypeCodeBytes() { + java.lang.Object ref = typeCode_; + if (ref instanceof String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + typeCode_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + /** + * optional string typeCode = 13; + * @param value The typeCode to set. + * @return This builder for chaining. + */ + public Builder setTypeCode( + java.lang.String value) { + if (value == null) { throw new NullPointerException(); } + typeCode_ = value; + bitField0_ |= 0x00000800; + onChanged(); + return this; + } + /** + * optional string typeCode = 13; + * @return This builder for chaining. + */ + public Builder clearTypeCode() { + typeCode_ = getDefaultInstance().getTypeCode(); + bitField0_ = (bitField0_ & ~0x00000800); + onChanged(); + return this; + } + /** + * optional string typeCode = 13; + * @param value The bytes for typeCode to set. + * @return This builder for chaining. + */ + public Builder setTypeCodeBytes( + com.google.protobuf.ByteString value) { + if (value == null) { throw new NullPointerException(); } + checkByteStringIsUtf8(value); + typeCode_ = value; + bitField0_ |= 0x00000800; + onChanged(); + return this; + } + @java.lang.Override + public final Builder setUnknownFields( + final com.google.protobuf.UnknownFieldSet unknownFields) { + return super.setUnknownFields(unknownFields); + } + + @java.lang.Override + public final Builder mergeUnknownFields( + final com.google.protobuf.UnknownFieldSet unknownFields) { + return super.mergeUnknownFields(unknownFields); + } + + + // @@protoc_insertion_point(builder_scope:name.abuchen.portfolio.PLedgerPosting) + } + + // @@protoc_insertion_point(class_scope:name.abuchen.portfolio.PLedgerPosting) + private static final name.abuchen.portfolio.model.proto.v1.PLedgerPosting DEFAULT_INSTANCE; + static { + DEFAULT_INSTANCE = new name.abuchen.portfolio.model.proto.v1.PLedgerPosting(); + } + + public static name.abuchen.portfolio.model.proto.v1.PLedgerPosting getDefaultInstance() { + return DEFAULT_INSTANCE; + } + + private static final com.google.protobuf.Parser + PARSER = new com.google.protobuf.AbstractParser() { + @java.lang.Override + public PLedgerPosting parsePartialFrom( + com.google.protobuf.CodedInputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + Builder builder = newBuilder(); + try { + builder.mergeFrom(input, extensionRegistry); + } catch (com.google.protobuf.InvalidProtocolBufferException e) { + throw e.setUnfinishedMessage(builder.buildPartial()); + } catch (com.google.protobuf.UninitializedMessageException e) { + throw e.asInvalidProtocolBufferException().setUnfinishedMessage(builder.buildPartial()); + } catch (java.io.IOException e) { + throw new com.google.protobuf.InvalidProtocolBufferException(e) + .setUnfinishedMessage(builder.buildPartial()); + } + return builder.buildPartial(); + } + }; + + public static com.google.protobuf.Parser parser() { + return PARSER; + } + + @java.lang.Override + public com.google.protobuf.Parser getParserForType() { + return PARSER; + } + + @java.lang.Override + public name.abuchen.portfolio.model.proto.v1.PLedgerPosting getDefaultInstanceForType() { + return DEFAULT_INSTANCE; + } + +} diff --git a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerPostingOrBuilder.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerPostingOrBuilder.java new file mode 100644 index 0000000000..c65d98d6fe --- /dev/null +++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerPostingOrBuilder.java @@ -0,0 +1,185 @@ +// Generated by the protocol buffer compiler. DO NOT EDIT! +// source: client.proto + +package name.abuchen.portfolio.model.proto.v1; + +public interface PLedgerPostingOrBuilder extends + // @@protoc_insertion_point(interface_extends:name.abuchen.portfolio.PLedgerPosting) + com.google.protobuf.MessageOrBuilder { + + /** + * string uuid = 1; + * @return The uuid. + */ + java.lang.String getUuid(); + /** + * string uuid = 1; + * @return The bytes for uuid. + */ + com.google.protobuf.ByteString + getUuidBytes(); + + /** + * int64 amount = 3; + * @return The amount. + */ + long getAmount(); + + /** + * optional string currency = 4; + * @return Whether the currency field is set. + */ + boolean hasCurrency(); + /** + * optional string currency = 4; + * @return The currency. + */ + java.lang.String getCurrency(); + /** + * optional string currency = 4; + * @return The bytes for currency. + */ + com.google.protobuf.ByteString + getCurrencyBytes(); + + /** + * optional int64 forexAmount = 5; + * @return Whether the forexAmount field is set. + */ + boolean hasForexAmount(); + /** + * optional int64 forexAmount = 5; + * @return The forexAmount. + */ + long getForexAmount(); + + /** + * optional string forexCurrency = 6; + * @return Whether the forexCurrency field is set. + */ + boolean hasForexCurrency(); + /** + * optional string forexCurrency = 6; + * @return The forexCurrency. + */ + java.lang.String getForexCurrency(); + /** + * optional string forexCurrency = 6; + * @return The bytes for forexCurrency. + */ + com.google.protobuf.ByteString + getForexCurrencyBytes(); + + /** + * optional .name.abuchen.portfolio.PDecimalValue exchangeRate = 7; + * @return Whether the exchangeRate field is set. + */ + boolean hasExchangeRate(); + /** + * optional .name.abuchen.portfolio.PDecimalValue exchangeRate = 7; + * @return The exchangeRate. + */ + name.abuchen.portfolio.model.proto.v1.PDecimalValue getExchangeRate(); + /** + * optional .name.abuchen.portfolio.PDecimalValue exchangeRate = 7; + */ + name.abuchen.portfolio.model.proto.v1.PDecimalValueOrBuilder getExchangeRateOrBuilder(); + + /** + * optional string security = 8; + * @return Whether the security field is set. + */ + boolean hasSecurity(); + /** + * optional string security = 8; + * @return The security. + */ + java.lang.String getSecurity(); + /** + * optional string security = 8; + * @return The bytes for security. + */ + com.google.protobuf.ByteString + getSecurityBytes(); + + /** + * int64 shares = 9; + * @return The shares. + */ + long getShares(); + + /** + * optional string account = 10; + * @return Whether the account field is set. + */ + boolean hasAccount(); + /** + * optional string account = 10; + * @return The account. + */ + java.lang.String getAccount(); + /** + * optional string account = 10; + * @return The bytes for account. + */ + com.google.protobuf.ByteString + getAccountBytes(); + + /** + * optional string portfolio = 11; + * @return Whether the portfolio field is set. + */ + boolean hasPortfolio(); + /** + * optional string portfolio = 11; + * @return The portfolio. + */ + java.lang.String getPortfolio(); + /** + * optional string portfolio = 11; + * @return The bytes for portfolio. + */ + com.google.protobuf.ByteString + getPortfolioBytes(); + + /** + * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 12; + */ + java.util.List + getParametersList(); + /** + * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 12; + */ + name.abuchen.portfolio.model.proto.v1.PLedgerParameter getParameters(int index); + /** + * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 12; + */ + int getParametersCount(); + /** + * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 12; + */ + java.util.List + getParametersOrBuilderList(); + /** + * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 12; + */ + name.abuchen.portfolio.model.proto.v1.PLedgerParameterOrBuilder getParametersOrBuilder( + int index); + + /** + * optional string typeCode = 13; + * @return Whether the typeCode field is set. + */ + boolean hasTypeCode(); + /** + * optional string typeCode = 13; + * @return The typeCode. + */ + java.lang.String getTypeCode(); + /** + * optional string typeCode = 13; + * @return The bytes for typeCode. + */ + com.google.protobuf.ByteString + getTypeCodeBytes(); +} diff --git a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionMembership.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionMembership.java new file mode 100644 index 0000000000..d922317343 --- /dev/null +++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionMembership.java @@ -0,0 +1,600 @@ +// Generated by the protocol buffer compiler. DO NOT EDIT! +// source: client.proto + +package name.abuchen.portfolio.model.proto.v1; + +/** + * Protobuf type {@code name.abuchen.portfolio.PLedgerProjectionMembership} + */ +public final class PLedgerProjectionMembership extends + com.google.protobuf.GeneratedMessageV3 implements + // @@protoc_insertion_point(message_implements:name.abuchen.portfolio.PLedgerProjectionMembership) + PLedgerProjectionMembershipOrBuilder { +private static final long serialVersionUID = 0L; + // Use PLedgerProjectionMembership.newBuilder() to construct. + private PLedgerProjectionMembership(com.google.protobuf.GeneratedMessageV3.Builder builder) { + super(builder); + } + private PLedgerProjectionMembership() { + postingUUID_ = ""; + role_ = 0; + } + + @java.lang.Override + @SuppressWarnings({"unused"}) + protected java.lang.Object newInstance( + UnusedPrivateParameter unused) { + return new PLedgerProjectionMembership(); + } + + public static final com.google.protobuf.Descriptors.Descriptor + getDescriptor() { + return name.abuchen.portfolio.model.proto.v1.ClientProtos.internal_static_name_abuchen_portfolio_PLedgerProjectionMembership_descriptor; + } + + @java.lang.Override + protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable + internalGetFieldAccessorTable() { + return name.abuchen.portfolio.model.proto.v1.ClientProtos.internal_static_name_abuchen_portfolio_PLedgerProjectionMembership_fieldAccessorTable + .ensureFieldAccessorsInitialized( + name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership.class, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership.Builder.class); + } + + public static final int POSTINGUUID_FIELD_NUMBER = 1; + @SuppressWarnings("serial") + private volatile java.lang.Object postingUUID_ = ""; + /** + * string postingUUID = 1; + * @return The postingUUID. + */ + @java.lang.Override + public java.lang.String getPostingUUID() { + java.lang.Object ref = postingUUID_; + if (ref instanceof java.lang.String) { + return (java.lang.String) ref; + } else { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + postingUUID_ = s; + return s; + } + } + /** + * string postingUUID = 1; + * @return The bytes for postingUUID. + */ + @java.lang.Override + public com.google.protobuf.ByteString + getPostingUUIDBytes() { + java.lang.Object ref = postingUUID_; + if (ref instanceof java.lang.String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + postingUUID_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + + public static final int ROLE_FIELD_NUMBER = 2; + private int role_ = 0; + /** + * .name.abuchen.portfolio.PLedgerProjectionMembershipRole role = 2; + * @return The enum numeric value on the wire for role. + */ + @java.lang.Override public int getRoleValue() { + return role_; + } + /** + * .name.abuchen.portfolio.PLedgerProjectionMembershipRole role = 2; + * @return The role. + */ + @java.lang.Override public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipRole getRole() { + name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipRole result = name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipRole.forNumber(role_); + return result == null ? name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipRole.UNRECOGNIZED : result; + } + + private byte memoizedIsInitialized = -1; + @java.lang.Override + public final boolean isInitialized() { + byte isInitialized = memoizedIsInitialized; + if (isInitialized == 1) return true; + if (isInitialized == 0) return false; + + memoizedIsInitialized = 1; + return true; + } + + @java.lang.Override + public void writeTo(com.google.protobuf.CodedOutputStream output) + throws java.io.IOException { + if (!com.google.protobuf.GeneratedMessageV3.isStringEmpty(postingUUID_)) { + com.google.protobuf.GeneratedMessageV3.writeString(output, 1, postingUUID_); + } + if (role_ != name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipRole.LEDGER_PROJECTION_MEMBERSHIP_ROLE_UNSPECIFIED.getNumber()) { + output.writeEnum(2, role_); + } + getUnknownFields().writeTo(output); + } + + @java.lang.Override + public int getSerializedSize() { + int size = memoizedSize; + if (size != -1) return size; + + size = 0; + if (!com.google.protobuf.GeneratedMessageV3.isStringEmpty(postingUUID_)) { + size += com.google.protobuf.GeneratedMessageV3.computeStringSize(1, postingUUID_); + } + if (role_ != name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipRole.LEDGER_PROJECTION_MEMBERSHIP_ROLE_UNSPECIFIED.getNumber()) { + size += com.google.protobuf.CodedOutputStream + .computeEnumSize(2, role_); + } + size += getUnknownFields().getSerializedSize(); + memoizedSize = size; + return size; + } + + @java.lang.Override + public boolean equals(final java.lang.Object obj) { + if (obj == this) { + return true; + } + if (!(obj instanceof name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership)) { + return super.equals(obj); + } + name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership other = (name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership) obj; + + if (!getPostingUUID() + .equals(other.getPostingUUID())) return false; + if (role_ != other.role_) return false; + if (!getUnknownFields().equals(other.getUnknownFields())) return false; + return true; + } + + @java.lang.Override + public int hashCode() { + if (memoizedHashCode != 0) { + return memoizedHashCode; + } + int hash = 41; + hash = (19 * hash) + getDescriptor().hashCode(); + hash = (37 * hash) + POSTINGUUID_FIELD_NUMBER; + hash = (53 * hash) + getPostingUUID().hashCode(); + hash = (37 * hash) + ROLE_FIELD_NUMBER; + hash = (53 * hash) + role_; + hash = (29 * hash) + getUnknownFields().hashCode(); + memoizedHashCode = hash; + return hash; + } + + public static name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership parseFrom( + java.nio.ByteBuffer data) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data); + } + public static name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership parseFrom( + java.nio.ByteBuffer data, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data, extensionRegistry); + } + public static name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership parseFrom( + com.google.protobuf.ByteString data) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data); + } + public static name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership parseFrom( + com.google.protobuf.ByteString data, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data, extensionRegistry); + } + public static name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership parseFrom(byte[] data) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data); + } + public static name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership parseFrom( + byte[] data, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data, extensionRegistry); + } + public static name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership parseFrom(java.io.InputStream input) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageV3 + .parseWithIOException(PARSER, input); + } + public static name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership parseFrom( + java.io.InputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageV3 + .parseWithIOException(PARSER, input, extensionRegistry); + } + public static name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership parseDelimitedFrom(java.io.InputStream input) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageV3 + .parseDelimitedWithIOException(PARSER, input); + } + public static name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership parseDelimitedFrom( + java.io.InputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageV3 + .parseDelimitedWithIOException(PARSER, input, extensionRegistry); + } + public static name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership parseFrom( + com.google.protobuf.CodedInputStream input) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageV3 + .parseWithIOException(PARSER, input); + } + public static name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership parseFrom( + com.google.protobuf.CodedInputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageV3 + .parseWithIOException(PARSER, input, extensionRegistry); + } + + @java.lang.Override + public Builder newBuilderForType() { return newBuilder(); } + public static Builder newBuilder() { + return DEFAULT_INSTANCE.toBuilder(); + } + public static Builder newBuilder(name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership prototype) { + return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype); + } + @java.lang.Override + public Builder toBuilder() { + return this == DEFAULT_INSTANCE + ? new Builder() : new Builder().mergeFrom(this); + } + + @java.lang.Override + protected Builder newBuilderForType( + com.google.protobuf.GeneratedMessageV3.BuilderParent parent) { + Builder builder = new Builder(parent); + return builder; + } + /** + * Protobuf type {@code name.abuchen.portfolio.PLedgerProjectionMembership} + */ + public static final class Builder extends + com.google.protobuf.GeneratedMessageV3.Builder implements + // @@protoc_insertion_point(builder_implements:name.abuchen.portfolio.PLedgerProjectionMembership) + name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipOrBuilder { + public static final com.google.protobuf.Descriptors.Descriptor + getDescriptor() { + return name.abuchen.portfolio.model.proto.v1.ClientProtos.internal_static_name_abuchen_portfolio_PLedgerProjectionMembership_descriptor; + } + + @java.lang.Override + protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable + internalGetFieldAccessorTable() { + return name.abuchen.portfolio.model.proto.v1.ClientProtos.internal_static_name_abuchen_portfolio_PLedgerProjectionMembership_fieldAccessorTable + .ensureFieldAccessorsInitialized( + name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership.class, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership.Builder.class); + } + + // Construct using name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership.newBuilder() + private Builder() { + + } + + private Builder( + com.google.protobuf.GeneratedMessageV3.BuilderParent parent) { + super(parent); + + } + @java.lang.Override + public Builder clear() { + super.clear(); + bitField0_ = 0; + postingUUID_ = ""; + role_ = 0; + return this; + } + + @java.lang.Override + public com.google.protobuf.Descriptors.Descriptor + getDescriptorForType() { + return name.abuchen.portfolio.model.proto.v1.ClientProtos.internal_static_name_abuchen_portfolio_PLedgerProjectionMembership_descriptor; + } + + @java.lang.Override + public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership getDefaultInstanceForType() { + return name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership.getDefaultInstance(); + } + + @java.lang.Override + public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership build() { + name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership result = buildPartial(); + if (!result.isInitialized()) { + throw newUninitializedMessageException(result); + } + return result; + } + + @java.lang.Override + public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership buildPartial() { + name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership result = new name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership(this); + if (bitField0_ != 0) { buildPartial0(result); } + onBuilt(); + return result; + } + + private void buildPartial0(name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership result) { + int from_bitField0_ = bitField0_; + if (((from_bitField0_ & 0x00000001) != 0)) { + result.postingUUID_ = postingUUID_; + } + if (((from_bitField0_ & 0x00000002) != 0)) { + result.role_ = role_; + } + } + + @java.lang.Override + public Builder mergeFrom(com.google.protobuf.Message other) { + if (other instanceof name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership) { + return mergeFrom((name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership)other); + } else { + super.mergeFrom(other); + return this; + } + } + + public Builder mergeFrom(name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership other) { + if (other == name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership.getDefaultInstance()) return this; + if (!other.getPostingUUID().isEmpty()) { + postingUUID_ = other.postingUUID_; + bitField0_ |= 0x00000001; + onChanged(); + } + if (other.role_ != 0) { + setRoleValue(other.getRoleValue()); + } + this.mergeUnknownFields(other.getUnknownFields()); + onChanged(); + return this; + } + + @java.lang.Override + public final boolean isInitialized() { + return true; + } + + @java.lang.Override + public Builder mergeFrom( + com.google.protobuf.CodedInputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + if (extensionRegistry == null) { + throw new java.lang.NullPointerException(); + } + try { + boolean done = false; + while (!done) { + int tag = input.readTag(); + switch (tag) { + case 0: + done = true; + break; + case 10: { + postingUUID_ = input.readStringRequireUtf8(); + bitField0_ |= 0x00000001; + break; + } // case 10 + case 16: { + role_ = input.readEnum(); + bitField0_ |= 0x00000002; + break; + } // case 16 + default: { + if (!super.parseUnknownField(input, extensionRegistry, tag)) { + done = true; // was an endgroup tag + } + break; + } // default: + } // switch (tag) + } // while (!done) + } catch (com.google.protobuf.InvalidProtocolBufferException e) { + throw e.unwrapIOException(); + } finally { + onChanged(); + } // finally + return this; + } + private int bitField0_; + + private java.lang.Object postingUUID_ = ""; + /** + * string postingUUID = 1; + * @return The postingUUID. + */ + public java.lang.String getPostingUUID() { + java.lang.Object ref = postingUUID_; + if (!(ref instanceof java.lang.String)) { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + postingUUID_ = s; + return s; + } else { + return (java.lang.String) ref; + } + } + /** + * string postingUUID = 1; + * @return The bytes for postingUUID. + */ + public com.google.protobuf.ByteString + getPostingUUIDBytes() { + java.lang.Object ref = postingUUID_; + if (ref instanceof String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + postingUUID_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + /** + * string postingUUID = 1; + * @param value The postingUUID to set. + * @return This builder for chaining. + */ + public Builder setPostingUUID( + java.lang.String value) { + if (value == null) { throw new NullPointerException(); } + postingUUID_ = value; + bitField0_ |= 0x00000001; + onChanged(); + return this; + } + /** + * string postingUUID = 1; + * @return This builder for chaining. + */ + public Builder clearPostingUUID() { + postingUUID_ = getDefaultInstance().getPostingUUID(); + bitField0_ = (bitField0_ & ~0x00000001); + onChanged(); + return this; + } + /** + * string postingUUID = 1; + * @param value The bytes for postingUUID to set. + * @return This builder for chaining. + */ + public Builder setPostingUUIDBytes( + com.google.protobuf.ByteString value) { + if (value == null) { throw new NullPointerException(); } + checkByteStringIsUtf8(value); + postingUUID_ = value; + bitField0_ |= 0x00000001; + onChanged(); + return this; + } + + private int role_ = 0; + /** + * .name.abuchen.portfolio.PLedgerProjectionMembershipRole role = 2; + * @return The enum numeric value on the wire for role. + */ + @java.lang.Override public int getRoleValue() { + return role_; + } + /** + * .name.abuchen.portfolio.PLedgerProjectionMembershipRole role = 2; + * @param value The enum numeric value on the wire for role to set. + * @return This builder for chaining. + */ + public Builder setRoleValue(int value) { + role_ = value; + bitField0_ |= 0x00000002; + onChanged(); + return this; + } + /** + * .name.abuchen.portfolio.PLedgerProjectionMembershipRole role = 2; + * @return The role. + */ + @java.lang.Override + public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipRole getRole() { + name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipRole result = name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipRole.forNumber(role_); + return result == null ? name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipRole.UNRECOGNIZED : result; + } + /** + * .name.abuchen.portfolio.PLedgerProjectionMembershipRole role = 2; + * @param value The role to set. + * @return This builder for chaining. + */ + public Builder setRole(name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipRole value) { + if (value == null) { + throw new NullPointerException(); + } + bitField0_ |= 0x00000002; + role_ = value.getNumber(); + onChanged(); + return this; + } + /** + * .name.abuchen.portfolio.PLedgerProjectionMembershipRole role = 2; + * @return This builder for chaining. + */ + public Builder clearRole() { + bitField0_ = (bitField0_ & ~0x00000002); + role_ = 0; + onChanged(); + return this; + } + @java.lang.Override + public final Builder setUnknownFields( + final com.google.protobuf.UnknownFieldSet unknownFields) { + return super.setUnknownFields(unknownFields); + } + + @java.lang.Override + public final Builder mergeUnknownFields( + final com.google.protobuf.UnknownFieldSet unknownFields) { + return super.mergeUnknownFields(unknownFields); + } + + + // @@protoc_insertion_point(builder_scope:name.abuchen.portfolio.PLedgerProjectionMembership) + } + + // @@protoc_insertion_point(class_scope:name.abuchen.portfolio.PLedgerProjectionMembership) + private static final name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership DEFAULT_INSTANCE; + static { + DEFAULT_INSTANCE = new name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership(); + } + + public static name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership getDefaultInstance() { + return DEFAULT_INSTANCE; + } + + private static final com.google.protobuf.Parser + PARSER = new com.google.protobuf.AbstractParser() { + @java.lang.Override + public PLedgerProjectionMembership parsePartialFrom( + com.google.protobuf.CodedInputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + Builder builder = newBuilder(); + try { + builder.mergeFrom(input, extensionRegistry); + } catch (com.google.protobuf.InvalidProtocolBufferException e) { + throw e.setUnfinishedMessage(builder.buildPartial()); + } catch (com.google.protobuf.UninitializedMessageException e) { + throw e.asInvalidProtocolBufferException().setUnfinishedMessage(builder.buildPartial()); + } catch (java.io.IOException e) { + throw new com.google.protobuf.InvalidProtocolBufferException(e) + .setUnfinishedMessage(builder.buildPartial()); + } + return builder.buildPartial(); + } + }; + + public static com.google.protobuf.Parser parser() { + return PARSER; + } + + @java.lang.Override + public com.google.protobuf.Parser getParserForType() { + return PARSER; + } + + @java.lang.Override + public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership getDefaultInstanceForType() { + return DEFAULT_INSTANCE; + } + +} diff --git a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionMembershipOrBuilder.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionMembershipOrBuilder.java new file mode 100644 index 0000000000..59a3d0218f --- /dev/null +++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionMembershipOrBuilder.java @@ -0,0 +1,32 @@ +// Generated by the protocol buffer compiler. DO NOT EDIT! +// source: client.proto + +package name.abuchen.portfolio.model.proto.v1; + +public interface PLedgerProjectionMembershipOrBuilder extends + // @@protoc_insertion_point(interface_extends:name.abuchen.portfolio.PLedgerProjectionMembership) + com.google.protobuf.MessageOrBuilder { + + /** + * string postingUUID = 1; + * @return The postingUUID. + */ + java.lang.String getPostingUUID(); + /** + * string postingUUID = 1; + * @return The bytes for postingUUID. + */ + com.google.protobuf.ByteString + getPostingUUIDBytes(); + + /** + * .name.abuchen.portfolio.PLedgerProjectionMembershipRole role = 2; + * @return The enum numeric value on the wire for role. + */ + int getRoleValue(); + /** + * .name.abuchen.portfolio.PLedgerProjectionMembershipRole role = 2; + * @return The role. + */ + name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipRole getRole(); +} diff --git a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionMembershipRole.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionMembershipRole.java new file mode 100644 index 0000000000..2c1c1907ed --- /dev/null +++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionMembershipRole.java @@ -0,0 +1,157 @@ +// Generated by the protocol buffer compiler. DO NOT EDIT! +// source: client.proto + +package name.abuchen.portfolio.model.proto.v1; + +/** + * Protobuf enum {@code name.abuchen.portfolio.PLedgerProjectionMembershipRole} + */ +public enum PLedgerProjectionMembershipRole + implements com.google.protobuf.ProtocolMessageEnum { + /** + * LEDGER_PROJECTION_MEMBERSHIP_ROLE_UNSPECIFIED = 0; + */ + LEDGER_PROJECTION_MEMBERSHIP_ROLE_UNSPECIFIED(0), + /** + * LEDGER_PROJECTION_MEMBERSHIP_ROLE_PRIMARY = 1; + */ + LEDGER_PROJECTION_MEMBERSHIP_ROLE_PRIMARY(1), + /** + * LEDGER_PROJECTION_MEMBERSHIP_ROLE_GROUP_ANCHOR = 2; + */ + LEDGER_PROJECTION_MEMBERSHIP_ROLE_GROUP_ANCHOR(2), + /** + * LEDGER_PROJECTION_MEMBERSHIP_ROLE_FEE_UNIT = 3; + */ + LEDGER_PROJECTION_MEMBERSHIP_ROLE_FEE_UNIT(3), + /** + * LEDGER_PROJECTION_MEMBERSHIP_ROLE_TAX_UNIT = 4; + */ + LEDGER_PROJECTION_MEMBERSHIP_ROLE_TAX_UNIT(4), + /** + * LEDGER_PROJECTION_MEMBERSHIP_ROLE_GROSS_VALUE_UNIT = 5; + */ + LEDGER_PROJECTION_MEMBERSHIP_ROLE_GROSS_VALUE_UNIT(5), + /** + * LEDGER_PROJECTION_MEMBERSHIP_ROLE_FOREX_CONTEXT = 6; + */ + LEDGER_PROJECTION_MEMBERSHIP_ROLE_FOREX_CONTEXT(6), + UNRECOGNIZED(-1), + ; + + /** + * LEDGER_PROJECTION_MEMBERSHIP_ROLE_UNSPECIFIED = 0; + */ + public static final int LEDGER_PROJECTION_MEMBERSHIP_ROLE_UNSPECIFIED_VALUE = 0; + /** + * LEDGER_PROJECTION_MEMBERSHIP_ROLE_PRIMARY = 1; + */ + public static final int LEDGER_PROJECTION_MEMBERSHIP_ROLE_PRIMARY_VALUE = 1; + /** + * LEDGER_PROJECTION_MEMBERSHIP_ROLE_GROUP_ANCHOR = 2; + */ + public static final int LEDGER_PROJECTION_MEMBERSHIP_ROLE_GROUP_ANCHOR_VALUE = 2; + /** + * LEDGER_PROJECTION_MEMBERSHIP_ROLE_FEE_UNIT = 3; + */ + public static final int LEDGER_PROJECTION_MEMBERSHIP_ROLE_FEE_UNIT_VALUE = 3; + /** + * LEDGER_PROJECTION_MEMBERSHIP_ROLE_TAX_UNIT = 4; + */ + public static final int LEDGER_PROJECTION_MEMBERSHIP_ROLE_TAX_UNIT_VALUE = 4; + /** + * LEDGER_PROJECTION_MEMBERSHIP_ROLE_GROSS_VALUE_UNIT = 5; + */ + public static final int LEDGER_PROJECTION_MEMBERSHIP_ROLE_GROSS_VALUE_UNIT_VALUE = 5; + /** + * LEDGER_PROJECTION_MEMBERSHIP_ROLE_FOREX_CONTEXT = 6; + */ + public static final int LEDGER_PROJECTION_MEMBERSHIP_ROLE_FOREX_CONTEXT_VALUE = 6; + + + public final int getNumber() { + if (this == UNRECOGNIZED) { + throw new java.lang.IllegalArgumentException( + "Can't get the number of an unknown enum value."); + } + return value; + } + + /** + * @param value The numeric wire value of the corresponding enum entry. + * @return The enum associated with the given numeric wire value. + * @deprecated Use {@link #forNumber(int)} instead. + */ + @java.lang.Deprecated + public static PLedgerProjectionMembershipRole valueOf(int value) { + return forNumber(value); + } + + /** + * @param value The numeric wire value of the corresponding enum entry. + * @return The enum associated with the given numeric wire value. + */ + public static PLedgerProjectionMembershipRole forNumber(int value) { + switch (value) { + case 0: return LEDGER_PROJECTION_MEMBERSHIP_ROLE_UNSPECIFIED; + case 1: return LEDGER_PROJECTION_MEMBERSHIP_ROLE_PRIMARY; + case 2: return LEDGER_PROJECTION_MEMBERSHIP_ROLE_GROUP_ANCHOR; + case 3: return LEDGER_PROJECTION_MEMBERSHIP_ROLE_FEE_UNIT; + case 4: return LEDGER_PROJECTION_MEMBERSHIP_ROLE_TAX_UNIT; + case 5: return LEDGER_PROJECTION_MEMBERSHIP_ROLE_GROSS_VALUE_UNIT; + case 6: return LEDGER_PROJECTION_MEMBERSHIP_ROLE_FOREX_CONTEXT; + default: return null; + } + } + + public static com.google.protobuf.Internal.EnumLiteMap + internalGetValueMap() { + return internalValueMap; + } + private static final com.google.protobuf.Internal.EnumLiteMap< + PLedgerProjectionMembershipRole> internalValueMap = + new com.google.protobuf.Internal.EnumLiteMap() { + public PLedgerProjectionMembershipRole findValueByNumber(int number) { + return PLedgerProjectionMembershipRole.forNumber(number); + } + }; + + public final com.google.protobuf.Descriptors.EnumValueDescriptor + getValueDescriptor() { + if (this == UNRECOGNIZED) { + throw new java.lang.IllegalStateException( + "Can't get the descriptor of an unrecognized enum value."); + } + return getDescriptor().getValues().get(ordinal()); + } + public final com.google.protobuf.Descriptors.EnumDescriptor + getDescriptorForType() { + return getDescriptor(); + } + public static final com.google.protobuf.Descriptors.EnumDescriptor + getDescriptor() { + return name.abuchen.portfolio.model.proto.v1.ClientProtos.getDescriptor().getEnumTypes().get(1); + } + + private static final PLedgerProjectionMembershipRole[] VALUES = values(); + + public static PLedgerProjectionMembershipRole valueOf( + com.google.protobuf.Descriptors.EnumValueDescriptor desc) { + if (desc.getType() != getDescriptor()) { + throw new java.lang.IllegalArgumentException( + "EnumValueDescriptor is not for this type."); + } + if (desc.getIndex() == -1) { + return UNRECOGNIZED; + } + return VALUES[desc.getIndex()]; + } + + private final int value; + + private PLedgerProjectionMembershipRole(int value) { + this.value = value; + } + + // @@protoc_insertion_point(enum_scope:name.abuchen.portfolio.PLedgerProjectionMembershipRole) +} diff --git a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionRef.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionRef.java new file mode 100644 index 0000000000..86ce2e6909 --- /dev/null +++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionRef.java @@ -0,0 +1,1271 @@ +// Generated by the protocol buffer compiler. DO NOT EDIT! +// source: client.proto + +package name.abuchen.portfolio.model.proto.v1; + +/** + * Protobuf type {@code name.abuchen.portfolio.PLedgerProjectionRef} + */ +public final class PLedgerProjectionRef extends + com.google.protobuf.GeneratedMessageV3 implements + // @@protoc_insertion_point(message_implements:name.abuchen.portfolio.PLedgerProjectionRef) + PLedgerProjectionRefOrBuilder { +private static final long serialVersionUID = 0L; + // Use PLedgerProjectionRef.newBuilder() to construct. + private PLedgerProjectionRef(com.google.protobuf.GeneratedMessageV3.Builder builder) { + super(builder); + } + private PLedgerProjectionRef() { + uuid_ = ""; + role_ = 0; + account_ = ""; + portfolio_ = ""; + memberships_ = java.util.Collections.emptyList(); + } + + @java.lang.Override + @SuppressWarnings({"unused"}) + protected java.lang.Object newInstance( + UnusedPrivateParameter unused) { + return new PLedgerProjectionRef(); + } + + public static final com.google.protobuf.Descriptors.Descriptor + getDescriptor() { + return name.abuchen.portfolio.model.proto.v1.ClientProtos.internal_static_name_abuchen_portfolio_PLedgerProjectionRef_descriptor; + } + + @java.lang.Override + protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable + internalGetFieldAccessorTable() { + return name.abuchen.portfolio.model.proto.v1.ClientProtos.internal_static_name_abuchen_portfolio_PLedgerProjectionRef_fieldAccessorTable + .ensureFieldAccessorsInitialized( + name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef.class, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef.Builder.class); + } + + private int bitField0_; + public static final int UUID_FIELD_NUMBER = 1; + @SuppressWarnings("serial") + private volatile java.lang.Object uuid_ = ""; + /** + * string uuid = 1; + * @return The uuid. + */ + @java.lang.Override + public java.lang.String getUuid() { + java.lang.Object ref = uuid_; + if (ref instanceof java.lang.String) { + return (java.lang.String) ref; + } else { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + uuid_ = s; + return s; + } + } + /** + * string uuid = 1; + * @return The bytes for uuid. + */ + @java.lang.Override + public com.google.protobuf.ByteString + getUuidBytes() { + java.lang.Object ref = uuid_; + if (ref instanceof java.lang.String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + uuid_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + + public static final int ROLE_FIELD_NUMBER = 2; + private int role_ = 0; + /** + * .name.abuchen.portfolio.PLedgerProjectionRole role = 2; + * @return The enum numeric value on the wire for role. + */ + @java.lang.Override public int getRoleValue() { + return role_; + } + /** + * .name.abuchen.portfolio.PLedgerProjectionRole role = 2; + * @return The role. + */ + @java.lang.Override public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRole getRole() { + name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRole result = name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRole.forNumber(role_); + return result == null ? name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRole.UNRECOGNIZED : result; + } + + public static final int ACCOUNT_FIELD_NUMBER = 3; + @SuppressWarnings("serial") + private volatile java.lang.Object account_ = ""; + /** + * optional string account = 3; + * @return Whether the account field is set. + */ + @java.lang.Override + public boolean hasAccount() { + return ((bitField0_ & 0x00000001) != 0); + } + /** + * optional string account = 3; + * @return The account. + */ + @java.lang.Override + public java.lang.String getAccount() { + java.lang.Object ref = account_; + if (ref instanceof java.lang.String) { + return (java.lang.String) ref; + } else { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + account_ = s; + return s; + } + } + /** + * optional string account = 3; + * @return The bytes for account. + */ + @java.lang.Override + public com.google.protobuf.ByteString + getAccountBytes() { + java.lang.Object ref = account_; + if (ref instanceof java.lang.String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + account_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + + public static final int PORTFOLIO_FIELD_NUMBER = 4; + @SuppressWarnings("serial") + private volatile java.lang.Object portfolio_ = ""; + /** + * optional string portfolio = 4; + * @return Whether the portfolio field is set. + */ + @java.lang.Override + public boolean hasPortfolio() { + return ((bitField0_ & 0x00000002) != 0); + } + /** + * optional string portfolio = 4; + * @return The portfolio. + */ + @java.lang.Override + public java.lang.String getPortfolio() { + java.lang.Object ref = portfolio_; + if (ref instanceof java.lang.String) { + return (java.lang.String) ref; + } else { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + portfolio_ = s; + return s; + } + } + /** + * optional string portfolio = 4; + * @return The bytes for portfolio. + */ + @java.lang.Override + public com.google.protobuf.ByteString + getPortfolioBytes() { + java.lang.Object ref = portfolio_; + if (ref instanceof java.lang.String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + portfolio_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + + public static final int MEMBERSHIPS_FIELD_NUMBER = 5; + @SuppressWarnings("serial") + private java.util.List memberships_; + /** + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + */ + @java.lang.Override + public java.util.List getMembershipsList() { + return memberships_; + } + /** + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + */ + @java.lang.Override + public java.util.List + getMembershipsOrBuilderList() { + return memberships_; + } + /** + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + */ + @java.lang.Override + public int getMembershipsCount() { + return memberships_.size(); + } + /** + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + */ + @java.lang.Override + public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership getMemberships(int index) { + return memberships_.get(index); + } + /** + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + */ + @java.lang.Override + public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipOrBuilder getMembershipsOrBuilder( + int index) { + return memberships_.get(index); + } + + private byte memoizedIsInitialized = -1; + @java.lang.Override + public final boolean isInitialized() { + byte isInitialized = memoizedIsInitialized; + if (isInitialized == 1) return true; + if (isInitialized == 0) return false; + + memoizedIsInitialized = 1; + return true; + } + + @java.lang.Override + public void writeTo(com.google.protobuf.CodedOutputStream output) + throws java.io.IOException { + if (!com.google.protobuf.GeneratedMessageV3.isStringEmpty(uuid_)) { + com.google.protobuf.GeneratedMessageV3.writeString(output, 1, uuid_); + } + if (role_ != name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRole.LEDGER_PROJECTION_ROLE_UNSPECIFIED.getNumber()) { + output.writeEnum(2, role_); + } + if (((bitField0_ & 0x00000001) != 0)) { + com.google.protobuf.GeneratedMessageV3.writeString(output, 3, account_); + } + if (((bitField0_ & 0x00000002) != 0)) { + com.google.protobuf.GeneratedMessageV3.writeString(output, 4, portfolio_); + } + for (int i = 0; i < memberships_.size(); i++) { + output.writeMessage(5, memberships_.get(i)); + } + getUnknownFields().writeTo(output); + } + + @java.lang.Override + public int getSerializedSize() { + int size = memoizedSize; + if (size != -1) return size; + + size = 0; + if (!com.google.protobuf.GeneratedMessageV3.isStringEmpty(uuid_)) { + size += com.google.protobuf.GeneratedMessageV3.computeStringSize(1, uuid_); + } + if (role_ != name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRole.LEDGER_PROJECTION_ROLE_UNSPECIFIED.getNumber()) { + size += com.google.protobuf.CodedOutputStream + .computeEnumSize(2, role_); + } + if (((bitField0_ & 0x00000001) != 0)) { + size += com.google.protobuf.GeneratedMessageV3.computeStringSize(3, account_); + } + if (((bitField0_ & 0x00000002) != 0)) { + size += com.google.protobuf.GeneratedMessageV3.computeStringSize(4, portfolio_); + } + for (int i = 0; i < memberships_.size(); i++) { + size += com.google.protobuf.CodedOutputStream + .computeMessageSize(5, memberships_.get(i)); + } + size += getUnknownFields().getSerializedSize(); + memoizedSize = size; + return size; + } + + @java.lang.Override + public boolean equals(final java.lang.Object obj) { + if (obj == this) { + return true; + } + if (!(obj instanceof name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef)) { + return super.equals(obj); + } + name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef other = (name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef) obj; + + if (!getUuid() + .equals(other.getUuid())) return false; + if (role_ != other.role_) return false; + if (hasAccount() != other.hasAccount()) return false; + if (hasAccount()) { + if (!getAccount() + .equals(other.getAccount())) return false; + } + if (hasPortfolio() != other.hasPortfolio()) return false; + if (hasPortfolio()) { + if (!getPortfolio() + .equals(other.getPortfolio())) return false; + } + if (!getMembershipsList() + .equals(other.getMembershipsList())) return false; + if (!getUnknownFields().equals(other.getUnknownFields())) return false; + return true; + } + + @java.lang.Override + public int hashCode() { + if (memoizedHashCode != 0) { + return memoizedHashCode; + } + int hash = 41; + hash = (19 * hash) + getDescriptor().hashCode(); + hash = (37 * hash) + UUID_FIELD_NUMBER; + hash = (53 * hash) + getUuid().hashCode(); + hash = (37 * hash) + ROLE_FIELD_NUMBER; + hash = (53 * hash) + role_; + if (hasAccount()) { + hash = (37 * hash) + ACCOUNT_FIELD_NUMBER; + hash = (53 * hash) + getAccount().hashCode(); + } + if (hasPortfolio()) { + hash = (37 * hash) + PORTFOLIO_FIELD_NUMBER; + hash = (53 * hash) + getPortfolio().hashCode(); + } + if (getMembershipsCount() > 0) { + hash = (37 * hash) + MEMBERSHIPS_FIELD_NUMBER; + hash = (53 * hash) + getMembershipsList().hashCode(); + } + hash = (29 * hash) + getUnknownFields().hashCode(); + memoizedHashCode = hash; + return hash; + } + + public static name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef parseFrom( + java.nio.ByteBuffer data) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data); + } + public static name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef parseFrom( + java.nio.ByteBuffer data, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data, extensionRegistry); + } + public static name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef parseFrom( + com.google.protobuf.ByteString data) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data); + } + public static name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef parseFrom( + com.google.protobuf.ByteString data, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data, extensionRegistry); + } + public static name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef parseFrom(byte[] data) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data); + } + public static name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef parseFrom( + byte[] data, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data, extensionRegistry); + } + public static name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef parseFrom(java.io.InputStream input) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageV3 + .parseWithIOException(PARSER, input); + } + public static name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef parseFrom( + java.io.InputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageV3 + .parseWithIOException(PARSER, input, extensionRegistry); + } + public static name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef parseDelimitedFrom(java.io.InputStream input) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageV3 + .parseDelimitedWithIOException(PARSER, input); + } + public static name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef parseDelimitedFrom( + java.io.InputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageV3 + .parseDelimitedWithIOException(PARSER, input, extensionRegistry); + } + public static name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef parseFrom( + com.google.protobuf.CodedInputStream input) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageV3 + .parseWithIOException(PARSER, input); + } + public static name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef parseFrom( + com.google.protobuf.CodedInputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageV3 + .parseWithIOException(PARSER, input, extensionRegistry); + } + + @java.lang.Override + public Builder newBuilderForType() { return newBuilder(); } + public static Builder newBuilder() { + return DEFAULT_INSTANCE.toBuilder(); + } + public static Builder newBuilder(name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef prototype) { + return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype); + } + @java.lang.Override + public Builder toBuilder() { + return this == DEFAULT_INSTANCE + ? new Builder() : new Builder().mergeFrom(this); + } + + @java.lang.Override + protected Builder newBuilderForType( + com.google.protobuf.GeneratedMessageV3.BuilderParent parent) { + Builder builder = new Builder(parent); + return builder; + } + /** + * Protobuf type {@code name.abuchen.portfolio.PLedgerProjectionRef} + */ + public static final class Builder extends + com.google.protobuf.GeneratedMessageV3.Builder implements + // @@protoc_insertion_point(builder_implements:name.abuchen.portfolio.PLedgerProjectionRef) + name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRefOrBuilder { + public static final com.google.protobuf.Descriptors.Descriptor + getDescriptor() { + return name.abuchen.portfolio.model.proto.v1.ClientProtos.internal_static_name_abuchen_portfolio_PLedgerProjectionRef_descriptor; + } + + @java.lang.Override + protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable + internalGetFieldAccessorTable() { + return name.abuchen.portfolio.model.proto.v1.ClientProtos.internal_static_name_abuchen_portfolio_PLedgerProjectionRef_fieldAccessorTable + .ensureFieldAccessorsInitialized( + name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef.class, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef.Builder.class); + } + + // Construct using name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef.newBuilder() + private Builder() { + + } + + private Builder( + com.google.protobuf.GeneratedMessageV3.BuilderParent parent) { + super(parent); + + } + @java.lang.Override + public Builder clear() { + super.clear(); + bitField0_ = 0; + uuid_ = ""; + role_ = 0; + account_ = ""; + portfolio_ = ""; + if (membershipsBuilder_ == null) { + memberships_ = java.util.Collections.emptyList(); + } else { + memberships_ = null; + membershipsBuilder_.clear(); + } + bitField0_ = (bitField0_ & ~0x00000010); + return this; + } + + @java.lang.Override + public com.google.protobuf.Descriptors.Descriptor + getDescriptorForType() { + return name.abuchen.portfolio.model.proto.v1.ClientProtos.internal_static_name_abuchen_portfolio_PLedgerProjectionRef_descriptor; + } + + @java.lang.Override + public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef getDefaultInstanceForType() { + return name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef.getDefaultInstance(); + } + + @java.lang.Override + public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef build() { + name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef result = buildPartial(); + if (!result.isInitialized()) { + throw newUninitializedMessageException(result); + } + return result; + } + + @java.lang.Override + public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef buildPartial() { + name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef result = new name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef(this); + buildPartialRepeatedFields(result); + if (bitField0_ != 0) { buildPartial0(result); } + onBuilt(); + return result; + } + + private void buildPartialRepeatedFields(name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef result) { + if (membershipsBuilder_ == null) { + if (((bitField0_ & 0x00000010) != 0)) { + memberships_ = java.util.Collections.unmodifiableList(memberships_); + bitField0_ = (bitField0_ & ~0x00000010); + } + result.memberships_ = memberships_; + } else { + result.memberships_ = membershipsBuilder_.build(); + } + } + + private void buildPartial0(name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef result) { + int from_bitField0_ = bitField0_; + if (((from_bitField0_ & 0x00000001) != 0)) { + result.uuid_ = uuid_; + } + if (((from_bitField0_ & 0x00000002) != 0)) { + result.role_ = role_; + } + int to_bitField0_ = 0; + if (((from_bitField0_ & 0x00000004) != 0)) { + result.account_ = account_; + to_bitField0_ |= 0x00000001; + } + if (((from_bitField0_ & 0x00000008) != 0)) { + result.portfolio_ = portfolio_; + to_bitField0_ |= 0x00000002; + } + result.bitField0_ |= to_bitField0_; + } + + @java.lang.Override + public Builder mergeFrom(com.google.protobuf.Message other) { + if (other instanceof name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef) { + return mergeFrom((name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef)other); + } else { + super.mergeFrom(other); + return this; + } + } + + public Builder mergeFrom(name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef other) { + if (other == name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef.getDefaultInstance()) return this; + if (!other.getUuid().isEmpty()) { + uuid_ = other.uuid_; + bitField0_ |= 0x00000001; + onChanged(); + } + if (other.role_ != 0) { + setRoleValue(other.getRoleValue()); + } + if (other.hasAccount()) { + account_ = other.account_; + bitField0_ |= 0x00000004; + onChanged(); + } + if (other.hasPortfolio()) { + portfolio_ = other.portfolio_; + bitField0_ |= 0x00000008; + onChanged(); + } + if (membershipsBuilder_ == null) { + if (!other.memberships_.isEmpty()) { + if (memberships_.isEmpty()) { + memberships_ = other.memberships_; + bitField0_ = (bitField0_ & ~0x00000010); + } else { + ensureMembershipsIsMutable(); + memberships_.addAll(other.memberships_); + } + onChanged(); + } + } else { + if (!other.memberships_.isEmpty()) { + if (membershipsBuilder_.isEmpty()) { + membershipsBuilder_.dispose(); + membershipsBuilder_ = null; + memberships_ = other.memberships_; + bitField0_ = (bitField0_ & ~0x00000010); + membershipsBuilder_ = + com.google.protobuf.GeneratedMessageV3.alwaysUseFieldBuilders ? + getMembershipsFieldBuilder() : null; + } else { + membershipsBuilder_.addAllMessages(other.memberships_); + } + } + } + this.mergeUnknownFields(other.getUnknownFields()); + onChanged(); + return this; + } + + @java.lang.Override + public final boolean isInitialized() { + return true; + } + + @java.lang.Override + public Builder mergeFrom( + com.google.protobuf.CodedInputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + if (extensionRegistry == null) { + throw new java.lang.NullPointerException(); + } + try { + boolean done = false; + while (!done) { + int tag = input.readTag(); + switch (tag) { + case 0: + done = true; + break; + case 10: { + uuid_ = input.readStringRequireUtf8(); + bitField0_ |= 0x00000001; + break; + } // case 10 + case 16: { + role_ = input.readEnum(); + bitField0_ |= 0x00000002; + break; + } // case 16 + case 26: { + account_ = input.readStringRequireUtf8(); + bitField0_ |= 0x00000004; + break; + } // case 26 + case 34: { + portfolio_ = input.readStringRequireUtf8(); + bitField0_ |= 0x00000008; + break; + } // case 34 + case 42: { + name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership m = + input.readMessage( + name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership.parser(), + extensionRegistry); + if (membershipsBuilder_ == null) { + ensureMembershipsIsMutable(); + memberships_.add(m); + } else { + membershipsBuilder_.addMessage(m); + } + break; + } // case 42 + default: { + if (!super.parseUnknownField(input, extensionRegistry, tag)) { + done = true; // was an endgroup tag + } + break; + } // default: + } // switch (tag) + } // while (!done) + } catch (com.google.protobuf.InvalidProtocolBufferException e) { + throw e.unwrapIOException(); + } finally { + onChanged(); + } // finally + return this; + } + private int bitField0_; + + private java.lang.Object uuid_ = ""; + /** + * string uuid = 1; + * @return The uuid. + */ + public java.lang.String getUuid() { + java.lang.Object ref = uuid_; + if (!(ref instanceof java.lang.String)) { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + uuid_ = s; + return s; + } else { + return (java.lang.String) ref; + } + } + /** + * string uuid = 1; + * @return The bytes for uuid. + */ + public com.google.protobuf.ByteString + getUuidBytes() { + java.lang.Object ref = uuid_; + if (ref instanceof String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + uuid_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + /** + * string uuid = 1; + * @param value The uuid to set. + * @return This builder for chaining. + */ + public Builder setUuid( + java.lang.String value) { + if (value == null) { throw new NullPointerException(); } + uuid_ = value; + bitField0_ |= 0x00000001; + onChanged(); + return this; + } + /** + * string uuid = 1; + * @return This builder for chaining. + */ + public Builder clearUuid() { + uuid_ = getDefaultInstance().getUuid(); + bitField0_ = (bitField0_ & ~0x00000001); + onChanged(); + return this; + } + /** + * string uuid = 1; + * @param value The bytes for uuid to set. + * @return This builder for chaining. + */ + public Builder setUuidBytes( + com.google.protobuf.ByteString value) { + if (value == null) { throw new NullPointerException(); } + checkByteStringIsUtf8(value); + uuid_ = value; + bitField0_ |= 0x00000001; + onChanged(); + return this; + } + + private int role_ = 0; + /** + * .name.abuchen.portfolio.PLedgerProjectionRole role = 2; + * @return The enum numeric value on the wire for role. + */ + @java.lang.Override public int getRoleValue() { + return role_; + } + /** + * .name.abuchen.portfolio.PLedgerProjectionRole role = 2; + * @param value The enum numeric value on the wire for role to set. + * @return This builder for chaining. + */ + public Builder setRoleValue(int value) { + role_ = value; + bitField0_ |= 0x00000002; + onChanged(); + return this; + } + /** + * .name.abuchen.portfolio.PLedgerProjectionRole role = 2; + * @return The role. + */ + @java.lang.Override + public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRole getRole() { + name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRole result = name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRole.forNumber(role_); + return result == null ? name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRole.UNRECOGNIZED : result; + } + /** + * .name.abuchen.portfolio.PLedgerProjectionRole role = 2; + * @param value The role to set. + * @return This builder for chaining. + */ + public Builder setRole(name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRole value) { + if (value == null) { + throw new NullPointerException(); + } + bitField0_ |= 0x00000002; + role_ = value.getNumber(); + onChanged(); + return this; + } + /** + * .name.abuchen.portfolio.PLedgerProjectionRole role = 2; + * @return This builder for chaining. + */ + public Builder clearRole() { + bitField0_ = (bitField0_ & ~0x00000002); + role_ = 0; + onChanged(); + return this; + } + + private java.lang.Object account_ = ""; + /** + * optional string account = 3; + * @return Whether the account field is set. + */ + public boolean hasAccount() { + return ((bitField0_ & 0x00000004) != 0); + } + /** + * optional string account = 3; + * @return The account. + */ + public java.lang.String getAccount() { + java.lang.Object ref = account_; + if (!(ref instanceof java.lang.String)) { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + account_ = s; + return s; + } else { + return (java.lang.String) ref; + } + } + /** + * optional string account = 3; + * @return The bytes for account. + */ + public com.google.protobuf.ByteString + getAccountBytes() { + java.lang.Object ref = account_; + if (ref instanceof String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + account_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + /** + * optional string account = 3; + * @param value The account to set. + * @return This builder for chaining. + */ + public Builder setAccount( + java.lang.String value) { + if (value == null) { throw new NullPointerException(); } + account_ = value; + bitField0_ |= 0x00000004; + onChanged(); + return this; + } + /** + * optional string account = 3; + * @return This builder for chaining. + */ + public Builder clearAccount() { + account_ = getDefaultInstance().getAccount(); + bitField0_ = (bitField0_ & ~0x00000004); + onChanged(); + return this; + } + /** + * optional string account = 3; + * @param value The bytes for account to set. + * @return This builder for chaining. + */ + public Builder setAccountBytes( + com.google.protobuf.ByteString value) { + if (value == null) { throw new NullPointerException(); } + checkByteStringIsUtf8(value); + account_ = value; + bitField0_ |= 0x00000004; + onChanged(); + return this; + } + + private java.lang.Object portfolio_ = ""; + /** + * optional string portfolio = 4; + * @return Whether the portfolio field is set. + */ + public boolean hasPortfolio() { + return ((bitField0_ & 0x00000008) != 0); + } + /** + * optional string portfolio = 4; + * @return The portfolio. + */ + public java.lang.String getPortfolio() { + java.lang.Object ref = portfolio_; + if (!(ref instanceof java.lang.String)) { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + portfolio_ = s; + return s; + } else { + return (java.lang.String) ref; + } + } + /** + * optional string portfolio = 4; + * @return The bytes for portfolio. + */ + public com.google.protobuf.ByteString + getPortfolioBytes() { + java.lang.Object ref = portfolio_; + if (ref instanceof String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + portfolio_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + /** + * optional string portfolio = 4; + * @param value The portfolio to set. + * @return This builder for chaining. + */ + public Builder setPortfolio( + java.lang.String value) { + if (value == null) { throw new NullPointerException(); } + portfolio_ = value; + bitField0_ |= 0x00000008; + onChanged(); + return this; + } + /** + * optional string portfolio = 4; + * @return This builder for chaining. + */ + public Builder clearPortfolio() { + portfolio_ = getDefaultInstance().getPortfolio(); + bitField0_ = (bitField0_ & ~0x00000008); + onChanged(); + return this; + } + /** + * optional string portfolio = 4; + * @param value The bytes for portfolio to set. + * @return This builder for chaining. + */ + public Builder setPortfolioBytes( + com.google.protobuf.ByteString value) { + if (value == null) { throw new NullPointerException(); } + checkByteStringIsUtf8(value); + portfolio_ = value; + bitField0_ |= 0x00000008; + onChanged(); + return this; + } + + private java.util.List memberships_ = + java.util.Collections.emptyList(); + private void ensureMembershipsIsMutable() { + if (!((bitField0_ & 0x00000010) != 0)) { + memberships_ = new java.util.ArrayList(memberships_); + bitField0_ |= 0x00000010; + } + } + + private com.google.protobuf.RepeatedFieldBuilderV3< + name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership.Builder, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipOrBuilder> membershipsBuilder_; + + /** + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + */ + public java.util.List getMembershipsList() { + if (membershipsBuilder_ == null) { + return java.util.Collections.unmodifiableList(memberships_); + } else { + return membershipsBuilder_.getMessageList(); + } + } + /** + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + */ + public int getMembershipsCount() { + if (membershipsBuilder_ == null) { + return memberships_.size(); + } else { + return membershipsBuilder_.getCount(); + } + } + /** + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + */ + public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership getMemberships(int index) { + if (membershipsBuilder_ == null) { + return memberships_.get(index); + } else { + return membershipsBuilder_.getMessage(index); + } + } + /** + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + */ + public Builder setMemberships( + int index, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership value) { + if (membershipsBuilder_ == null) { + if (value == null) { + throw new NullPointerException(); + } + ensureMembershipsIsMutable(); + memberships_.set(index, value); + onChanged(); + } else { + membershipsBuilder_.setMessage(index, value); + } + return this; + } + /** + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + */ + public Builder setMemberships( + int index, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership.Builder builderForValue) { + if (membershipsBuilder_ == null) { + ensureMembershipsIsMutable(); + memberships_.set(index, builderForValue.build()); + onChanged(); + } else { + membershipsBuilder_.setMessage(index, builderForValue.build()); + } + return this; + } + /** + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + */ + public Builder addMemberships(name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership value) { + if (membershipsBuilder_ == null) { + if (value == null) { + throw new NullPointerException(); + } + ensureMembershipsIsMutable(); + memberships_.add(value); + onChanged(); + } else { + membershipsBuilder_.addMessage(value); + } + return this; + } + /** + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + */ + public Builder addMemberships( + int index, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership value) { + if (membershipsBuilder_ == null) { + if (value == null) { + throw new NullPointerException(); + } + ensureMembershipsIsMutable(); + memberships_.add(index, value); + onChanged(); + } else { + membershipsBuilder_.addMessage(index, value); + } + return this; + } + /** + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + */ + public Builder addMemberships( + name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership.Builder builderForValue) { + if (membershipsBuilder_ == null) { + ensureMembershipsIsMutable(); + memberships_.add(builderForValue.build()); + onChanged(); + } else { + membershipsBuilder_.addMessage(builderForValue.build()); + } + return this; + } + /** + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + */ + public Builder addMemberships( + int index, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership.Builder builderForValue) { + if (membershipsBuilder_ == null) { + ensureMembershipsIsMutable(); + memberships_.add(index, builderForValue.build()); + onChanged(); + } else { + membershipsBuilder_.addMessage(index, builderForValue.build()); + } + return this; + } + /** + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + */ + public Builder addAllMemberships( + java.lang.Iterable values) { + if (membershipsBuilder_ == null) { + ensureMembershipsIsMutable(); + com.google.protobuf.AbstractMessageLite.Builder.addAll( + values, memberships_); + onChanged(); + } else { + membershipsBuilder_.addAllMessages(values); + } + return this; + } + /** + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + */ + public Builder clearMemberships() { + if (membershipsBuilder_ == null) { + memberships_ = java.util.Collections.emptyList(); + bitField0_ = (bitField0_ & ~0x00000010); + onChanged(); + } else { + membershipsBuilder_.clear(); + } + return this; + } + /** + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + */ + public Builder removeMemberships(int index) { + if (membershipsBuilder_ == null) { + ensureMembershipsIsMutable(); + memberships_.remove(index); + onChanged(); + } else { + membershipsBuilder_.remove(index); + } + return this; + } + /** + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + */ + public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership.Builder getMembershipsBuilder( + int index) { + return getMembershipsFieldBuilder().getBuilder(index); + } + /** + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + */ + public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipOrBuilder getMembershipsOrBuilder( + int index) { + if (membershipsBuilder_ == null) { + return memberships_.get(index); } else { + return membershipsBuilder_.getMessageOrBuilder(index); + } + } + /** + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + */ + public java.util.List + getMembershipsOrBuilderList() { + if (membershipsBuilder_ != null) { + return membershipsBuilder_.getMessageOrBuilderList(); + } else { + return java.util.Collections.unmodifiableList(memberships_); + } + } + /** + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + */ + public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership.Builder addMembershipsBuilder() { + return getMembershipsFieldBuilder().addBuilder( + name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership.getDefaultInstance()); + } + /** + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + */ + public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership.Builder addMembershipsBuilder( + int index) { + return getMembershipsFieldBuilder().addBuilder( + index, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership.getDefaultInstance()); + } + /** + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + */ + public java.util.List + getMembershipsBuilderList() { + return getMembershipsFieldBuilder().getBuilderList(); + } + private com.google.protobuf.RepeatedFieldBuilderV3< + name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership.Builder, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipOrBuilder> + getMembershipsFieldBuilder() { + if (membershipsBuilder_ == null) { + membershipsBuilder_ = new com.google.protobuf.RepeatedFieldBuilderV3< + name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership.Builder, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipOrBuilder>( + memberships_, + ((bitField0_ & 0x00000010) != 0), + getParentForChildren(), + isClean()); + memberships_ = null; + } + return membershipsBuilder_; + } + @java.lang.Override + public final Builder setUnknownFields( + final com.google.protobuf.UnknownFieldSet unknownFields) { + return super.setUnknownFields(unknownFields); + } + + @java.lang.Override + public final Builder mergeUnknownFields( + final com.google.protobuf.UnknownFieldSet unknownFields) { + return super.mergeUnknownFields(unknownFields); + } + + + // @@protoc_insertion_point(builder_scope:name.abuchen.portfolio.PLedgerProjectionRef) + } + + // @@protoc_insertion_point(class_scope:name.abuchen.portfolio.PLedgerProjectionRef) + private static final name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef DEFAULT_INSTANCE; + static { + DEFAULT_INSTANCE = new name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef(); + } + + public static name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef getDefaultInstance() { + return DEFAULT_INSTANCE; + } + + private static final com.google.protobuf.Parser + PARSER = new com.google.protobuf.AbstractParser() { + @java.lang.Override + public PLedgerProjectionRef parsePartialFrom( + com.google.protobuf.CodedInputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + Builder builder = newBuilder(); + try { + builder.mergeFrom(input, extensionRegistry); + } catch (com.google.protobuf.InvalidProtocolBufferException e) { + throw e.setUnfinishedMessage(builder.buildPartial()); + } catch (com.google.protobuf.UninitializedMessageException e) { + throw e.asInvalidProtocolBufferException().setUnfinishedMessage(builder.buildPartial()); + } catch (java.io.IOException e) { + throw new com.google.protobuf.InvalidProtocolBufferException(e) + .setUnfinishedMessage(builder.buildPartial()); + } + return builder.buildPartial(); + } + }; + + public static com.google.protobuf.Parser parser() { + return PARSER; + } + + @java.lang.Override + public com.google.protobuf.Parser getParserForType() { + return PARSER; + } + + @java.lang.Override + public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef getDefaultInstanceForType() { + return DEFAULT_INSTANCE; + } + +} diff --git a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionRefOrBuilder.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionRefOrBuilder.java new file mode 100644 index 0000000000..937efd21b1 --- /dev/null +++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionRefOrBuilder.java @@ -0,0 +1,90 @@ +// Generated by the protocol buffer compiler. DO NOT EDIT! +// source: client.proto + +package name.abuchen.portfolio.model.proto.v1; + +public interface PLedgerProjectionRefOrBuilder extends + // @@protoc_insertion_point(interface_extends:name.abuchen.portfolio.PLedgerProjectionRef) + com.google.protobuf.MessageOrBuilder { + + /** + * string uuid = 1; + * @return The uuid. + */ + java.lang.String getUuid(); + /** + * string uuid = 1; + * @return The bytes for uuid. + */ + com.google.protobuf.ByteString + getUuidBytes(); + + /** + * .name.abuchen.portfolio.PLedgerProjectionRole role = 2; + * @return The enum numeric value on the wire for role. + */ + int getRoleValue(); + /** + * .name.abuchen.portfolio.PLedgerProjectionRole role = 2; + * @return The role. + */ + name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRole getRole(); + + /** + * optional string account = 3; + * @return Whether the account field is set. + */ + boolean hasAccount(); + /** + * optional string account = 3; + * @return The account. + */ + java.lang.String getAccount(); + /** + * optional string account = 3; + * @return The bytes for account. + */ + com.google.protobuf.ByteString + getAccountBytes(); + + /** + * optional string portfolio = 4; + * @return Whether the portfolio field is set. + */ + boolean hasPortfolio(); + /** + * optional string portfolio = 4; + * @return The portfolio. + */ + java.lang.String getPortfolio(); + /** + * optional string portfolio = 4; + * @return The bytes for portfolio. + */ + com.google.protobuf.ByteString + getPortfolioBytes(); + + /** + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + */ + java.util.List + getMembershipsList(); + /** + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + */ + name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership getMemberships(int index); + /** + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + */ + int getMembershipsCount(); + /** + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + */ + java.util.List + getMembershipsOrBuilderList(); + /** + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + */ + name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipOrBuilder getMembershipsOrBuilder( + int index); +} diff --git a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionRole.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionRole.java new file mode 100644 index 0000000000..e606be939c --- /dev/null +++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionRole.java @@ -0,0 +1,212 @@ +// Generated by the protocol buffer compiler. DO NOT EDIT! +// source: client.proto + +package name.abuchen.portfolio.model.proto.v1; + +/** + * Protobuf enum {@code name.abuchen.portfolio.PLedgerProjectionRole} + */ +public enum PLedgerProjectionRole + implements com.google.protobuf.ProtocolMessageEnum { + /** + * LEDGER_PROJECTION_ROLE_UNSPECIFIED = 0; + */ + LEDGER_PROJECTION_ROLE_UNSPECIFIED(0), + /** + * LEDGER_PROJECTION_ROLE_ACCOUNT = 1; + */ + LEDGER_PROJECTION_ROLE_ACCOUNT(1), + /** + * LEDGER_PROJECTION_ROLE_PORTFOLIO = 2; + */ + LEDGER_PROJECTION_ROLE_PORTFOLIO(2), + /** + * LEDGER_PROJECTION_ROLE_SOURCE_ACCOUNT = 3; + */ + LEDGER_PROJECTION_ROLE_SOURCE_ACCOUNT(3), + /** + * LEDGER_PROJECTION_ROLE_TARGET_ACCOUNT = 4; + */ + LEDGER_PROJECTION_ROLE_TARGET_ACCOUNT(4), + /** + * LEDGER_PROJECTION_ROLE_SOURCE_PORTFOLIO = 5; + */ + LEDGER_PROJECTION_ROLE_SOURCE_PORTFOLIO(5), + /** + * LEDGER_PROJECTION_ROLE_TARGET_PORTFOLIO = 6; + */ + LEDGER_PROJECTION_ROLE_TARGET_PORTFOLIO(6), + /** + * LEDGER_PROJECTION_ROLE_DELIVERY = 7; + */ + LEDGER_PROJECTION_ROLE_DELIVERY(7), + /** + * LEDGER_PROJECTION_ROLE_DELIVERY_INBOUND = 8; + */ + LEDGER_PROJECTION_ROLE_DELIVERY_INBOUND(8), + /** + * LEDGER_PROJECTION_ROLE_DELIVERY_OUTBOUND = 9; + */ + LEDGER_PROJECTION_ROLE_DELIVERY_OUTBOUND(9), + /** + * LEDGER_PROJECTION_ROLE_CASH_COMPENSATION = 10; + */ + LEDGER_PROJECTION_ROLE_CASH_COMPENSATION(10), + /** + * LEDGER_PROJECTION_ROLE_OLD_SECURITY_LEG = 11; + */ + LEDGER_PROJECTION_ROLE_OLD_SECURITY_LEG(11), + /** + * LEDGER_PROJECTION_ROLE_NEW_SECURITY_LEG = 12; + */ + LEDGER_PROJECTION_ROLE_NEW_SECURITY_LEG(12), + UNRECOGNIZED(-1), + ; + + /** + * LEDGER_PROJECTION_ROLE_UNSPECIFIED = 0; + */ + public static final int LEDGER_PROJECTION_ROLE_UNSPECIFIED_VALUE = 0; + /** + * LEDGER_PROJECTION_ROLE_ACCOUNT = 1; + */ + public static final int LEDGER_PROJECTION_ROLE_ACCOUNT_VALUE = 1; + /** + * LEDGER_PROJECTION_ROLE_PORTFOLIO = 2; + */ + public static final int LEDGER_PROJECTION_ROLE_PORTFOLIO_VALUE = 2; + /** + * LEDGER_PROJECTION_ROLE_SOURCE_ACCOUNT = 3; + */ + public static final int LEDGER_PROJECTION_ROLE_SOURCE_ACCOUNT_VALUE = 3; + /** + * LEDGER_PROJECTION_ROLE_TARGET_ACCOUNT = 4; + */ + public static final int LEDGER_PROJECTION_ROLE_TARGET_ACCOUNT_VALUE = 4; + /** + * LEDGER_PROJECTION_ROLE_SOURCE_PORTFOLIO = 5; + */ + public static final int LEDGER_PROJECTION_ROLE_SOURCE_PORTFOLIO_VALUE = 5; + /** + * LEDGER_PROJECTION_ROLE_TARGET_PORTFOLIO = 6; + */ + public static final int LEDGER_PROJECTION_ROLE_TARGET_PORTFOLIO_VALUE = 6; + /** + * LEDGER_PROJECTION_ROLE_DELIVERY = 7; + */ + public static final int LEDGER_PROJECTION_ROLE_DELIVERY_VALUE = 7; + /** + * LEDGER_PROJECTION_ROLE_DELIVERY_INBOUND = 8; + */ + public static final int LEDGER_PROJECTION_ROLE_DELIVERY_INBOUND_VALUE = 8; + /** + * LEDGER_PROJECTION_ROLE_DELIVERY_OUTBOUND = 9; + */ + public static final int LEDGER_PROJECTION_ROLE_DELIVERY_OUTBOUND_VALUE = 9; + /** + * LEDGER_PROJECTION_ROLE_CASH_COMPENSATION = 10; + */ + public static final int LEDGER_PROJECTION_ROLE_CASH_COMPENSATION_VALUE = 10; + /** + * LEDGER_PROJECTION_ROLE_OLD_SECURITY_LEG = 11; + */ + public static final int LEDGER_PROJECTION_ROLE_OLD_SECURITY_LEG_VALUE = 11; + /** + * LEDGER_PROJECTION_ROLE_NEW_SECURITY_LEG = 12; + */ + public static final int LEDGER_PROJECTION_ROLE_NEW_SECURITY_LEG_VALUE = 12; + + + public final int getNumber() { + if (this == UNRECOGNIZED) { + throw new java.lang.IllegalArgumentException( + "Can't get the number of an unknown enum value."); + } + return value; + } + + /** + * @param value The numeric wire value of the corresponding enum entry. + * @return The enum associated with the given numeric wire value. + * @deprecated Use {@link #forNumber(int)} instead. + */ + @java.lang.Deprecated + public static PLedgerProjectionRole valueOf(int value) { + return forNumber(value); + } + + /** + * @param value The numeric wire value of the corresponding enum entry. + * @return The enum associated with the given numeric wire value. + */ + public static PLedgerProjectionRole forNumber(int value) { + switch (value) { + case 0: return LEDGER_PROJECTION_ROLE_UNSPECIFIED; + case 1: return LEDGER_PROJECTION_ROLE_ACCOUNT; + case 2: return LEDGER_PROJECTION_ROLE_PORTFOLIO; + case 3: return LEDGER_PROJECTION_ROLE_SOURCE_ACCOUNT; + case 4: return LEDGER_PROJECTION_ROLE_TARGET_ACCOUNT; + case 5: return LEDGER_PROJECTION_ROLE_SOURCE_PORTFOLIO; + case 6: return LEDGER_PROJECTION_ROLE_TARGET_PORTFOLIO; + case 7: return LEDGER_PROJECTION_ROLE_DELIVERY; + case 8: return LEDGER_PROJECTION_ROLE_DELIVERY_INBOUND; + case 9: return LEDGER_PROJECTION_ROLE_DELIVERY_OUTBOUND; + case 10: return LEDGER_PROJECTION_ROLE_CASH_COMPENSATION; + case 11: return LEDGER_PROJECTION_ROLE_OLD_SECURITY_LEG; + case 12: return LEDGER_PROJECTION_ROLE_NEW_SECURITY_LEG; + default: return null; + } + } + + public static com.google.protobuf.Internal.EnumLiteMap + internalGetValueMap() { + return internalValueMap; + } + private static final com.google.protobuf.Internal.EnumLiteMap< + PLedgerProjectionRole> internalValueMap = + new com.google.protobuf.Internal.EnumLiteMap() { + public PLedgerProjectionRole findValueByNumber(int number) { + return PLedgerProjectionRole.forNumber(number); + } + }; + + public final com.google.protobuf.Descriptors.EnumValueDescriptor + getValueDescriptor() { + if (this == UNRECOGNIZED) { + throw new java.lang.IllegalStateException( + "Can't get the descriptor of an unrecognized enum value."); + } + return getDescriptor().getValues().get(ordinal()); + } + public final com.google.protobuf.Descriptors.EnumDescriptor + getDescriptorForType() { + return getDescriptor(); + } + public static final com.google.protobuf.Descriptors.EnumDescriptor + getDescriptor() { + return name.abuchen.portfolio.model.proto.v1.ClientProtos.getDescriptor().getEnumTypes().get(0); + } + + private static final PLedgerProjectionRole[] VALUES = values(); + + public static PLedgerProjectionRole valueOf( + com.google.protobuf.Descriptors.EnumValueDescriptor desc) { + if (desc.getType() != getDescriptor()) { + throw new java.lang.IllegalArgumentException( + "EnumValueDescriptor is not for this type."); + } + if (desc.getIndex() == -1) { + return UNRECOGNIZED; + } + return VALUES[desc.getIndex()]; + } + + private final int value; + + private PLedgerProjectionRole(int value) { + this.value = value; + } + + // @@protoc_insertion_point(enum_scope:name.abuchen.portfolio.PLedgerProjectionRole) +} + diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ClientFactory.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ClientFactory.java index fb3c1184a1..b1452ae3e7 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ClientFactory.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ClientFactory.java @@ -20,6 +20,10 @@ import java.nio.channels.FileChannel; import java.nio.channels.FileLock; import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.security.AlgorithmParameters; import java.security.GeneralSecurityException; import java.security.NoSuchAlgorithmException; @@ -67,17 +71,40 @@ import com.google.common.base.Strings; import com.thoughtworks.xstream.XStream; import com.thoughtworks.xstream.XStreamException; +import com.thoughtworks.xstream.converters.Converter; +import com.thoughtworks.xstream.converters.MarshallingContext; +import com.thoughtworks.xstream.converters.UnmarshallingContext; import com.thoughtworks.xstream.converters.collections.MapConverter; import com.thoughtworks.xstream.converters.reflection.ReflectionConverter; import com.thoughtworks.xstream.converters.reflection.ReflectionProvider; +import com.thoughtworks.xstream.io.HierarchicalStreamReader; +import com.thoughtworks.xstream.io.HierarchicalStreamWriter; import com.thoughtworks.xstream.mapper.Mapper; import name.abuchen.portfolio.Messages; import name.abuchen.portfolio.PortfolioLog; import name.abuchen.portfolio.model.AttributeType.ImageConverter; import name.abuchen.portfolio.model.Classification.Assignment; +import name.abuchen.portfolio.model.InvestmentPlan.LedgerExecutionRef; import name.abuchen.portfolio.model.PortfolioTransaction.Type; import name.abuchen.portfolio.model.Transaction.Unit; +import name.abuchen.portfolio.model.ledger.Ledger; +import name.abuchen.portfolio.model.ledger.LedgerDiagnosticMessageFormatter; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerParameter; +import name.abuchen.portfolio.model.ledger.LedgerParameter.ValueKind; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; +import name.abuchen.portfolio.model.ledger.ProjectionMembership; +import name.abuchen.portfolio.model.ledger.ProjectionMembershipRole; +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.model.ledger.legacy.LegacyTransactionToLedgerMigrator; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionService; import name.abuchen.portfolio.money.CurrencyUnit; import name.abuchen.portfolio.money.Money; import name.abuchen.portfolio.money.Values; @@ -116,6 +143,513 @@ protected boolean shouldUnmarshalField(Field field) } + private static Optional readAttribute(HierarchicalStreamReader reader, String name) + { + return Optional.ofNullable(reader.getAttribute(name)); + } + + private static void writeAttribute(HierarchicalStreamWriter writer, String name, Object value) + { + if (value != null) + writer.addAttribute(name, String.valueOf(value)); + } + + private static void writeValue(HierarchicalStreamWriter writer, String nodeName, String value) + { + if (value == null) + return; + + writer.startNode(nodeName); + writer.setValue(value); + writer.endNode(); + } + + private static void writeObject(HierarchicalStreamWriter writer, MarshallingContext context, String nodeName, + Object value) + { + if (value == null) + return; + + writer.startNode(nodeName); + context.convertAnother(value); + writer.endNode(); + } + + private static void writeCollection(HierarchicalStreamWriter writer, MarshallingContext context, + String collectionNodeName, String itemNodeName, List values) + { + writer.startNode(collectionNodeName); + + for (var value : values) + writeObject(writer, context, itemNodeName, value); + + writer.endNode(); + } + + private static void writeParameters(HierarchicalStreamWriter writer, MarshallingContext context, + List> parameters) + { + if (!parameters.isEmpty()) + writeCollection(writer, context, "parameters", "ledger-parameter", parameters); //$NON-NLS-1$ //$NON-NLS-2$ + } + + private static class LedgerProjectionRefConverter implements Converter + { + @Override + public boolean canConvert(@SuppressWarnings("rawtypes") Class type) + { + return type == LedgerProjectionRef.class; + } + + @Override + public void marshal(Object source, HierarchicalStreamWriter writer, MarshallingContext context) + { + var projectionRef = (LedgerProjectionRef) source; + + writeAttribute(writer, "uuid", projectionRef.getUUID()); //$NON-NLS-1$ + writeAttribute(writer, "role", projectionRef.getRole()); //$NON-NLS-1$ + writeObject(writer, context, "account", projectionRef.getAccount()); //$NON-NLS-1$ + writeObject(writer, context, "portfolio", projectionRef.getPortfolio()); //$NON-NLS-1$ + + if (!projectionRef.getMemberships().isEmpty()) + { + writer.startNode("memberships"); //$NON-NLS-1$ + for (ProjectionMembership membership : projectionRef.getMemberships()) + writeObject(writer, context, "membership", membership); //$NON-NLS-1$ + writer.endNode(); + } + } + + @Override + public Object unmarshal(HierarchicalStreamReader reader, UnmarshallingContext context) + { + var projectionRef = new LedgerProjectionRef(); + var uuid = reader.getAttribute("uuid"); //$NON-NLS-1$ + var role = reader.getAttribute("role"); //$NON-NLS-1$ + + if (uuid != null) + projectionRef.setUUID(uuid); + if (role != null) + projectionRef.setRole(LedgerProjectionRole.valueOf(role)); + + while (reader.hasMoreChildren()) + { + reader.moveDown(); + + switch (reader.getNodeName()) + { + case "uuid" -> projectionRef.setUUID(reader.getValue()); //$NON-NLS-1$ + case "role" -> projectionRef.setRole((LedgerProjectionRole) context.convertAnother(projectionRef, //$NON-NLS-1$ + LedgerProjectionRole.class)); + case "account" -> projectionRef.setAccount((Account) context.convertAnother(projectionRef, //$NON-NLS-1$ + Account.class)); + case "portfolio" -> projectionRef.setPortfolio((Portfolio) context.convertAnother(projectionRef, //$NON-NLS-1$ + Portfolio.class)); + case "primaryPostingUUID" -> projectionRef.setPrimaryPostingUUID(reader.getValue()); //$NON-NLS-1$ + case "postingGroupUUID" -> projectionRef.setPostingGroupUUID(reader.getValue()); //$NON-NLS-1$ + case "memberships" -> readMemberships(reader, context, projectionRef); //$NON-NLS-1$ + default -> { + // Ignore unknown ProjectionRef fields to preserve load recovery behavior. + } + } + + reader.moveUp(); + } + + return projectionRef; + } + + private void readMemberships(HierarchicalStreamReader reader, UnmarshallingContext context, + LedgerProjectionRef projectionRef) + { + while (reader.hasMoreChildren()) + { + reader.moveDown(); + projectionRef.addMembership((ProjectionMembership) context.convertAnother(projectionRef, + ProjectionMembership.class)); + reader.moveUp(); + } + } + + private void writeAttribute(HierarchicalStreamWriter writer, String name, Object value) + { + if (value == null) + return; + + writer.addAttribute(name, String.valueOf(value)); + } + + private void writeObject(HierarchicalStreamWriter writer, MarshallingContext context, String nodeName, + Object value) + { + if (value == null) + return; + + writer.startNode(nodeName); + context.convertAnother(value); + writer.endNode(); + } + } + + private static class LedgerEntryConverter implements Converter + { + @Override + public boolean canConvert(@SuppressWarnings("rawtypes") Class type) + { + return type == LedgerEntry.class; + } + + @Override + public void marshal(Object source, HierarchicalStreamWriter writer, MarshallingContext context) + { + var entry = (LedgerEntry) source; + + writeAttribute(writer, "uuid", entry.getUUID()); //$NON-NLS-1$ + writeAttribute(writer, "type", entry.getType()); //$NON-NLS-1$ + writeAttribute(writer, "dateTime", entry.getDateTime()); //$NON-NLS-1$ + writeAttribute(writer, "updatedAt", entry.getUpdatedAt()); //$NON-NLS-1$ + writeValue(writer, "note", entry.getNote()); //$NON-NLS-1$ + writeValue(writer, "source", entry.getSource()); //$NON-NLS-1$ + writeParameters(writer, context, entry.getParameters()); + writeCollection(writer, context, "postings", "ledger-posting", entry.getPostings()); //$NON-NLS-1$ //$NON-NLS-2$ + writeCollection(writer, context, "projectionRefs", "ledger-projection-ref", //$NON-NLS-1$ //$NON-NLS-2$ + entry.getProjectionRefs()); + } + + @Override + public Object unmarshal(HierarchicalStreamReader reader, UnmarshallingContext context) + { + var entry = new LedgerEntry(); + var updatedAt = reader.getAttribute("updatedAt"); //$NON-NLS-1$ + + readAttribute(reader, "uuid").ifPresent(entry::setUUID); //$NON-NLS-1$ + readAttribute(reader, "type").map(LedgerEntryType::valueOf).ifPresent(entry::setType); //$NON-NLS-1$ + readAttribute(reader, "dateTime").map(LocalDateTime::parse).ifPresent(entry::setDateTime); //$NON-NLS-1$ + + while (reader.hasMoreChildren()) + { + reader.moveDown(); + + switch (reader.getNodeName()) + { + case "uuid" -> entry.setUUID(reader.getValue()); //$NON-NLS-1$ + case "type" -> entry.setType((LedgerEntryType) context.convertAnother(entry, //$NON-NLS-1$ + LedgerEntryType.class)); + case "dateTime" -> entry.setDateTime((LocalDateTime) context.convertAnother(entry, //$NON-NLS-1$ + LocalDateTime.class)); + case "updatedAt" -> updatedAt = reader.getValue(); //$NON-NLS-1$ + case "note" -> entry.setNote(reader.getValue()); //$NON-NLS-1$ + case "source" -> entry.setSource(reader.getValue()); //$NON-NLS-1$ + case "parameters" -> readParameters(reader, context, entry); //$NON-NLS-1$ + case "postings" -> readPostings(reader, context, entry); //$NON-NLS-1$ + case "projectionRefs" -> readProjectionRefs(reader, context, entry); //$NON-NLS-1$ + default -> { + // Ignore unknown LedgerEntry fields to preserve load recovery behavior. + } + } + + reader.moveUp(); + } + + if (updatedAt != null) + entry.setUpdatedAt(Instant.parse(updatedAt)); + + return entry; + } + + private void readParameters(HierarchicalStreamReader reader, UnmarshallingContext context, LedgerEntry entry) + { + while (reader.hasMoreChildren()) + { + reader.moveDown(); + entry.addParameter((LedgerParameter) context.convertAnother(entry, LedgerParameter.class)); + reader.moveUp(); + } + } + + private void readPostings(HierarchicalStreamReader reader, UnmarshallingContext context, LedgerEntry entry) + { + while (reader.hasMoreChildren()) + { + reader.moveDown(); + entry.addPosting((LedgerPosting) context.convertAnother(entry, LedgerPosting.class)); + reader.moveUp(); + } + } + + private void readProjectionRefs(HierarchicalStreamReader reader, UnmarshallingContext context, + LedgerEntry entry) + { + while (reader.hasMoreChildren()) + { + reader.moveDown(); + entry.addProjectionRef((LedgerProjectionRef) context.convertAnother(entry, LedgerProjectionRef.class)); + reader.moveUp(); + } + } + } + + private static class LedgerPostingConverter implements Converter + { + @Override + public boolean canConvert(@SuppressWarnings("rawtypes") Class type) + { + return type == LedgerPosting.class; + } + + @Override + public void marshal(Object source, HierarchicalStreamWriter writer, MarshallingContext context) + { + var posting = (LedgerPosting) source; + + writeAttribute(writer, "uuid", posting.getUUID()); //$NON-NLS-1$ + writeAttribute(writer, "type", posting.getType()); //$NON-NLS-1$ + writeAttribute(writer, "amount", posting.getAmount()); //$NON-NLS-1$ + writeAttribute(writer, "currency", posting.getCurrency()); //$NON-NLS-1$ + writeAttribute(writer, "forexAmount", posting.getForexAmount()); //$NON-NLS-1$ + writeAttribute(writer, "forexCurrency", posting.getForexCurrency()); //$NON-NLS-1$ + writeAttribute(writer, "exchangeRate", posting.getExchangeRate()); //$NON-NLS-1$ + writeAttribute(writer, "shares", posting.getShares()); //$NON-NLS-1$ + writeObject(writer, context, "security", posting.getSecurity()); //$NON-NLS-1$ + writeObject(writer, context, "account", posting.getAccount()); //$NON-NLS-1$ + writeObject(writer, context, "portfolio", posting.getPortfolio()); //$NON-NLS-1$ + writeParameters(writer, context, posting.getParameters()); + } + + @Override + public Object unmarshal(HierarchicalStreamReader reader, UnmarshallingContext context) + { + var posting = new LedgerPosting(); + + readAttribute(reader, "uuid").ifPresent(posting::setUUID); //$NON-NLS-1$ + readAttribute(reader, "type").map(LedgerPostingType::valueOf).ifPresent(posting::setType); //$NON-NLS-1$ + readAttribute(reader, "amount").map(Long::parseLong).ifPresent(posting::setAmount); //$NON-NLS-1$ + readAttribute(reader, "currency").ifPresent(posting::setCurrency); //$NON-NLS-1$ + readAttribute(reader, "forexAmount").map(Long::valueOf).ifPresent(posting::setForexAmount); //$NON-NLS-1$ + readAttribute(reader, "forexCurrency").ifPresent(posting::setForexCurrency); //$NON-NLS-1$ + readAttribute(reader, "exchangeRate").map(BigDecimal::new).ifPresent(posting::setExchangeRate); //$NON-NLS-1$ + readAttribute(reader, "shares").map(Long::parseLong).ifPresent(posting::setShares); //$NON-NLS-1$ + + while (reader.hasMoreChildren()) + { + reader.moveDown(); + + switch (reader.getNodeName()) + { + case "uuid" -> posting.setUUID(reader.getValue()); //$NON-NLS-1$ + case "type" -> posting.setType((LedgerPostingType) context.convertAnother(posting, //$NON-NLS-1$ + LedgerPostingType.class)); + case "amount" -> posting.setAmount(Long.parseLong(reader.getValue())); //$NON-NLS-1$ + case "currency" -> posting.setCurrency(reader.getValue()); //$NON-NLS-1$ + case "forexAmount" -> posting.setForexAmount(Long.valueOf(reader.getValue())); //$NON-NLS-1$ + case "forexCurrency" -> posting.setForexCurrency(reader.getValue()); //$NON-NLS-1$ + case "exchangeRate" -> posting.setExchangeRate(new BigDecimal(reader.getValue())); //$NON-NLS-1$ + case "security" -> posting.setSecurity((Security) context.convertAnother(posting, //$NON-NLS-1$ + Security.class)); + case "shares" -> posting.setShares(Long.parseLong(reader.getValue())); //$NON-NLS-1$ + case "account" -> posting.setAccount((Account) context.convertAnother(posting, Account.class)); //$NON-NLS-1$ + case "portfolio" -> posting.setPortfolio((Portfolio) context.convertAnother(posting, //$NON-NLS-1$ + Portfolio.class)); + case "parameters" -> readParameters(reader, context, posting); //$NON-NLS-1$ + default -> { + // Ignore unknown LedgerPosting fields to preserve load recovery behavior. + } + } + + reader.moveUp(); + } + + return posting; + } + + private void readParameters(HierarchicalStreamReader reader, UnmarshallingContext context, + LedgerPosting posting) + { + while (reader.hasMoreChildren()) + { + reader.moveDown(); + posting.addParameter((LedgerParameter) context.convertAnother(posting, LedgerParameter.class)); + reader.moveUp(); + } + } + } + + private static class LedgerParameterConverter implements Converter + { + @Override + public boolean canConvert(@SuppressWarnings("rawtypes") Class type) + { + return type == LedgerParameter.class; + } + + @Override + public void marshal(Object source, HierarchicalStreamWriter writer, MarshallingContext context) + { + var parameter = (LedgerParameter) source; + + writer.addAttribute("type", parameter.getType().getCode()); //$NON-NLS-1$ + writer.addAttribute("valueKind", parameter.getValueKind().name()); //$NON-NLS-1$ + + switch (parameter.getValueKind()) + { + case STRING, DECIMAL, LONG, BOOLEAN, LOCAL_DATE, LOCAL_DATE_TIME: + writer.addAttribute("value", String.valueOf(parameter.getValue())); //$NON-NLS-1$ + break; + case MONEY: + var money = (Money) parameter.getValue(); + writer.addAttribute("amount", String.valueOf(money.getAmount())); //$NON-NLS-1$ + writer.addAttribute("currency", money.getCurrencyCode()); //$NON-NLS-1$ + break; + case SECURITY, ACCOUNT, PORTFOLIO: + writer.startNode("value"); //$NON-NLS-1$ + context.convertAnother(parameter.getValue()); + writer.endNode(); + break; + default: + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PERSIST_003 + .message(MessageFormat.format(Messages.LedgerParameterUnsupportedValueKind, + parameter.getValueKind()))); + } + } + + @Override + public Object unmarshal(HierarchicalStreamReader reader, UnmarshallingContext context) + { + LedgerParameterType type = typeOrNull(reader.getAttribute("type")); //$NON-NLS-1$ + ValueKind valueKind = valueKindOrNull(reader.getAttribute("valueKind")); //$NON-NLS-1$ + var scalarValue = reader.getAttribute("value"); //$NON-NLS-1$ + var amount = reader.getAttribute("amount"); //$NON-NLS-1$ + var currency = reader.getAttribute("currency"); //$NON-NLS-1$ + Object parameterValue = null; + + while (reader.hasMoreChildren()) + { + reader.moveDown(); + + switch (reader.getNodeName()) + { + case "type" -> type = typeOrNull(reader.getValue()); //$NON-NLS-1$ + case "valueKind" -> valueKind = valueKindOrNull(reader.getValue()); //$NON-NLS-1$ + case "value" -> parameterValue = readValue(reader, context, valueKind); //$NON-NLS-1$ + default -> { + // Ignore unknown LedgerParameter fields to keep XML load recovery tolerant. + } + } + + reader.moveUp(); + } + + if (type == null) + throw new IllegalArgumentException( + LedgerDiagnosticCode.LEDGER_PERSIST_004.message(Messages.LedgerParameterMissingType)); + + if (valueKind == null) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PERSIST_005 + .message(Messages.LedgerParameterMissingValueKind)); + + if (parameterValue == null) + parameterValue = readValue(scalarValue, amount, currency, valueKind); + + return newParameter(type, valueKind, parameterValue); + } + + private LedgerParameterType typeOrNull(String value) + { + if (Strings.isNullOrEmpty(value)) + return null; + + return LedgerParameterType.fromCode(value); + } + + private ValueKind valueKindOrNull(String value) + { + if (Strings.isNullOrEmpty(value)) + return null; + + return ValueKind.valueOf(value); + } + + private Object readValue(HierarchicalStreamReader reader, UnmarshallingContext context, ValueKind valueKind) + { + return switch (valueKind) + { + case STRING, DECIMAL, LONG, BOOLEAN, LOCAL_DATE, LOCAL_DATE_TIME -> readValue(reader.getValue(), + valueKind); + case MONEY -> context.convertAnother(null, Money.class); + case SECURITY -> context.convertAnother(null, Security.class); + case ACCOUNT -> context.convertAnother(null, Account.class); + case PORTFOLIO -> context.convertAnother(null, Portfolio.class); + }; + } + + private Object readValue(String value, String amount, String currency, ValueKind valueKind) + { + return switch (valueKind) + { + case STRING, DECIMAL, LONG, BOOLEAN, LOCAL_DATE, LOCAL_DATE_TIME -> readValue(require(value, "value"), //$NON-NLS-1$ + valueKind); + case MONEY -> Money.of(require(currency, "currency"), Long.parseLong(require(amount, "amount"))); //$NON-NLS-1$ //$NON-NLS-2$ + case SECURITY, ACCOUNT, PORTFOLIO -> throw new IllegalArgumentException( + LedgerDiagnosticCode.LEDGER_PERSIST_006 + .message(Messages.LedgerParameterReferenceValueMissingValueNode)); + }; + } + + private Object readValue(String value, ValueKind valueKind) + { + return switch (valueKind) + { + case STRING -> value; + case DECIMAL -> new BigDecimal(value); + case LONG -> Long.valueOf(value); + case BOOLEAN -> Boolean.valueOf(value); + case LOCAL_DATE -> LocalDate.parse(value); + case LOCAL_DATE_TIME -> localDateTime(value); + case MONEY, SECURITY, ACCOUNT, PORTFOLIO -> throw new IllegalArgumentException( + LedgerDiagnosticCode.LEDGER_PERSIST_007 + .message(MessageFormat.format( + Messages.LedgerParameterValueKindRequiresStructuredValue, + valueKind))); + }; + } + + private LocalDateTime localDateTime(String value) + { + try + { + return LocalDateTime.parse(value); + } + catch (RuntimeException e) + { + return LocalDate.parse(value).atStartOfDay(); + } + } + + private String require(String value, String name) + { + if (value == null) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PERSIST_008 + .message(MessageFormat.format(Messages.LedgerParameterMissingAttribute, name))); + + return value; + } + + private LedgerParameter newParameter(LedgerParameterType type, ValueKind valueKind, Object value) + { + try + { + var constructor = LedgerParameter.class.getDeclaredConstructor(LedgerParameterType.class, + ValueKind.class, Object.class); + constructor.setAccessible(true); + return constructor.newInstance(type, valueKind, value); + } + catch (ReflectiveOperationException e) + { + throw new IllegalStateException(e); + } + } + } + /* package */ static class XmlSerialization { private boolean idReferences; @@ -146,6 +680,7 @@ public Client load(Reader input) throws IOException client.getVersion())); upgradeModel(client); + initializeLedgerXmlState(client); return client; } @@ -158,10 +693,211 @@ public Client load(Reader input) throws IOException void save(Client client, OutputStream output) throws IOException { Writer writer = new OutputStreamWriter(output, StandardCharsets.UTF_8); + var saveState = new LedgerXmlSaveState(); + + try + { + prepareLedgerXmlSave(client, saveState); + makeXStream(false).toXML(client, writer); + writer.flush(); + } + finally + { + saveState.restore(); + } + } + + private void initializeLedgerXmlState(Client client) throws IOException + { + if (client.getLedger().getEntries().isEmpty()) + { + new LegacyTransactionToLedgerMigrator().migrate(client); + convertPlanTransactionsToLedgerRefs(client); + return; + } + + LedgerProjectionService.adaptLegacyScalarMemberships(client); + removeLegacyProjectionShadows(client); + LedgerProjectionService.restoreIfValid(client); + } + + private void prepareLedgerXmlSave(Client client, LedgerXmlSaveState saveState) throws IOException + { + validateLedger(client); + + for (var account : client.getAccounts()) + saveState.removeLedgerBackedTransactions(account.getTransactions()); + + for (var portfolio : client.getPortfolios()) + saveState.removeLedgerBackedTransactions(portfolio.getTransactions()); + + for (var plan : client.getPlans()) + saveState.replaceLedgerBackedPlanTransactions(plan); + } + + private LedgerStructuralValidator.ValidationResult validateLedger(Client client) throws IOException + { + var result = LedgerStructuralValidator.validate(client.getLedger()); + + if (!result.isOK()) + { + LedgerProjectionService.logSkipped(client.getLedger(), result); + throw new IOException(LedgerDiagnosticCode.LEDGER_PERSIST_001 + .message(MessageFormat.format(Messages.LedgerXmlInvalidLedgerStructure, + LedgerDiagnosticMessageFormatter.formatValidationResult( + client.getLedger(), result)))); + } + + return result; + } + + private void removeLegacyProjectionShadows(Client client) + { + convertPlanTransactionsToLedgerRefs(client); + + var projectionUUIDs = ledgerProjectionUUIDs(client); + + for (var account : client.getAccounts()) + account.getTransactions().removeIf(transaction -> !(transaction instanceof LedgerBackedTransaction) + && projectionUUIDs.contains(transaction.getUUID())); + + for (var portfolio : client.getPortfolios()) + portfolio.getTransactions().removeIf(transaction -> !(transaction instanceof LedgerBackedTransaction) + && projectionUUIDs.contains(transaction.getUUID())); + } + + private void convertPlanTransactionsToLedgerRefs(Client client) + { + var projectionUUIDs = ledgerProjectionUUIDs(client); + + for (var plan : client.getPlans()) + { + for (var transaction : List.copyOf(plan.getTransactions())) + { + if (!projectionUUIDs.contains(transaction.getUUID())) + continue; + + ledgerExecutionRef(client, transaction.getUUID()).ifPresent(ref -> { + if (plan.getLedgerExecutionRefs().stream().noneMatch(existing -> sameExecutionRef(existing, ref))) + plan.addLedgerExecutionRef(ref); + }); + plan.getTransactions().remove(transaction); + } + } + } + + private Set ledgerProjectionUUIDs(Client client) + { + return client.getLedger().getEntries().stream() // + .flatMap(entry -> entry.getProjectionRefs().stream()) // + .map(LedgerProjectionRef::getUUID) // + .collect(Collectors.toSet()); + } - makeXStream(false).toXML(client, writer); + private Optional ledgerExecutionRef(Client client, String projectionUUID) + { + for (var entry : client.getLedger().getEntries()) + { + for (var projection : entry.getProjectionRefs()) + { + if (projectionUUID.equals(projection.getUUID())) + return Optional.of(new LedgerExecutionRef(entry.getUUID(), projection.getUUID(), + projection.getRole())); + } + } - writer.flush(); + return Optional.empty(); + } + + private boolean sameExecutionRef(LedgerExecutionRef left, LedgerExecutionRef right) + { + return Objects.equals(left.getLedgerEntryUUID(), right.getLedgerEntryUUID()) + && Objects.equals(left.getProjectionUUID(), right.getProjectionUUID()) + && left.getProjectionRole() == right.getProjectionRole(); + } + } + + private static final class LedgerXmlSaveState + { + private final List removedElements = new ArrayList<>(); + private final List planRefsSnapshots = new ArrayList<>(); + + private void removeLedgerBackedTransactions(List transactions) + { + for (var index = transactions.size() - 1; index >= 0; index--) + { + var transaction = transactions.get(index); + + if (transaction instanceof LedgerBackedTransaction) + remove(transactions, index); + } + } + + private void replaceLedgerBackedPlanTransactions(InvestmentPlan plan) + { + var previousRefs = List.copyOf(plan.getLedgerExecutionRefs()); + var snapshot = new PlanRefsSnapshot(plan, previousRefs); + var snapshotAdded = false; + + for (var index = plan.getTransactions().size() - 1; index >= 0; index--) + { + var transaction = plan.getTransactions().get(index); + + if (transaction instanceof LedgerBackedTransaction ledgerBackedTransaction) + { + if (!snapshotAdded) + { + planRefsSnapshots.add(snapshot); + snapshotAdded = true; + } + + remove(plan.getTransactions(), index); + + var ref = LedgerExecutionRef.of(ledgerBackedTransaction); + if (plan.getLedgerExecutionRefs().stream().noneMatch(existing -> sameExecutionRef(existing, ref))) + plan.addLedgerExecutionRef(ref); + } + } + } + + @SuppressWarnings("rawtypes") + private void remove(List list, int index) + { + removedElements.add(new RemovedListElement(list, index, list.remove(index))); + } + + private boolean sameExecutionRef(LedgerExecutionRef left, LedgerExecutionRef right) + { + return Objects.equals(left.getLedgerEntryUUID(), right.getLedgerEntryUUID()) + && Objects.equals(left.getProjectionUUID(), right.getProjectionUUID()) + && left.getProjectionRole() == right.getProjectionRole(); + } + + private void restore() + { + for (var index = removedElements.size() - 1; index >= 0; index--) + removedElements.get(index).restore(); + + for (var snapshot : planRefsSnapshots) + snapshot.restore(); + } + } + + private record RemovedListElement(@SuppressWarnings("rawtypes") List list, int index, Object element) + { + @SuppressWarnings("unchecked") + private void restore() + { + list.add(index, element); + } + } + + private record PlanRefsSnapshot(InvestmentPlan plan, List refs) + { + private void restore() + { + plan.getLedgerExecutionRefs().clear(); + plan.getLedgerExecutionRefs().addAll(refs); } } @@ -702,47 +1438,74 @@ private static void writeFile(final Client client, final File file, char[] passw { PortfolioLog.info(String.format("Saving %s with %s", file.getName(), flags.toString())); //$NON-NLS-1$ + var target = file.toPath(); + var directory = target.toAbsolutePath().getParent(); + var tempFile = Files.createTempFile(directory, "portfolio-save-", ".tmp"); //$NON-NLS-1$ //$NON-NLS-2$ + var moved = false; + // open an output stream for the file using a 64 KB buffer to speed up // writing - try (FileOutputStream stream = new FileOutputStream(file); - BufferedOutputStream output = new BufferedOutputStream(stream, 65536)) + try { - // lock file while writing (apparently network-attache storage is - // garbling up the files if it already starts syncing while the file - // is still being written) - FileChannel channel = stream.getChannel(); - FileLock lock = null; - - try - { - // On OS X fcntl does not support locking files on AFP or SMB - // https://bugs.openjdk.org/browse/JDK-8167023 - if (!Platform.getOS().equals(Platform.OS_MACOSX)) - lock = channel.tryLock(); - } - catch (IOException e) + try (FileOutputStream stream = new FileOutputStream(tempFile.toFile()); + BufferedOutputStream output = new BufferedOutputStream(stream, 65536)) { - // also on some other platforms (for example reported for Linux - // Mint, locks are not supported on SMB shares) + // lock file while writing (apparently network-attache storage is + // garbling up the files if it already starts syncing while the file + // is still being written) + FileChannel channel = stream.getChannel(); + FileLock lock = null; - PortfolioLog.warning(MessageFormat.format("Failed to acquire lock {0} with message {1}", //$NON-NLS-1$ - file.getAbsolutePath(), e.getMessage())); - } + try + { + // On OS X fcntl does not support locking files on AFP or SMB + // https://bugs.openjdk.org/browse/JDK-8167023 + if (!Platform.OS_MACOSX.equals(Platform.getOS())) + lock = channel.tryLock(); + } + catch (IOException e) + { + // also on some other platforms (for example reported for Linux + // Mint, locks are not supported on SMB shares) - ClientPersister persister = buildPersister(flags, password); - persister.save(client, output); + PortfolioLog.warning(MessageFormat.format("Failed to acquire lock {0} with message {1}", //$NON-NLS-1$ + tempFile.toAbsolutePath(), e.getMessage())); + } - output.flush(); + ClientPersister persister = buildPersister(flags, password); + persister.save(client, output); - if (lock != null && lock.isValid()) - lock.release(); + output.flush(); + if (lock != null && lock.isValid()) + lock.release(); + } + + moveSavedFile(tempFile, target); + moved = true; if (updateFlags) { client.getSaveFlags().clear(); client.getSaveFlags().addAll(flags); } } + finally + { + if (!moved) + Files.deleteIfExists(tempFile); + } + } + + private static void moveSavedFile(Path tempFile, Path target) throws IOException + { + try + { + Files.move(tempFile, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } + catch (AtomicMoveNotSupportedException e) + { + Files.move(tempFile, target, StandardCopyOption.REPLACE_EXISTING); + } } private static ClientPersister buildPersister(Set flags, char[] password) @@ -1859,6 +2622,7 @@ private static XStream xstreamFactory() var xstream = new XStream(); xstream.allowTypesByWildcard(new String[] { "name.abuchen.portfolio.model.**" }); + xstream.allowTypes(new Class[] { Money.class }); xstream.setClassLoader(ClientFactory.class.getClassLoader()); @@ -1874,6 +2638,10 @@ private static XStream xstreamFactory() xstream.registerConverter(new XStreamSecurityPriceConverter()); xstream.registerConverter( new PortfolioTransactionConverter(xstream.getMapper(), xstream.getReflectionProvider())); + xstream.registerConverter(new LedgerEntryConverter()); + xstream.registerConverter(new LedgerPostingConverter()); + xstream.registerConverter(new LedgerProjectionRefConverter()); + xstream.registerConverter(new LedgerParameterConverter()); xstream.registerConverter(new MapConverter(xstream.getMapper(), TypedMap.class)); xstream.registerConverter(new XStreamArrayListConverter(xstream.getMapper())); @@ -1891,12 +2659,41 @@ private static XStream xstreamFactory() xstream.useAttributeFor(Transaction.Unit.class, "type"); xstream.alias("account-transaction", AccountTransaction.class); xstream.alias("portfolio-transaction", PortfolioTransaction.class); + xstream.alias("ledger", Ledger.class); + xstream.alias("ledger-entry", LedgerEntry.class); + xstream.useAttributeFor(LedgerEntry.class, "uuid"); + xstream.useAttributeFor(LedgerEntry.class, "type"); + xstream.useAttributeFor(LedgerEntry.class, "dateTime"); + xstream.useAttributeFor(LedgerEntry.class, "updatedAt"); + xstream.alias("ledger-posting", LedgerPosting.class); + xstream.useAttributeFor(LedgerPosting.class, "uuid"); + xstream.useAttributeFor(LedgerPosting.class, "type"); + xstream.useAttributeFor(LedgerPosting.class, "amount"); + xstream.useAttributeFor(LedgerPosting.class, "currency"); + xstream.useAttributeFor(LedgerPosting.class, "forexAmount"); + xstream.useAttributeFor(LedgerPosting.class, "forexCurrency"); + xstream.useAttributeFor(LedgerPosting.class, "exchangeRate"); + xstream.useAttributeFor(LedgerPosting.class, "shares"); + xstream.alias("ledger-posting-parameter", LedgerParameter.class); + xstream.alias("ledger-posting-parameter-type", LedgerParameterType.class); + xstream.alias("ledger-projection-ref", LedgerProjectionRef.class); + xstream.alias("projection-membership", ProjectionMembership.class); + xstream.alias("membership", ProjectionMembership.class); + xstream.useAttributeFor(ProjectionMembership.class, "postingUUID"); + xstream.useAttributeFor(ProjectionMembership.class, "role"); + xstream.alias("projection-membership-role", ProjectionMembershipRole.class); + xstream.alias("ledger-parameter", LedgerParameter.class); + xstream.alias("ledger-parameter-type", LedgerParameterType.class); + xstream.alias("ledger-entry-type", LedgerEntryType.class); + xstream.alias("ledger-posting-type", LedgerPostingType.class); + xstream.alias("ledger-projection-role", LedgerProjectionRole.class); xstream.alias("security", Security.class); xstream.addImplicitCollection(Security.class, "properties"); xstream.alias("latest", LatestSecurityPrice.class); xstream.alias("category", Category.class); // NOSONAR xstream.alias("watchlist", Watchlist.class); xstream.alias("investment-plan", InvestmentPlan.class); + xstream.alias("ledger-execution-ref", LedgerExecutionRef.class); xstream.alias("attribute-type", AttributeType.class); xstream.alias("price", SecurityPrice.class); diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerModelLoadSupport.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerModelLoadSupport.java new file mode 100644 index 0000000000..dcaa5c15f7 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerModelLoadSupport.java @@ -0,0 +1,167 @@ +package name.abuchen.portfolio.model; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.Objects; + +import name.abuchen.portfolio.model.ledger.Ledger; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerParameter; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.ProjectionMembershipRole; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; + +/** + * Builds Ledger model objects while loading persisted files. + * This is persistence infrastructure. Normal Ledger construction should use creators, + * native assemblers, or mutation contexts instead of this load-only support. + */ +final class LedgerModelLoadSupport +{ + private LedgerModelLoadSupport() + { + } + + static LedgerEntry newEntry(String uuid, LedgerEntryType type, LocalDateTime dateTime) + { + var entry = new LedgerEntry(uuid); + + entry.setType(Objects.requireNonNull(type)); + entry.setDateTime(Objects.requireNonNull(dateTime)); + + return entry; + } + + static void setEntryNote(LedgerEntry entry, String note) + { + Objects.requireNonNull(entry).setNote(note); + } + + static void setEntrySource(LedgerEntry entry, String source) + { + Objects.requireNonNull(entry).setSource(source); + } + + static void setEntryUpdatedAt(LedgerEntry entry, Instant updatedAt) + { + Objects.requireNonNull(entry).setUpdatedAt(updatedAt); + } + + static void addEntry(Ledger ledger, LedgerEntry entry) + { + Objects.requireNonNull(ledger).addEntry(entry); + } + + static void addEntryParameter(LedgerEntry entry, LedgerParameter parameter) + { + Objects.requireNonNull(entry).addParameter(parameter); + } + + static void addPosting(LedgerEntry entry, LedgerPosting posting) + { + Objects.requireNonNull(entry).addPosting(posting); + } + + static void addProjectionRef(LedgerEntry entry, LedgerProjectionRef projectionRef) + { + Objects.requireNonNull(entry).addProjectionRef(projectionRef); + } + + static LedgerPosting newPosting(String uuid, LedgerPostingType type) + { + var posting = new LedgerPosting(uuid); + + posting.setType(Objects.requireNonNull(type)); + + return posting; + } + + static void setPostingAmount(LedgerPosting posting, long amount) + { + Objects.requireNonNull(posting).setAmount(amount); + } + + static void setPostingCurrency(LedgerPosting posting, String currency) + { + Objects.requireNonNull(posting).setCurrency(currency); + } + + static void setPostingForexAmount(LedgerPosting posting, Long forexAmount) + { + Objects.requireNonNull(posting).setForexAmount(forexAmount); + } + + static void setPostingForexCurrency(LedgerPosting posting, String forexCurrency) + { + Objects.requireNonNull(posting).setForexCurrency(forexCurrency); + } + + static void setPostingExchangeRate(LedgerPosting posting, BigDecimal exchangeRate) + { + Objects.requireNonNull(posting).setExchangeRate(exchangeRate); + } + + static void setPostingSecurity(LedgerPosting posting, Security security) + { + Objects.requireNonNull(posting).setSecurity(security); + } + + static void setPostingShares(LedgerPosting posting, long shares) + { + Objects.requireNonNull(posting).setShares(shares); + } + + static void setPostingAccount(LedgerPosting posting, Account account) + { + Objects.requireNonNull(posting).setAccount(account); + } + + static void setPostingPortfolio(LedgerPosting posting, Portfolio portfolio) + { + Objects.requireNonNull(posting).setPortfolio(portfolio); + } + + static void addPostingParameter(LedgerPosting posting, LedgerParameter parameter) + { + Objects.requireNonNull(posting).addParameter(parameter); + } + + static LedgerProjectionRef newProjectionRef(String uuid, LedgerProjectionRole role) + { + var projectionRef = new LedgerProjectionRef(uuid); + + projectionRef.setRole(Objects.requireNonNull(role)); + + return projectionRef; + } + + static void setProjectionRefAccount(LedgerProjectionRef projectionRef, Account account) + { + Objects.requireNonNull(projectionRef).setAccount(account); + } + + static void setProjectionRefPortfolio(LedgerProjectionRef projectionRef, Portfolio portfolio) + { + Objects.requireNonNull(projectionRef).setPortfolio(portfolio); + } + + static void setProjectionRefPrimaryPostingUUID(LedgerProjectionRef projectionRef, String primaryPostingUUID) + { + Objects.requireNonNull(projectionRef).setPrimaryPostingUUID(primaryPostingUUID); + } + + static void setProjectionRefPostingGroupUUID(LedgerProjectionRef projectionRef, String postingGroupUUID) + { + Objects.requireNonNull(projectionRef).setPostingGroupUUID(postingGroupUUID); + } + + static void addProjectionRefMembership(LedgerProjectionRef projectionRef, String postingUUID, + ProjectionMembershipRole role) + { + Objects.requireNonNull(projectionRef).addMembership(postingUUID, role); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ProtobufWriter.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ProtobufWriter.java index 0b2c38f4ae..a4e6432833 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ProtobufWriter.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ProtobufWriter.java @@ -14,13 +14,17 @@ import java.io.InputStream; import java.io.OutputStream; import java.math.BigDecimal; +import java.text.MessageFormat; import java.time.LocalDate; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.EnumSet; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; import com.google.protobuf.Any; @@ -32,6 +36,23 @@ import name.abuchen.portfolio.model.ClientFactory.ClientPersister; import name.abuchen.portfolio.model.ConfigurationSet.Configuration; import name.abuchen.portfolio.model.SecurityEvent.DividendEvent; +import name.abuchen.portfolio.model.ledger.Ledger; +import name.abuchen.portfolio.model.ledger.LedgerDiagnosticMessageFormatter; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerParameter; +import name.abuchen.portfolio.model.ledger.LedgerParameter.ValueKind; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; +import name.abuchen.portfolio.model.ledger.ProjectionMembership; +import name.abuchen.portfolio.model.ledger.ProjectionMembershipRole; +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.model.ledger.legacy.LegacyTransactionToLedgerMigrator; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionService; import name.abuchen.portfolio.model.proto.v1.PAccount; import name.abuchen.portfolio.model.proto.v1.PAnyValue; import name.abuchen.portfolio.model.proto.v1.PAttributeType; @@ -43,7 +64,17 @@ import name.abuchen.portfolio.model.proto.v1.PHistoricalPrice; import name.abuchen.portfolio.model.proto.v1.PInvestmentPlan; import name.abuchen.portfolio.model.proto.v1.PInvestmentPlan.Type; +import name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef; import name.abuchen.portfolio.model.proto.v1.PKeyValue; +import name.abuchen.portfolio.model.proto.v1.PLedger; +import name.abuchen.portfolio.model.proto.v1.PLedgerEntry; +import name.abuchen.portfolio.model.proto.v1.PLedgerParameter; +import name.abuchen.portfolio.model.proto.v1.PLedgerParameterValueKind; +import name.abuchen.portfolio.model.proto.v1.PLedgerPosting; +import name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership; +import name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipRole; +import name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef; +import name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRole; import name.abuchen.portfolio.model.proto.v1.PMap; import name.abuchen.portfolio.model.proto.v1.PPortfolio; import name.abuchen.portfolio.model.proto.v1.PSecurity; @@ -128,7 +159,16 @@ public Client load(InputStream input) throws IOException loadSecurities(newClient, client, lookup); loadAccounts(newClient, client, lookup); loadPortfolios(newClient, client, lookup); - loadTransactions(newClient, lookup); + + boolean hasLedgerTruth = hasLedgerTruth(newClient); + Set ledgerProjectionUUIDs = Collections.emptySet(); + if (hasLedgerTruth) + { + loadLedger(newClient.getLedger(), client, lookup); + ledgerProjectionUUIDs = ledgerProjectionUUIDs(client.getLedger()); + } + + loadTransactions(newClient, lookup, ledgerProjectionUUIDs); client.getProperties().putAll(newClient.getPropertiesMap()); loadTaxonomies(newClient, client, lookup); @@ -141,6 +181,16 @@ public Client load(InputStream input) throws IOException ClientFactory.upgradeModel(client); + if (hasLedgerTruth) + { + LedgerProjectionService.adaptLegacyScalarMemberships(client); + LedgerProjectionService.restoreIfValid(client); + } + else + { + new LegacyTransactionToLedgerMigrator().migrate(client); + } + return client; } @@ -304,10 +354,13 @@ private void loadPortfolios(PClient newClient, Client client, Lookup lookup) } } - private void loadTransactions(PClient newClient, Lookup lookup) + private void loadTransactions(PClient newClient, Lookup lookup, Set ledgerProjectionUUIDs) { for (PTransaction newTransaction : newClient.getTransactionsList()) { + if (isLedgerCompatibilityShadow(newTransaction, ledgerProjectionUUIDs)) + continue; + PTransaction.Type type = newTransaction.getType(); switch (type) @@ -545,6 +598,12 @@ private void loadTransactions(PClient newClient, Lookup lookup) } } + private boolean isLedgerCompatibilityShadow(PTransaction newTransaction, Set ledgerProjectionUUIDs) + { + return ledgerProjectionUUIDs.contains(newTransaction.getUuid()) + || (newTransaction.hasOtherUuid() && ledgerProjectionUUIDs.contains(newTransaction.getOtherUuid())); + } + private void loadCommonTransaction(PTransaction newTransaction, Transaction t, Lookup lookup, boolean requiresSecurity) { @@ -608,6 +667,139 @@ private void loadTransactionUnits(PTransaction newTransaction, Transaction t) } } + private boolean hasLedgerTruth(PClient newClient) + { + return newClient.hasLedger() && newClient.getLedger().getEntriesCount() > 0; + } + + private Set ledgerProjectionUUIDs(Ledger ledger) + { + return ledger.getEntries().stream() // + .flatMap(entry -> entry.getProjectionRefs().stream()) // + .map(LedgerProjectionRef::getUUID) // + .collect(Collectors.toCollection(HashSet::new)); + } + + private void loadLedger(PLedger newLedger, Client client, Lookup lookup) + { + for (PLedgerEntry newEntry : newLedger.getEntriesList()) + { + LedgerEntry entry = LedgerModelLoadSupport.newEntry(newEntry.getUuid(), + LedgerEntryType.fromProtobufId(newEntry.getTypeId()), + fromTimestamp(newEntry.getDateTime())); + + if (newEntry.hasNote()) + LedgerModelLoadSupport.setEntryNote(entry, newEntry.getNote()); + if (newEntry.hasSource()) + LedgerModelLoadSupport.setEntrySource(entry, newEntry.getSource()); + if (newEntry.hasUpdatedAt()) + LedgerModelLoadSupport.setEntryUpdatedAt(entry, fromUpdatedAtTimestamp(newEntry.getUpdatedAt())); + + for (PLedgerParameter newParameter : newEntry.getParametersList()) + LedgerModelLoadSupport.addEntryParameter(entry, loadLedgerParameter(newParameter, lookup)); + + for (PLedgerPosting newPosting : newEntry.getPostingsList()) + LedgerModelLoadSupport.addPosting(entry, loadLedgerPosting(newPosting, lookup)); + + for (PLedgerProjectionRef newProjectionRef : newEntry.getProjectionRefsList()) + LedgerModelLoadSupport.addProjectionRef(entry, loadLedgerProjectionRef(newProjectionRef, lookup)); + + if (newEntry.hasUpdatedAt()) + LedgerModelLoadSupport.setEntryUpdatedAt(entry, fromUpdatedAtTimestamp(newEntry.getUpdatedAt())); + + LedgerModelLoadSupport.addEntry(client.getLedger(), entry); + } + } + + private LedgerPosting loadLedgerPosting(PLedgerPosting newPosting, Lookup lookup) + { + LedgerPosting posting = LedgerModelLoadSupport.newPosting(newPosting.getUuid(), + LedgerPostingType.fromCode(newPosting.getTypeCode())); + + LedgerModelLoadSupport.setPostingAmount(posting, newPosting.getAmount()); + if (newPosting.hasCurrency()) + LedgerModelLoadSupport.setPostingCurrency(posting, newPosting.getCurrency()); + if (newPosting.hasForexAmount()) + LedgerModelLoadSupport.setPostingForexAmount(posting, newPosting.getForexAmount()); + if (newPosting.hasForexCurrency()) + LedgerModelLoadSupport.setPostingForexCurrency(posting, newPosting.getForexCurrency()); + if (newPosting.hasExchangeRate()) + LedgerModelLoadSupport.setPostingExchangeRate(posting, + fromDecimalValue(newPosting.getExchangeRate())); + if (newPosting.hasSecurity()) + LedgerModelLoadSupport.setPostingSecurity(posting, lookup.getSecurity(newPosting.getSecurity())); + LedgerModelLoadSupport.setPostingShares(posting, newPosting.getShares()); + if (newPosting.hasAccount()) + LedgerModelLoadSupport.setPostingAccount(posting, lookup.getAccount(newPosting.getAccount())); + if (newPosting.hasPortfolio()) + LedgerModelLoadSupport.setPostingPortfolio(posting, lookup.getPortfolio(newPosting.getPortfolio())); + + for (PLedgerParameter newParameter : newPosting.getParametersList()) + LedgerModelLoadSupport.addPostingParameter(posting, loadLedgerParameter(newParameter, lookup)); + + return posting; + } + + private LedgerProjectionRef loadLedgerProjectionRef(PLedgerProjectionRef newProjectionRef, Lookup lookup) + { + LedgerProjectionRef projectionRef = LedgerModelLoadSupport.newProjectionRef(newProjectionRef.getUuid(), + fromProto(newProjectionRef.getRole())); + + if (newProjectionRef.hasAccount()) + LedgerModelLoadSupport.setProjectionRefAccount(projectionRef, + lookup.getAccount(newProjectionRef.getAccount())); + if (newProjectionRef.hasPortfolio()) + LedgerModelLoadSupport.setProjectionRefPortfolio(projectionRef, + lookup.getPortfolio(newProjectionRef.getPortfolio())); + + for (PLedgerProjectionMembership newMembership : newProjectionRef.getMembershipsList()) + LedgerModelLoadSupport.addProjectionRefMembership(projectionRef, newMembership.getPostingUUID(), + fromProto(newMembership.getRole())); + + return projectionRef; + } + + private LedgerParameter loadLedgerParameter(PLedgerParameter newParameter, Lookup lookup) + { + LedgerParameterType type = LedgerParameterType.fromCode(newParameter.getTypeCode()); + + switch (newParameter.getValueKind()) + { + case LEDGER_PARAMETER_VALUE_KIND_STRING: + return LedgerParameter.ofString(type, newParameter.getStringValue()); + case LEDGER_PARAMETER_VALUE_KIND_DECIMAL: + return LedgerParameter.ofDecimal(type, fromDecimalValue(newParameter.getDecimalValue())); + case LEDGER_PARAMETER_VALUE_KIND_LONG: + return LedgerParameter.ofLong(type, newParameter.getLongValue()); + case LEDGER_PARAMETER_VALUE_KIND_MONEY: + return LedgerParameter.ofMoney(type, + Money.of(newParameter.getMoneyCurrency(), newParameter.getMoneyAmount())); + case LEDGER_PARAMETER_VALUE_KIND_SECURITY: + return LedgerParameter.ofSecurity(type, lookup.getSecurity(newParameter.getSecurity())); + case LEDGER_PARAMETER_VALUE_KIND_ACCOUNT: + return LedgerParameter.ofAccount(type, lookup.getAccount(newParameter.getAccount())); + case LEDGER_PARAMETER_VALUE_KIND_PORTFOLIO: + return LedgerParameter.ofPortfolio(type, lookup.getPortfolio(newParameter.getPortfolio())); + case LEDGER_PARAMETER_VALUE_KIND_BOOLEAN: + if (!newParameter.hasBooleanValue()) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PERSIST_009 + .message(Messages.LedgerProtobufBooleanParameterMissingBooleanValue)); + return LedgerParameter.ofBoolean(type, Boolean.valueOf(newParameter.getBooleanValue())); + case LEDGER_PARAMETER_VALUE_KIND_LOCAL_DATE: + if (!newParameter.hasLocalDateValue()) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PERSIST_010 + .message(Messages.LedgerProtobufLocalDateParameterMissingLocalDateValue)); + return LedgerParameter.ofLocalDate(type, LocalDate.ofEpochDay(newParameter.getLocalDateValue())); + case LEDGER_PARAMETER_VALUE_KIND_LOCAL_DATE_TIME: + return LedgerParameter.ofLocalDateTime(type, + fromLocalDateTime(newParameter.getLocalDateTimeValue())); + case LEDGER_PARAMETER_VALUE_KIND_UNSPECIFIED: + case UNRECOGNIZED: + default: + throw new UnsupportedOperationException(newParameter.getValueKind().toString()); + } + } + @SuppressWarnings("unchecked") private void loadSettings(PClient newClient, Client client) throws IOException { @@ -826,15 +1018,48 @@ private void loadInvestmentPlans(PClient newClient, Client client, Lookup lookup for (String uuid : newPlan.getTransactionsList()) { Transaction t = uuid2transaction.get(uuid); - if (t == null) - throw new UnsupportedOperationException(uuid); - plan.getTransactions().add(t); + if (t != null) + { + plan.getTransactions().add(t); + continue; + } + + InvestmentPlan.LedgerExecutionRef executionRef = ledgerExecutionRefForProjectionUUID(client, uuid); + if (executionRef != null) + { + plan.addLedgerExecutionRef(executionRef); + continue; + } + + throw new UnsupportedOperationException(uuid); + } + + for (PInvestmentPlanLedgerExecutionRef newRef : newPlan.getLedgerExecutionRefsList()) + { + plan.addLedgerExecutionRef(new InvestmentPlan.LedgerExecutionRef(newRef.getLedgerEntryUUID(), + newRef.hasProjectionUUID() ? newRef.getProjectionUUID() : null, + newRef.hasProjectionRole() ? fromProto(newRef.getProjectionRole()) : null)); } client.addPlan(plan); } } + private InvestmentPlan.LedgerExecutionRef ledgerExecutionRefForProjectionUUID(Client client, String projectionUUID) + { + for (LedgerEntry entry : client.getLedger().getEntries()) + { + for (LedgerProjectionRef projectionRef : entry.getProjectionRefs()) + { + if (projectionRef.getUUID().equals(projectionUUID)) + return new InvestmentPlan.LedgerExecutionRef(entry.getUUID(), projectionRef.getUUID(), + projectionRef.getRole()); + } + } + + return null; + } + private void loadExtensions(PClient newClient, Client client) { // Load extension data from the Any fields @@ -866,6 +1091,7 @@ public void save(Client client, OutputStream output) throws IOException saveSecurities(client, newClient); saveAccounts(client, newClient); savePortfolios(client, newClient); + saveLedger(client, newClient); saveTransactions(client, newClient); newClient.putAllProperties(client.getProperties()); @@ -1044,11 +1270,159 @@ private void savePortfolios(Client client, PClient.Builder newClient) } } + private void saveLedger(Client client, PClient.Builder newClient) + { + validateLedger(client); + + PLedger.Builder newLedger = PLedger.newBuilder(); + + for (LedgerEntry entry : client.getLedger().getEntries()) + newLedger.addEntries(saveLedgerEntry(entry)); + + newClient.setLedger(newLedger); + } + + private PLedgerEntry saveLedgerEntry(LedgerEntry entry) + { + PLedgerEntry.Builder newEntry = PLedgerEntry.newBuilder(); + + newEntry.setUuid(entry.getUUID()); + newEntry.setTypeId(entry.getType().getProtobufId()); + newEntry.setDateTime(asTimestamp(entry.getDateTime())); + + if (entry.getNote() != null) + newEntry.setNote(entry.getNote()); + if (entry.getSource() != null) + newEntry.setSource(entry.getSource()); + if (entry.getUpdatedAt() != null) + newEntry.setUpdatedAt(asUpdatedAtTimestamp(entry.getUpdatedAt())); + + for (LedgerParameter parameter : entry.getParameters()) + newEntry.addParameters(saveLedgerParameter(parameter)); + + for (LedgerPosting posting : entry.getPostings()) + newEntry.addPostings(saveLedgerPosting(posting)); + + for (LedgerProjectionRef projectionRef : entry.getProjectionRefs()) + newEntry.addProjectionRefs(saveLedgerProjectionRef(projectionRef)); + + return newEntry.build(); + } + + private PLedgerPosting saveLedgerPosting(LedgerPosting posting) + { + PLedgerPosting.Builder newPosting = PLedgerPosting.newBuilder(); + + newPosting.setUuid(posting.getUUID()); + newPosting.setTypeCode(posting.getType().getCode()); + newPosting.setAmount(posting.getAmount()); + + if (posting.getCurrency() != null) + newPosting.setCurrency(posting.getCurrency()); + if (posting.getForexAmount() != null) + newPosting.setForexAmount(posting.getForexAmount()); + if (posting.getForexCurrency() != null) + newPosting.setForexCurrency(posting.getForexCurrency()); + if (posting.getExchangeRate() != null) + newPosting.setExchangeRate(asDecimalValue(posting.getExchangeRate())); + if (posting.getSecurity() != null) + newPosting.setSecurity(posting.getSecurity().getUUID()); + newPosting.setShares(posting.getShares()); + if (posting.getAccount() != null) + newPosting.setAccount(posting.getAccount().getUUID()); + if (posting.getPortfolio() != null) + newPosting.setPortfolio(posting.getPortfolio().getUUID()); + + for (LedgerParameter parameter : posting.getParameters()) + newPosting.addParameters(saveLedgerParameter(parameter)); + + return newPosting.build(); + } + + private PLedgerProjectionRef saveLedgerProjectionRef(LedgerProjectionRef projectionRef) + { + PLedgerProjectionRef.Builder newProjectionRef = PLedgerProjectionRef.newBuilder(); + + newProjectionRef.setUuid(projectionRef.getUUID()); + newProjectionRef.setRole(toProto(projectionRef.getRole())); + if (projectionRef.getAccount() != null) + newProjectionRef.setAccount(projectionRef.getAccount().getUUID()); + if (projectionRef.getPortfolio() != null) + newProjectionRef.setPortfolio(projectionRef.getPortfolio().getUUID()); + for (ProjectionMembership membership : projectionRef.getMemberships()) + newProjectionRef.addMemberships(saveLedgerProjectionMembership(membership)); + + return newProjectionRef.build(); + } + + private PLedgerProjectionMembership saveLedgerProjectionMembership(ProjectionMembership membership) + { + PLedgerProjectionMembership.Builder newMembership = PLedgerProjectionMembership.newBuilder(); + + newMembership.setPostingUUID(membership.getPostingUUID()); + newMembership.setRole(toProto(membership.getRole())); + + return newMembership.build(); + } + + private PLedgerParameter saveLedgerParameter(LedgerParameter parameter) + { + PLedgerParameter.Builder newParameter = PLedgerParameter.newBuilder(); + + newParameter.setTypeCode(parameter.getType().getCode()); + newParameter.setValueKind(toProto(parameter.getValueKind())); + + switch (parameter.getValueKind()) + { + case STRING: + newParameter.setStringValue((String) parameter.getValue()); + break; + case DECIMAL: + newParameter.setDecimalValue(asDecimalValue((BigDecimal) parameter.getValue())); + break; + case LONG: + newParameter.setLongValue((Long) parameter.getValue()); + break; + case MONEY: + Money money = (Money) parameter.getValue(); + newParameter.setMoneyAmount(money.getAmount()); + newParameter.setMoneyCurrency(money.getCurrencyCode()); + break; + case SECURITY: + newParameter.setSecurity(((Security) parameter.getValue()).getUUID()); + break; + case ACCOUNT: + newParameter.setAccount(((Account) parameter.getValue()).getUUID()); + break; + case PORTFOLIO: + newParameter.setPortfolio(((Portfolio) parameter.getValue()).getUUID()); + break; + case BOOLEAN: + newParameter.setBooleanValue(((Boolean) parameter.getValue()).booleanValue()); + break; + case LOCAL_DATE: + newParameter.setLocalDateValue(((LocalDate) parameter.getValue()).toEpochDay()); + break; + case LOCAL_DATE_TIME: + newParameter.setLocalDateTimeValue(asLocalDateTime((java.time.LocalDateTime) parameter.getValue())); + break; + default: + throw new UnsupportedOperationException(parameter.getValueKind().toString()); + } + + return newParameter.build(); + } + private void saveTransactions(Client client, PClient.Builder newClient) { + Set ledgerProjectionUUIDs = ledgerProjectionUUIDs(client.getLedger()); + saveLedgerCompatibilityShadows(client, newClient); + for (Portfolio portfolio : client.getPortfolios()) { - portfolio.getTransactions().stream().filter(t -> t.getType() != PortfolioTransaction.Type.TRANSFER_IN) + portfolio.getTransactions().stream() + .filter(t -> t.getType() != PortfolioTransaction.Type.TRANSFER_IN) + .filter(t -> shouldSaveLegacyTransaction(t, ledgerProjectionUUIDs)) .forEach(t -> addTransaction(newClient, portfolio, t)); } @@ -1057,12 +1431,57 @@ private void saveTransactions(Client client, PClient.Builder newClient) for (Account account : client.getAccounts()) { - account.getTransactions().stream().filter(t -> !exclude.contains(t.getType())) + account.getTransactions().stream() // + .filter(t -> !exclude.contains(t.getType())) + .filter(t -> shouldSaveLegacyTransaction(t, ledgerProjectionUUIDs)) .forEach(t -> addTransaction(newClient, account, t)); } } + private void saveLedgerCompatibilityShadows(Client client, PClient.Builder newClient) + { + for (LedgerEntry entry : client.getLedger().getEntries()) + { + if (!entry.getType().isLegacyFixedShape()) + continue; + + for (Transaction transaction : LedgerProjectionService.createProjections(entry)) + { + if (!shouldSaveLedgerCompatibilityShadow(transaction)) + continue; + + LedgerBackedTransaction ledgerBackedTransaction = (LedgerBackedTransaction) transaction; + LedgerProjectionRef projectionRef = ledgerBackedTransaction.getLedgerProjectionRef(); + + if (transaction instanceof AccountTransaction accountTransaction) + addTransaction(newClient, projectionRef.getAccount(), accountTransaction); + else if (transaction instanceof PortfolioTransaction portfolioTransaction) + addTransaction(newClient, projectionRef.getPortfolio(), portfolioTransaction); + else + throw new UnsupportedOperationException(transaction.getClass().getName()); + } + } + } + + private boolean shouldSaveLedgerCompatibilityShadow(Transaction transaction) + { + if (transaction instanceof AccountTransaction accountTransaction) + return accountTransaction.getType() != AccountTransaction.Type.BUY + && accountTransaction.getType() != AccountTransaction.Type.SELL + && accountTransaction.getType() != AccountTransaction.Type.TRANSFER_IN; + + if (transaction instanceof PortfolioTransaction portfolioTransaction) + return portfolioTransaction.getType() != PortfolioTransaction.Type.TRANSFER_IN; + + return false; + } + + private boolean shouldSaveLegacyTransaction(Transaction transaction, Set ledgerProjectionUUIDs) + { + return !(transaction instanceof LedgerBackedTransaction) && !ledgerProjectionUUIDs.contains(transaction.getUUID()); + } + private void addTransaction(PClient.Builder newClient, Portfolio portfolio, PortfolioTransaction t) { PTransaction.Builder newTransaction = PTransaction.newBuilder(); @@ -1411,12 +1830,200 @@ private void saveInvestmentPlans(Client client, PClient.Builder newClient) throw new UnsupportedOperationException(); } - plan.getTransactions().forEach(t -> newPlan.addTransactions(t.getUUID())); + Set ledgerExecutionRefKeys = new HashSet<>(); + + plan.getTransactions().forEach(t -> { + if (t instanceof LedgerBackedTransaction ledgerBackedTransaction) + addLedgerExecutionRef(newPlan, InvestmentPlan.LedgerExecutionRef.of(ledgerBackedTransaction), + ledgerExecutionRefKeys); + else + newPlan.addTransactions(t.getUUID()); + }); + + plan.getLedgerExecutionRefs() + .forEach(ref -> addLedgerExecutionRef(newPlan, ref, ledgerExecutionRefKeys)); newClient.addPlans(newPlan); }); } + private void addLedgerExecutionRef(PInvestmentPlan.Builder newPlan, InvestmentPlan.LedgerExecutionRef ref, + Set keys) + { + String key = ref.getLedgerEntryUUID() + "|" + ref.getProjectionUUID() + "|" + ref.getProjectionRole(); //$NON-NLS-1$ //$NON-NLS-2$ + + if (!keys.add(key)) + return; + + PInvestmentPlanLedgerExecutionRef.Builder newRef = PInvestmentPlanLedgerExecutionRef.newBuilder(); + newRef.setLedgerEntryUUID(ref.getLedgerEntryUUID()); + + if (ref.getProjectionUUID() != null) + newRef.setProjectionUUID(ref.getProjectionUUID()); + if (ref.getProjectionRole() != null) + newRef.setProjectionRole(toProto(ref.getProjectionRole())); + + newPlan.addLedgerExecutionRefs(newRef); + } + + private void validateLedger(Client client) + { + var ledger = client.getLedger(); + var result = LedgerStructuralValidator.validate(ledger); + + if (!result.isOK()) + { + LedgerProjectionService.logSkipped(ledger, result); + throw new UnsupportedOperationException( + LedgerDiagnosticCode.LEDGER_PERSIST_002 + .message(MessageFormat.format( + Messages.LedgerProtobufInvalidLedgerPersistenceState, + LedgerDiagnosticMessageFormatter.formatValidationResult( + ledger, result)))); + } + } + + private PLedgerProjectionRole toProto(LedgerProjectionRole role) + { + switch (role) + { + case ACCOUNT: + return PLedgerProjectionRole.LEDGER_PROJECTION_ROLE_ACCOUNT; + case PORTFOLIO: + return PLedgerProjectionRole.LEDGER_PROJECTION_ROLE_PORTFOLIO; + case SOURCE_ACCOUNT: + return PLedgerProjectionRole.LEDGER_PROJECTION_ROLE_SOURCE_ACCOUNT; + case TARGET_ACCOUNT: + return PLedgerProjectionRole.LEDGER_PROJECTION_ROLE_TARGET_ACCOUNT; + case SOURCE_PORTFOLIO: + return PLedgerProjectionRole.LEDGER_PROJECTION_ROLE_SOURCE_PORTFOLIO; + case TARGET_PORTFOLIO: + return PLedgerProjectionRole.LEDGER_PROJECTION_ROLE_TARGET_PORTFOLIO; + case DELIVERY: + return PLedgerProjectionRole.LEDGER_PROJECTION_ROLE_DELIVERY; + case DELIVERY_INBOUND: + return PLedgerProjectionRole.LEDGER_PROJECTION_ROLE_DELIVERY_INBOUND; + case DELIVERY_OUTBOUND: + return PLedgerProjectionRole.LEDGER_PROJECTION_ROLE_DELIVERY_OUTBOUND; + case CASH_COMPENSATION: + return PLedgerProjectionRole.LEDGER_PROJECTION_ROLE_CASH_COMPENSATION; + case OLD_SECURITY_LEG: + return PLedgerProjectionRole.LEDGER_PROJECTION_ROLE_OLD_SECURITY_LEG; + case NEW_SECURITY_LEG: + return PLedgerProjectionRole.LEDGER_PROJECTION_ROLE_NEW_SECURITY_LEG; + default: + throw new UnsupportedOperationException(role.toString()); + } + } + + private LedgerProjectionRole fromProto(PLedgerProjectionRole role) + { + switch (role) + { + case LEDGER_PROJECTION_ROLE_ACCOUNT: + return LedgerProjectionRole.ACCOUNT; + case LEDGER_PROJECTION_ROLE_PORTFOLIO: + return LedgerProjectionRole.PORTFOLIO; + case LEDGER_PROJECTION_ROLE_SOURCE_ACCOUNT: + return LedgerProjectionRole.SOURCE_ACCOUNT; + case LEDGER_PROJECTION_ROLE_TARGET_ACCOUNT: + return LedgerProjectionRole.TARGET_ACCOUNT; + case LEDGER_PROJECTION_ROLE_SOURCE_PORTFOLIO: + return LedgerProjectionRole.SOURCE_PORTFOLIO; + case LEDGER_PROJECTION_ROLE_TARGET_PORTFOLIO: + return LedgerProjectionRole.TARGET_PORTFOLIO; + case LEDGER_PROJECTION_ROLE_DELIVERY: + return LedgerProjectionRole.DELIVERY; + case LEDGER_PROJECTION_ROLE_DELIVERY_INBOUND: + return LedgerProjectionRole.DELIVERY_INBOUND; + case LEDGER_PROJECTION_ROLE_DELIVERY_OUTBOUND: + return LedgerProjectionRole.DELIVERY_OUTBOUND; + case LEDGER_PROJECTION_ROLE_CASH_COMPENSATION: + return LedgerProjectionRole.CASH_COMPENSATION; + case LEDGER_PROJECTION_ROLE_OLD_SECURITY_LEG: + return LedgerProjectionRole.OLD_SECURITY_LEG; + case LEDGER_PROJECTION_ROLE_NEW_SECURITY_LEG: + return LedgerProjectionRole.NEW_SECURITY_LEG; + case LEDGER_PROJECTION_ROLE_UNSPECIFIED: + case UNRECOGNIZED: + default: + throw new UnsupportedOperationException(role.toString()); + } + } + + private PLedgerProjectionMembershipRole toProto(ProjectionMembershipRole role) + { + switch (role) + { + case PRIMARY: + return PLedgerProjectionMembershipRole.LEDGER_PROJECTION_MEMBERSHIP_ROLE_PRIMARY; + case GROUP_ANCHOR: + return PLedgerProjectionMembershipRole.LEDGER_PROJECTION_MEMBERSHIP_ROLE_GROUP_ANCHOR; + case FEE_UNIT: + return PLedgerProjectionMembershipRole.LEDGER_PROJECTION_MEMBERSHIP_ROLE_FEE_UNIT; + case TAX_UNIT: + return PLedgerProjectionMembershipRole.LEDGER_PROJECTION_MEMBERSHIP_ROLE_TAX_UNIT; + case GROSS_VALUE_UNIT: + return PLedgerProjectionMembershipRole.LEDGER_PROJECTION_MEMBERSHIP_ROLE_GROSS_VALUE_UNIT; + case FOREX_CONTEXT: + return PLedgerProjectionMembershipRole.LEDGER_PROJECTION_MEMBERSHIP_ROLE_FOREX_CONTEXT; + default: + throw new UnsupportedOperationException(role.toString()); + } + } + + private ProjectionMembershipRole fromProto(PLedgerProjectionMembershipRole role) + { + switch (role) + { + case LEDGER_PROJECTION_MEMBERSHIP_ROLE_PRIMARY: + return ProjectionMembershipRole.PRIMARY; + case LEDGER_PROJECTION_MEMBERSHIP_ROLE_GROUP_ANCHOR: + return ProjectionMembershipRole.GROUP_ANCHOR; + case LEDGER_PROJECTION_MEMBERSHIP_ROLE_FEE_UNIT: + return ProjectionMembershipRole.FEE_UNIT; + case LEDGER_PROJECTION_MEMBERSHIP_ROLE_TAX_UNIT: + return ProjectionMembershipRole.TAX_UNIT; + case LEDGER_PROJECTION_MEMBERSHIP_ROLE_GROSS_VALUE_UNIT: + return ProjectionMembershipRole.GROSS_VALUE_UNIT; + case LEDGER_PROJECTION_MEMBERSHIP_ROLE_FOREX_CONTEXT: + return ProjectionMembershipRole.FOREX_CONTEXT; + case LEDGER_PROJECTION_MEMBERSHIP_ROLE_UNSPECIFIED: + case UNRECOGNIZED: + default: + throw new UnsupportedOperationException(role.toString()); + } + } + + private PLedgerParameterValueKind toProto(ValueKind valueKind) + { + switch (valueKind) + { + case STRING: + return PLedgerParameterValueKind.LEDGER_PARAMETER_VALUE_KIND_STRING; + case DECIMAL: + return PLedgerParameterValueKind.LEDGER_PARAMETER_VALUE_KIND_DECIMAL; + case LONG: + return PLedgerParameterValueKind.LEDGER_PARAMETER_VALUE_KIND_LONG; + case MONEY: + return PLedgerParameterValueKind.LEDGER_PARAMETER_VALUE_KIND_MONEY; + case SECURITY: + return PLedgerParameterValueKind.LEDGER_PARAMETER_VALUE_KIND_SECURITY; + case ACCOUNT: + return PLedgerParameterValueKind.LEDGER_PARAMETER_VALUE_KIND_ACCOUNT; + case PORTFOLIO: + return PLedgerParameterValueKind.LEDGER_PARAMETER_VALUE_KIND_PORTFOLIO; + case BOOLEAN: + return PLedgerParameterValueKind.LEDGER_PARAMETER_VALUE_KIND_BOOLEAN; + case LOCAL_DATE: + return PLedgerParameterValueKind.LEDGER_PARAMETER_VALUE_KIND_LOCAL_DATE; + case LOCAL_DATE_TIME: + return PLedgerParameterValueKind.LEDGER_PARAMETER_VALUE_KIND_LOCAL_DATE_TIME; + default: + throw new UnsupportedOperationException(valueKind.toString()); + } + } + private void saveExtensions(Client client, PClient.Builder newClient) { // Save extension data to the Any fields diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/client.proto b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/client.proto index a805d285fd..c3f0b425af 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/client.proto +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/client.proto @@ -90,11 +90,11 @@ message PSecurity { optional string latestFeed = 14; optional string latestFeedURL = 15; optional PFullHistoricalPrice latest = 16; - + repeated PKeyValue attributes = 17; - + repeated PSecurityEvent events = 18; - + repeated PKeyValue properties = 19; bool isRetired = 20; @@ -113,9 +113,9 @@ message PAccount { string currencyCode = 3; optional string note = 4; bool isRetired = 5; - + repeated PKeyValue attributes = 6; - + google.protobuf.Timestamp updatedAt = 7; } @@ -124,11 +124,11 @@ message PPortfolio { string name = 2; optional string note = 3; bool isRetired = 4; - + optional string referenceAccount = 5; - + repeated PKeyValue attributes = 6; - + google.protobuf.Timestamp updatedAt = 7; } @@ -164,20 +164,20 @@ message PTransaction { FEE = 13; FEE_REFUND = 14; } - + string uuid = 1; - + Type type = 2; optional string account = 3; optional string portfolio = 4; optional string otherAccount = 5; optional string otherPortfolio = 6; - + optional string otherUuid = 7; optional google.protobuf.Timestamp otherUpdatedAt = 8; - + google.protobuf.Timestamp date = 9; - + string currencyCode = 10; int64 amount = 11; optional int64 shares = 12; @@ -187,7 +187,7 @@ message PTransaction { repeated PTransactionUnit units = 15; google.protobuf.Timestamp updatedAt = 16; - + optional string source = 17; optional PLocalDateTime exDate = 18; } @@ -205,7 +205,7 @@ message PInvestmentPlan { optional string security = 3; optional string portfolio = 4; optional string account = 5; - + repeated PKeyValue attributes = 6; bool autoGenerate = 7; @@ -217,6 +217,123 @@ message PInvestmentPlan { int64 taxes = 13; Type type = 14; + + repeated PInvestmentPlanLedgerExecutionRef ledgerExecutionRefs = 15; +} + +message PInvestmentPlanLedgerExecutionRef { + string ledgerEntryUUID = 1; + optional string projectionUUID = 2; + optional PLedgerProjectionRole projectionRole = 3; +} + +enum PLedgerProjectionRole { + LEDGER_PROJECTION_ROLE_UNSPECIFIED = 0; + LEDGER_PROJECTION_ROLE_ACCOUNT = 1; + LEDGER_PROJECTION_ROLE_PORTFOLIO = 2; + LEDGER_PROJECTION_ROLE_SOURCE_ACCOUNT = 3; + LEDGER_PROJECTION_ROLE_TARGET_ACCOUNT = 4; + LEDGER_PROJECTION_ROLE_SOURCE_PORTFOLIO = 5; + LEDGER_PROJECTION_ROLE_TARGET_PORTFOLIO = 6; + LEDGER_PROJECTION_ROLE_DELIVERY = 7; + LEDGER_PROJECTION_ROLE_DELIVERY_INBOUND = 8; + LEDGER_PROJECTION_ROLE_DELIVERY_OUTBOUND = 9; + LEDGER_PROJECTION_ROLE_CASH_COMPENSATION = 10; + LEDGER_PROJECTION_ROLE_OLD_SECURITY_LEG = 11; + LEDGER_PROJECTION_ROLE_NEW_SECURITY_LEG = 12; +} + +enum PLedgerProjectionMembershipRole { + LEDGER_PROJECTION_MEMBERSHIP_ROLE_UNSPECIFIED = 0; + LEDGER_PROJECTION_MEMBERSHIP_ROLE_PRIMARY = 1; + LEDGER_PROJECTION_MEMBERSHIP_ROLE_GROUP_ANCHOR = 2; + LEDGER_PROJECTION_MEMBERSHIP_ROLE_FEE_UNIT = 3; + LEDGER_PROJECTION_MEMBERSHIP_ROLE_TAX_UNIT = 4; + LEDGER_PROJECTION_MEMBERSHIP_ROLE_GROSS_VALUE_UNIT = 5; + LEDGER_PROJECTION_MEMBERSHIP_ROLE_FOREX_CONTEXT = 6; +} + +enum PLedgerParameterValueKind { + reserved 8, 9; + reserved "LEDGER_PARAMETER_VALUE_KIND_CORPORATE_ACTION_LEG", "LEDGER_PARAMETER_VALUE_KIND_COMPENSATION_KIND"; + + LEDGER_PARAMETER_VALUE_KIND_UNSPECIFIED = 0; + LEDGER_PARAMETER_VALUE_KIND_STRING = 1; + LEDGER_PARAMETER_VALUE_KIND_DECIMAL = 2; + LEDGER_PARAMETER_VALUE_KIND_LONG = 3; + LEDGER_PARAMETER_VALUE_KIND_MONEY = 4; + LEDGER_PARAMETER_VALUE_KIND_SECURITY = 5; + LEDGER_PARAMETER_VALUE_KIND_ACCOUNT = 6; + LEDGER_PARAMETER_VALUE_KIND_PORTFOLIO = 7; + LEDGER_PARAMETER_VALUE_KIND_LOCAL_DATE_TIME = 10; + LEDGER_PARAMETER_VALUE_KIND_BOOLEAN = 11; + LEDGER_PARAMETER_VALUE_KIND_LOCAL_DATE = 12; +} + +message PLedger { + repeated PLedgerEntry entries = 1; +} + +message PLedgerEntry { + string uuid = 1; + reserved 2; + google.protobuf.Timestamp dateTime = 3; + optional string note = 4; + optional string source = 5; + google.protobuf.Timestamp updatedAt = 6; + repeated PLedgerPosting postings = 7; + repeated PLedgerProjectionRef projectionRefs = 8; + optional uint32 typeId = 9; + repeated PLedgerParameter parameters = 10; +} + +message PLedgerPosting { + string uuid = 1; + reserved 2; + int64 amount = 3; + optional string currency = 4; + optional int64 forexAmount = 5; + optional string forexCurrency = 6; + optional PDecimalValue exchangeRate = 7; + optional string security = 8; + int64 shares = 9; + optional string account = 10; + optional string portfolio = 11; + repeated PLedgerParameter parameters = 12; + optional string typeCode = 13; +} + +message PLedgerProjectionRef { + string uuid = 1; + PLedgerProjectionRole role = 2; + optional string account = 3; + optional string portfolio = 4; + repeated PLedgerProjectionMembership memberships = 5; +} + +message PLedgerProjectionMembership { + string postingUUID = 1; + PLedgerProjectionMembershipRole role = 2; +} + +message PLedgerParameter { + reserved 1; + reserved 11; + reserved "enumValue"; + + PLedgerParameterValueKind valueKind = 2; + optional string stringValue = 3; + optional PDecimalValue decimalValue = 4; + optional int64 longValue = 5; + optional int64 moneyAmount = 6; + optional string moneyCurrency = 7; + optional string security = 8; + optional string account = 9; + optional string portfolio = 10; + optional PLocalDateTime localDateTimeValue = 12; + optional string typeCode = 13; + optional bool booleanValue = 14; + optional int64 localDateValue = 15; // epoch day, based on the epoch 1970-01-01 } message PTaxonomy { @@ -230,15 +347,15 @@ message PTaxonomy { message Classification { string id = 1; optional string parentId = 2; - + string name = 3; optional string note = 4; string color = 5; int32 weight = 6; int32 rank = 7; - + repeated PKeyValue data = 8; - + repeated PTaxonomy.Assignment assignments = 9; } @@ -246,7 +363,7 @@ message PTaxonomy { string name = 2; optional string source = 3; repeated string dimensions = 4; - + repeated PTaxonomy.Classification classifications = 5; } @@ -256,12 +373,12 @@ message PDashboard { string label = 2; map configuration = 3; } - + message Column { int32 weight = 1; repeated Widget widgets = 2; } - + string name = 1; map configuration = 2; repeated Column columns = 3; @@ -299,7 +416,7 @@ message PSettings { message PClient { int32 version = 1; - + repeated PSecurity securities = 2; repeated PAccount accounts = 3; repeated PPortfolio portfolios = 4; @@ -307,15 +424,17 @@ message PClient { repeated PInvestmentPlan plans = 6; - repeated PWatchlist watchlists = 7; + repeated PWatchlist watchlists = 7; repeated PTaxonomy taxonomies = 8; repeated PDashboard dashboards = 9; map properties = 10; - + PSettings settings = 11; string baseCurrency = 12; - + + PLedger ledger = 13; + // Extension data using Any type for maximum flexibility repeated google.protobuf.Any extensions = 99; } From 5894d3be9096198a90789445f318ebf94b654c8d Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Fri, 26 Jun 2026 14:29:29 +0200 Subject: [PATCH 05/68] Add Ledger compatibility creators and editors Add fixed-shape Ledger compatibility creators, editors, value objects, and tests. This groups transaction creation and update behavior separately from conversion guardrails. UI routing, persistence schema, and documentation are intentionally left unchanged in this commit. --- .../model/ledger/LedgerEditorTest.java | 1176 +++++++++++++++++ .../ledger/LedgerTransactionCreatorTest.java | 657 +++++++++ ...dgerAccountOnlyTransactionCreatorTest.java | 432 ++++++ ...AccountTransferTransactionCreatorTest.java | 414 ++++++ .../LedgerBuySellTransactionCreatorTest.java | 515 ++++++++ .../LedgerDeliveryTransactionCreatorTest.java | 420 ++++++ .../LedgerDividendTransactionCreatorTest.java | 389 ++++++ ...rtfolioTransferTransactionCreatorTest.java | 427 ++++++ .../compatibility/LedgerAccountCashLeg.java | 50 + .../LedgerAccountOnlyTransactionCreator.java | 288 ++++ .../LedgerAccountTransactionEdit.java | 137 ++ .../LedgerAccountTransactionEditor.java | 98 ++ .../LedgerAccountTransferEdit.java | 140 ++ .../LedgerAccountTransferEditor.java | 105 ++ ...dgerAccountTransferTransactionCreator.java | 245 ++++ .../compatibility/LedgerBuySellEdit.java | 152 +++ .../compatibility/LedgerBuySellEditor.java | 98 ++ .../LedgerBuySellTransactionCreator.java | 318 +++++ .../compatibility/LedgerCashTransferLeg.java | 50 + .../compatibility/LedgerCreationUnit.java | 65 + .../compatibility/LedgerCreationUnits.java | 45 + .../compatibility/LedgerDeliveryLeg.java | 72 + .../LedgerDeliveryTransactionCreator.java | 244 ++++ .../LedgerDeliveryTransactionEdit.java | 115 ++ .../LedgerDeliveryTransactionEditor.java | 73 + .../ledger/compatibility/LedgerDividend.java | 82 ++ .../LedgerDividendTransactionCreator.java | 233 ++++ .../compatibility/LedgerForexAmount.java | 53 + .../LedgerInvestmentPlanRefSupport.java | 215 +++ .../compatibility/LedgerOptionalSecurity.java | 42 + .../LedgerPortfolioSecurityLeg.java | 60 + .../LedgerPortfolioTransferEdit.java | 165 +++ .../LedgerPortfolioTransferEditor.java | 106 ++ .../LedgerPortfolioTransferLeg.java | 50 + .../LedgerPortfolioTransferSecurity.java | 37 + ...erPortfolioTransferTransactionCreator.java | 211 +++ .../compatibility/LedgerSecurityQuantity.java | 37 + .../LedgerTransactionCreator.java | 448 +++++++ 38 files changed, 8464 insertions(+) create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerEditorTest.java create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerTransactionCreatorTest.java create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountOnlyTransactionCreatorTest.java create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferTransactionCreatorTest.java create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellTransactionCreatorTest.java create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryTransactionCreatorTest.java create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDividendTransactionCreatorTest.java create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferTransactionCreatorTest.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountCashLeg.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountOnlyTransactionCreator.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransactionEdit.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransactionEditor.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferEdit.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferEditor.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferTransactionCreator.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellEdit.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellEditor.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellTransactionCreator.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerCashTransferLeg.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerCreationUnit.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerCreationUnits.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryLeg.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryTransactionCreator.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryTransactionEdit.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryTransactionEditor.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDividend.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDividendTransactionCreator.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerForexAmount.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerInvestmentPlanRefSupport.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerOptionalSecurity.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioSecurityLeg.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferEdit.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferEditor.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferLeg.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferSecurity.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferTransactionCreator.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerSecurityQuantity.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerTransactionCreator.java diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerEditorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerEditorTest.java new file mode 100644 index 0000000000..3aa70a1f02 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerEditorTest.java @@ -0,0 +1,1176 @@ +package name.abuchen.portfolio.model.ledger; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.lang.reflect.Field; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.Test; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.AccountTransferEntry; +import name.abuchen.portfolio.model.BuySellEntry; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.PortfolioTransaction; +import name.abuchen.portfolio.model.PortfolioTransferEntry; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountCashLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountOnlyTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountTransactionEdit; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountTransactionEditor; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountTransferEdit; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountTransferEditor; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountTransferTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerBuySellEdit; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerBuySellEditor; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerCashTransferLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerCreationUnit; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerCreationUnits; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerDeliveryLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerDeliveryTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerDeliveryTransactionEdit; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerDeliveryTransactionEditor; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerDividend; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerForexAmount; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerOptionalSecurity; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioSecurityLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferEdit; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferEditor; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferSecurity; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerSecurityQuantity; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerUnitPostingEdit; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerUnitPostingPatch; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerUnitPostingUpdater; +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.model.ledger.projection.LedgerBackedAccountTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedPortfolioTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionService; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionSupport; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.Values; + +/** + * Tests same-shape ledger editing for existing transaction entries. + * These tests make sure supported edits update ledger facts while projections remain derived runtime views. + */ +@SuppressWarnings("nls") +public class LedgerEditorTest +{ + private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 1, 2, 0, 0); + + /** + * Checks the ledger-backed editing scenario: field edit states are explicit. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testFieldEditStatesAreExplicit() + { + assertTrue(LedgerFieldEdit.omitted().isOmitted()); + assertTrue(LedgerFieldEdit.set("value").isSet()); + assertThat(LedgerFieldEdit.set("value").getValue(), is("value")); + assertTrue(LedgerFieldEdit.clear().isClear()); + var exception = assertThrows(IllegalStateException.class, () -> LedgerFieldEdit.clear().getValue()); + + assertThat(exception.getMessage(), is(LedgerDiagnosticCode.LEDGER_CORE_006 + .message("Only set edits have a value"))); + } + + /** + * Checks the ledger-backed editing scenario: metadata patch omitted set and clear. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testMetadataPatchOmittedSetAndClear() + { + var client = new Client(); + var entry = creator(client).createDeposit( + LedgerTransactionMetadata.of(DATE_TIME).withNote("old note").withSource("old source"), + cashLeg(account(), 100)).getEntry(); + + LedgerEntryMetadataPatchHelper.apply(entry, LedgerEntryMetadataPatch.none()); + + assertThat(entry.getDateTime(), is(DATE_TIME)); + assertThat(entry.getNote(), is("old note")); + assertThat(entry.getSource(), is("old source")); + + LedgerEntryMetadataPatchHelper.apply(entry, LedgerEntryMetadataPatch.builder() + .dateTime(DATE_TIME.plusDays(1)).note("new note").source("new source").build()); + + assertThat(entry.getDateTime(), is(DATE_TIME.plusDays(1))); + assertThat(entry.getNote(), is("new note")); + assertThat(entry.getSource(), is("new source")); + + LedgerEntryMetadataPatchHelper.apply(entry, + LedgerEntryMetadataPatch.builder().clearNote().clearSource().build()); + + assertNull(entry.getNote()); + assertNull(entry.getSource()); + } + + /** + * Checks the ledger-backed editing scenario: invalid metadata patch does not partially mutate note or source. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testInvalidMetadataPatchDoesNotPartiallyMutateNoteOrSource() + { + var entry = new LedgerEntry(); + + entry.setType(LedgerEntryType.DEPOSIT); + entry.setDateTime(DATE_TIME); + entry.setNote("old note"); + entry.setSource("old source"); + entry.addPosting(new LedgerPosting()); + + assertThrows(IllegalArgumentException.class, () -> LedgerEntryMetadataPatchHelper.apply(entry, + LedgerEntryMetadataPatch.builder().note("new note").source("new source").build())); + + assertThat(entry.getNote(), is("old note")); + assertThat(entry.getSource(), is("old source")); + } + + /** + * Checks the ledger-backed editing scenario: account transaction editor patches amount and currency. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testAccountTransactionEditorPatchesAmountAndCurrency() + { + var client = new Client(); + var account = account(); + var entry = creator(client).createDeposit(metadata(), cashLeg(account, 100)).getEntry(); + var projectionUUID = entry.getProjectionRefs().get(0).getUUID(); + var postingUUID = entry.getPostings().get(0).getUUID(); + + LedgerProjectionService.materialize(client); + + var transaction = (LedgerBackedAccountTransaction) account.getTransactions().get(0); + + new LedgerAccountTransactionEditor().apply(transaction, + LedgerAccountTransactionEdit.builder().amount(150).currency(CurrencyUnit.USD).build()); + + assertThat(entry.getUUID(), is(((LedgerBackedTransaction) transaction).getLedgerEntry().getUUID())); + assertThat(entry.getProjectionRefs().get(0).getUUID(), is(projectionUUID)); + assertThat(entry.getPostings().get(0).getUUID(), is(postingUUID)); + assertThat(transaction.getAmount(), is(150L)); + assertThat(transaction.getCurrencyCode(), is(CurrencyUnit.USD)); + } + + /** + * Checks the ledger-backed editing scenario: dividend editor patches ex-date and preserves surviving unit uuids. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testDividendEditorPatchesExDateAndPreservesSurvivingUnitUUIDs() + { + var client = new Client(); + var account = account(); + var forex = LedgerForexAmount.of(Money.of(CurrencyUnit.USD, 40L), BigDecimal.valueOf(0.90)); + var units = LedgerCreationUnits.of(LedgerCreationUnit.tax(money(3)), + LedgerCreationUnit.fee(money(2)), + LedgerCreationUnit.grossValue(Money.of(CurrencyUnit.EUR, 36L), forex)); + var dividend = LedgerDividend.withExDate(cashLeg(account, 30), LedgerOptionalSecurity.of(security()), units, + DATE_TIME.minusDays(5)); + var entry = creator(client).createDividend(metadata(), dividend).getEntry(); + var taxUUID = posting(entry, LedgerPostingType.TAX).getUUID(); + var feeUUID = posting(entry, LedgerPostingType.FEE).getUUID(); + var grossValueUUID = posting(entry, LedgerPostingType.GROSS_VALUE).getUUID(); + + LedgerProjectionService.materialize(client); + + var transaction = (LedgerBackedAccountTransaction) account.getTransactions().get(0); + + new LedgerAccountTransactionEditor().apply(transaction, LedgerAccountTransactionEdit.builder() + .exDate(DATE_TIME.minusDays(1)) + .units(LedgerUnitPostingPatch.of(LedgerUnitPostingEdit.update(taxUUID, + Money.of(CurrencyUnit.EUR, 4L)))) + .build()); + + assertThat(transaction.getExDate(), is(DATE_TIME.minusDays(1))); + assertThat(posting(entry, LedgerPostingType.TAX).getUUID(), is(taxUUID)); + assertThat(posting(entry, LedgerPostingType.TAX).getAmount(), is(4L)); + assertThat(posting(entry, LedgerPostingType.FEE).getUUID(), is(feeUUID)); + assertThat(posting(entry, LedgerPostingType.GROSS_VALUE).getUUID(), is(grossValueUUID)); + + new LedgerAccountTransactionEditor().apply(transaction, + LedgerAccountTransactionEdit.builder().clearExDate().build()); + + assertNull(transaction.getExDate()); + } + + /** + * Checks the ledger-backed editing scenario: unit posting updater adds updates and removes units without changing projections. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testUnitPostingUpdaterAddsUpdatesAndRemovesUnitsWithoutChangingProjections() + { + var client = new Client(); + var account = account(); + var entry = creator(client).createDeposit(metadata(), cashLeg(account, 100)).getEntry(); + var projectionCount = entry.getProjectionRefs().size(); + var updater = new LedgerUnitPostingUpdater(); + + updater.apply(entry, LedgerUnitPostingPatch.of(LedgerUnitPostingEdit.add(LedgerPostingType.FEE, money(1)))); + + var feeUUID = posting(entry, LedgerPostingType.FEE).getUUID(); + + updater.apply(entry, LedgerUnitPostingPatch.of(LedgerUnitPostingEdit.update(feeUUID, + Money.of(CurrencyUnit.EUR, 2L)))); + + assertThat(posting(entry, LedgerPostingType.FEE).getUUID(), is(feeUUID)); + assertThat(posting(entry, LedgerPostingType.FEE).getAmount(), is(2L)); + + updater.apply(entry, LedgerUnitPostingPatch.of(LedgerUnitPostingEdit.update(feeUUID, + Money.of(CurrencyUnit.EUR, 3L), + LedgerForexAmount.of(Money.of(CurrencyUnit.USD, 4L), new BigDecimal("0.7500"))))); + + assertThat(posting(entry, LedgerPostingType.FEE).getUUID(), is(feeUUID)); + assertThat(posting(entry, LedgerPostingType.FEE).getForexAmount(), is(4L)); + assertThat(posting(entry, LedgerPostingType.FEE).getForexCurrency(), is(CurrencyUnit.USD)); + assertThat(posting(entry, LedgerPostingType.FEE).getExchangeRate(), is(new BigDecimal("0.7500"))); + + updater.apply(entry, LedgerUnitPostingPatch.of(LedgerUnitPostingEdit.update(feeUUID, + Money.of(CurrencyUnit.EUR, 5L)))); + + assertThat(posting(entry, LedgerPostingType.FEE).getForexAmount(), is(4L)); + assertThat(posting(entry, LedgerPostingType.FEE).getForexCurrency(), is(CurrencyUnit.USD)); + assertThat(posting(entry, LedgerPostingType.FEE).getExchangeRate(), is(new BigDecimal("0.7500"))); + + updater.apply(entry, LedgerUnitPostingPatch.of(LedgerUnitPostingEdit.clearForex(feeUUID))); + + assertThat(posting(entry, LedgerPostingType.FEE).getUUID(), is(feeUUID)); + assertNull(posting(entry, LedgerPostingType.FEE).getForexAmount()); + assertNull(posting(entry, LedgerPostingType.FEE).getForexCurrency()); + assertNull(posting(entry, LedgerPostingType.FEE).getExchangeRate()); + + updater.apply(entry, LedgerUnitPostingPatch.of(LedgerUnitPostingEdit.remove(feeUUID))); + + assertThat(entry.getProjectionRefs().size(), is(projectionCount)); + assertTrue(entry.getPostings().stream().noneMatch(posting -> posting.getUUID().equals(feeUUID))); + } + + /** + * Checks the ledger-backed editing scenario: delivery editor patches security shares and amount. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testDeliveryEditorPatchesSecuritySharesAndAmount() + { + var client = new Client(); + var portfolio = portfolio(); + var newSecurity = security(); + var entry = creator(client).createInboundDelivery(metadata(), deliveryLeg(portfolio)).getEntry(); + var projectionUUID = entry.getProjectionRefs().get(0).getUUID(); + var postingUUID = entry.getPostings().get(0).getUUID(); + + LedgerProjectionService.materialize(client); + + var transaction = (LedgerBackedPortfolioTransaction) portfolio.getTransactions().get(0); + + new LedgerDeliveryTransactionEditor().apply(transaction, LedgerDeliveryTransactionEdit.builder() + .security(newSecurity).shares(Values.Share.factorize(7)).amount(77L).build()); + + assertThat(entry.getType(), is(LedgerEntryType.DELIVERY_INBOUND)); + assertThat(entry.getProjectionRefs().get(0).getUUID(), is(projectionUUID)); + assertThat(entry.getPostings().get(0).getUUID(), is(postingUUID)); + assertSame(newSecurity, transaction.getSecurity()); + assertThat(transaction.getShares(), is(Values.Share.factorize(7))); + assertThat(transaction.getAmount(), is(77L)); + } + + /** + * Checks the ledger-backed editing scenario: buy/sell editor patches cash and security postings. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testBuySellEditorPatchesCashAndSecurityPostings() + { + var client = new Client(); + var account = account(); + var portfolio = portfolio(); + var newSecurity = security(); + var entry = creator(client).createBuy(metadata(), cashLeg(account, 100), portfolioLeg(portfolio, 100), + LedgerCreationUnits.none()).getEntry(); + var accountProjectionUUID = projection(entry, LedgerProjectionRole.ACCOUNT).getUUID(); + var portfolioProjectionUUID = projection(entry, LedgerProjectionRole.PORTFOLIO).getUUID(); + var cashPostingUUID = posting(entry, LedgerPostingType.CASH).getUUID(); + var securityPostingUUID = posting(entry, LedgerPostingType.SECURITY).getUUID(); + + LedgerProjectionService.materialize(client); + + var accountTransaction = (LedgerBackedAccountTransaction) account.getTransactions().get(0); + var portfolioTransaction = (LedgerBackedPortfolioTransaction) portfolio.getTransactions().get(0); + + new LedgerBuySellEditor().apply(accountTransaction, LedgerBuySellEdit.builder().cashAmount(120L) + .securityAmount(121L).security(newSecurity).shares(Values.Share.factorize(9)).build()); + + assertThat(projection(entry, LedgerProjectionRole.ACCOUNT).getUUID(), is(accountProjectionUUID)); + assertThat(projection(entry, LedgerProjectionRole.PORTFOLIO).getUUID(), is(portfolioProjectionUUID)); + assertThat(posting(entry, LedgerPostingType.CASH).getUUID(), is(cashPostingUUID)); + assertThat(posting(entry, LedgerPostingType.SECURITY).getUUID(), is(securityPostingUUID)); + assertThat(accountTransaction.getAmount(), is(120L)); + assertThat(portfolioTransaction.getAmount(), is(121L)); + assertSame(newSecurity, portfolioTransaction.getSecurity()); + assertThat(portfolioTransaction.getShares(), is(Values.Share.factorize(9))); + } + + /** + * Checks the ledger-backed editing scenario: metadata patch rejects date clear. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testMetadataPatchRejectsDateTimeClear() throws ReflectiveOperationException + { + var entry = new LedgerEntry(); + + entry.setType(LedgerEntryType.DEPOSIT); + entry.setDateTime(DATE_TIME); + entry.addPosting(new LedgerPosting()); + + var builder = LedgerEntryMetadataPatch.builder(); + Field field = builder.getClass().getDeclaredField("dateTime"); + field.setAccessible(true); + field.set(builder, LedgerFieldEdit.clear()); + + var exception = assertThrows(IllegalArgumentException.class, + () -> LedgerEntryMetadataPatchHelper.apply(entry, builder.build())); + + assertThat(exception.getMessage(), is(LedgerDiagnosticCode.LEDGER_CORE_005 + .message("Ledger entry date/time cannot be cleared"))); + assertThat(entry.getDateTime(), is(DATE_TIME)); + } + + /** + * Checks the ledger-backed editing scenario: unsupported account transaction editor apply and validate branches + * keep distinct diagnostic code positions. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testUnsupportedAccountTransactionEditorMessagesUseDistinctConvertCodes() + { + var client = new Client(); + var account = account(); + var portfolio = portfolio(); + creator(client).createBuy(metadata(), cashLeg(account, 100), portfolioLeg(portfolio, 100), + LedgerCreationUnits.none()); + + LedgerProjectionService.materialize(client); + + var transaction = (LedgerBackedAccountTransaction) account.getTransactions().get(0); + var edit = LedgerAccountTransactionEdit.builder().build(); + + var applyFailure = assertThrows(IllegalArgumentException.class, + () -> new LedgerAccountTransactionEditor().apply(transaction, edit)); + assertThat(applyFailure.getMessage(), is(LedgerDiagnosticCode.LEDGER_CONVERT_007 + .message("Unsupported account transaction edit for BUY"))); + + var validateFailure = assertThrows(IllegalArgumentException.class, + () -> new LedgerAccountTransactionEditor().validate(transaction, edit)); + assertThat(validateFailure.getMessage(), is(LedgerDiagnosticCode.LEDGER_CONVERT_008 + .message("Unsupported account transaction edit for BUY"))); + } + + /** + * Checks the ledger-backed editing scenario: unsupported buy/sell editor apply and validate branches keep distinct + * diagnostic code positions. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testUnsupportedBuySellEditorMessagesUseDistinctConvertCodes() + { + var entry = new LedgerEntry(); + entry.setType(LedgerEntryType.DEPOSIT); + var edit = LedgerBuySellEdit.builder().build(); + + var applyFailure = assertThrows(IllegalArgumentException.class, + () -> new LedgerBuySellEditor().apply(entry, edit)); + assertThat(applyFailure.getMessage(), is(LedgerDiagnosticCode.LEDGER_CONVERT_025 + .message("Unsupported buy/sell edit for DEPOSIT"))); + + var validateFailure = assertThrows(IllegalArgumentException.class, + () -> new LedgerBuySellEditor().validate(entry, edit)); + assertThat(validateFailure.getMessage(), is(LedgerDiagnosticCode.LEDGER_CONVERT_026 + .message("Unsupported buy/sell edit for DEPOSIT"))); + } + + /** + * Checks the ledger-backed editing scenario: projection-removal guard reports the projection diagnostic code. + * The visible transaction must reflect the ledger entry after the operation. + * This protects support diagnostics from ambiguous projection edit failures. + */ + @Test + public void testProjectionRemovedByEditMessageUsesProjectionCode() throws Exception + { + var method = LedgerAccountTransactionEditor.class.getDeclaredMethod("ensureProjectionExists", LedgerEntry.class, //$NON-NLS-1$ + String.class); + var failureUUID = "missing-projection"; + + method.setAccessible(true); + + var failure = assertThrows(java.lang.reflect.InvocationTargetException.class, + () -> method.invoke(new LedgerAccountTransactionEditor(), new LedgerEntry(), failureUUID)); + + assertThat(failure.getCause().getMessage(), is(LedgerDiagnosticCode.LEDGER_PROJ_004 + .message("Projection was removed by edit: " + failureUUID))); + } + + /** + * Checks the ledger-backed editing scenario: account transfer editor patches both cash postings without swapping owners. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testAccountTransferEditorPatchesBothCashPostingsWithoutSwappingOwners() + { + var client = new Client(); + var source = account(); + var target = account(); + var entry = creator(client).createAccountTransfer(metadata(), LedgerCashTransferLeg.of(source, money(100)), + LedgerCashTransferLeg.of(target, money(100))).getEntry(); + + LedgerProjectionService.materialize(client); + + var sourceTransaction = (LedgerBackedAccountTransaction) source.getTransactions().get(0); + var targetTransaction = (LedgerBackedAccountTransaction) target.getTransactions().get(0); + + new LedgerAccountTransferEditor().apply(sourceTransaction, + LedgerAccountTransferEdit.builder().sourceAmount(110L).targetAmount(111L).build()); + + assertSame(source, projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT).getAccount()); + assertSame(target, projection(entry, LedgerProjectionRole.TARGET_ACCOUNT).getAccount()); + assertThat(sourceTransaction.getAmount(), is(110L)); + assertThat(targetTransaction.getAmount(), is(111L)); + } + + /** + * Checks the ledger-backed editing scenario: account transfer editor preserves primary forex and patches hidden units. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testAccountTransferEditorPreservesPrimaryForexAndPatchesHiddenUnits() + { + var client = new Client(); + var source = account(); + var target = account(); + var sourceForex = LedgerForexAmount.of(Money.of(CurrencyUnit.USD, 90L), new BigDecimal("1.10")); + var targetForex = LedgerForexAmount.of(Money.of(CurrencyUnit.USD, 180L), new BigDecimal("1.20")); + var entry = creator(client).createAccountTransfer(metadata(), + LedgerCashTransferLeg.of(source, money(100), sourceForex), + LedgerCashTransferLeg.of(target, money(200), targetForex)).getEntry(); + + new LedgerUnitPostingUpdater().apply(entry, LedgerUnitPostingPatch.of( + LedgerUnitPostingEdit.add(LedgerPostingType.GROSS_VALUE, money(300), + LedgerForexAmount.of(Money.of(CurrencyUnit.USD, + Values.Amount.factorize(270)), + new BigDecimal("1.1111"))), + LedgerUnitPostingEdit.add(LedgerPostingType.FEE, money(3)), + LedgerUnitPostingEdit.add(LedgerPostingType.TAX, money(4)))); + + var feeUUID = posting(entry, LedgerPostingType.FEE).getUUID(); + var taxUUID = posting(entry, LedgerPostingType.TAX).getUUID(); + + LedgerProjectionService.materialize(client); + + var sourceTransaction = (LedgerBackedAccountTransaction) source.getTransactions().get(0); + var targetTransaction = (LedgerBackedAccountTransaction) target.getTransactions().get(0); + + new LedgerAccountTransferEditor().apply(sourceTransaction, + LedgerAccountTransferEdit.builder().sourceAmount(110L).targetAmount(210L).build()); + + assertPrimaryForex(entry, LedgerProjectionRole.SOURCE_ACCOUNT, CurrencyUnit.USD, 90L, + new BigDecimal("1.10")); + assertPrimaryForex(entry, LedgerProjectionRole.TARGET_ACCOUNT, CurrencyUnit.USD, 180L, + new BigDecimal("1.20")); + assertThat(sourceTransaction.getUnit(name.abuchen.portfolio.model.Transaction.Unit.Type.GROSS_VALUE) + .orElseThrow().getForex().getAmount(), is(Values.Amount.factorize(270))); + assertThat(targetTransaction.getUnit(name.abuchen.portfolio.model.Transaction.Unit.Type.FEE).orElseThrow() + .getAmount().getAmount(), is(money(3).getAmount())); + + new LedgerAccountTransferEditor().apply(sourceTransaction, + LedgerAccountTransferEdit.builder() + .units(LedgerUnitPostingPatch.of( + LedgerUnitPostingEdit.update(feeUUID, money(5)), + LedgerUnitPostingEdit.remove(taxUUID), + LedgerUnitPostingEdit.add(LedgerPostingType.TAX, money(6)))) + .build()); + + assertThat(posting(entry, LedgerPostingType.FEE).getUUID(), is(feeUUID)); + assertThat(posting(entry, LedgerPostingType.FEE).getAmount(), is(money(5).getAmount())); + assertTrue(entry.getPostings().stream().noneMatch(posting -> posting.getUUID().equals(taxUUID))); + assertThat(posting(entry, LedgerPostingType.TAX).getAmount(), is(money(6).getAmount())); + assertOK(client); + } + + /** + * Checks the ledger-backed editing scenario: account transfer creator update preserves omitted primary forex. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testAccountTransferCreatorUpdatePreservesOmittedPrimaryForex() + { + var client = new Client(); + var source = account(); + var target = account(); + var sourceForex = LedgerForexAmount.of(Money.of(CurrencyUnit.USD, 90L), new BigDecimal("1.10")); + var targetForex = LedgerForexAmount.of(Money.of(CurrencyUnit.USD, 180L), new BigDecimal("1.20")); + var entry = creator(client).createAccountTransfer(metadata(), + LedgerCashTransferLeg.of(source, money(100), sourceForex), + LedgerCashTransferLeg.of(target, money(200), targetForex)).getEntry(); + + LedgerProjectionService.materialize(client); + + var sourceTransaction = (LedgerBackedAccountTransaction) source.getTransactions().get(0); + var targetTransaction = (LedgerBackedAccountTransaction) target.getTransactions().get(0); + var transfer = AccountTransferEntry.readOnly(source, sourceTransaction, target, targetTransaction); + + new LedgerAccountTransferTransactionCreator(client).update(transfer, source, target, DATE_TIME.plusDays(1), + 111L, CurrencyUnit.EUR, 222L, CurrencyUnit.EUR, null, null, "changed", "changed-source"); + + assertPrimaryForex(entry, LedgerProjectionRole.SOURCE_ACCOUNT, CurrencyUnit.USD, 90L, + new BigDecimal("1.10")); + assertPrimaryForex(entry, LedgerProjectionRole.TARGET_ACCOUNT, CurrencyUnit.USD, 180L, + new BigDecimal("1.20")); + assertOK(client); + } + + /** + * Checks the ledger-backed editing scenario: portfolio transfer editor patches both security postings without swapping owners. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testPortfolioTransferEditorPatchesBothSecurityPostingsWithoutSwappingOwners() + { + var client = new Client(); + var source = portfolio(); + var target = portfolio(); + var security = security(); + var entry = creator(client).createPortfolioTransfer(metadata(), + LedgerPortfolioTransferSecurity.of(security, Values.Share.factorize(5)), + LedgerPortfolioTransferLeg.of(source, money(100)), + LedgerPortfolioTransferLeg.of(target, money(100))).getEntry(); + + LedgerProjectionService.materialize(client); + + var sourceTransaction = (LedgerBackedPortfolioTransaction) source.getTransactions().get(0); + var targetTransaction = (LedgerBackedPortfolioTransaction) target.getTransactions().get(0); + + new LedgerPortfolioTransferEditor().apply(sourceTransaction, + LedgerPortfolioTransferEdit.builder().sourceAmount(110L).targetAmount(111L) + .sourceShares(Values.Share.factorize(6)) + .targetShares(Values.Share.factorize(6)).build()); + + assertSame(source, projection(entry, LedgerProjectionRole.SOURCE_PORTFOLIO).getPortfolio()); + assertSame(target, projection(entry, LedgerProjectionRole.TARGET_PORTFOLIO).getPortfolio()); + assertThat(sourceTransaction.getAmount(), is(110L)); + assertThat(targetTransaction.getAmount(), is(111L)); + assertThat(sourceTransaction.getShares(), is(Values.Share.factorize(6))); + assertThat(targetTransaction.getShares(), is(Values.Share.factorize(6))); + } + + /** + * Checks the ledger-backed editing scenario: portfolio transfer editor preserves primary forex and patches hidden units. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testPortfolioTransferEditorPreservesPrimaryForexAndPatchesHiddenUnits() + { + var client = new Client(); + var source = portfolio(); + var target = portfolio(); + var security = security(); + var entry = creator(client).createPortfolioTransfer(metadata(), + LedgerPortfolioTransferSecurity.of(security, Values.Share.factorize(5)), + LedgerPortfolioTransferLeg.of(source, money(100)), + LedgerPortfolioTransferLeg.of(target, money(100))).getEntry(); + var sourcePosting = primaryPosting(entry, LedgerProjectionRole.SOURCE_PORTFOLIO); + var targetPosting = primaryPosting(entry, LedgerProjectionRole.TARGET_PORTFOLIO); + + sourcePosting.setForexAmount(90L); + sourcePosting.setForexCurrency(CurrencyUnit.USD); + sourcePosting.setExchangeRate(new BigDecimal("1.10")); + targetPosting.setForexAmount(180L); + targetPosting.setForexCurrency(CurrencyUnit.USD); + targetPosting.setExchangeRate(new BigDecimal("1.20")); + + new LedgerUnitPostingUpdater().apply(entry, LedgerUnitPostingPatch.of( + LedgerUnitPostingEdit.add(LedgerPostingType.GROSS_VALUE, money(300), + LedgerForexAmount.of(Money.of(CurrencyUnit.USD, + Values.Amount.factorize(270)), + new BigDecimal("1.1111"))), + LedgerUnitPostingEdit.add(LedgerPostingType.FEE, money(3)), + LedgerUnitPostingEdit.add(LedgerPostingType.TAX, money(4)))); + + var feeUUID = posting(entry, LedgerPostingType.FEE).getUUID(); + var taxUUID = posting(entry, LedgerPostingType.TAX).getUUID(); + + LedgerProjectionService.materialize(client); + + var sourceTransaction = (LedgerBackedPortfolioTransaction) source.getTransactions().get(0); + var targetTransaction = (LedgerBackedPortfolioTransaction) target.getTransactions().get(0); + + new LedgerPortfolioTransferEditor().apply(sourceTransaction, + LedgerPortfolioTransferEdit.builder().sourceAmount(110L).targetAmount(111L).build()); + + assertPrimaryForex(entry, LedgerProjectionRole.SOURCE_PORTFOLIO, CurrencyUnit.USD, 90L, + new BigDecimal("1.10")); + assertPrimaryForex(entry, LedgerProjectionRole.TARGET_PORTFOLIO, CurrencyUnit.USD, 180L, + new BigDecimal("1.20")); + assertThat(sourceTransaction.getUnit(name.abuchen.portfolio.model.Transaction.Unit.Type.GROSS_VALUE) + .orElseThrow().getForex().getAmount(), is(Values.Amount.factorize(270))); + assertThat(targetTransaction.getUnit(name.abuchen.portfolio.model.Transaction.Unit.Type.FEE).orElseThrow() + .getAmount().getAmount(), is(money(3).getAmount())); + + new LedgerPortfolioTransferEditor().apply(sourceTransaction, + LedgerPortfolioTransferEdit.builder() + .units(LedgerUnitPostingPatch.of( + LedgerUnitPostingEdit.update(feeUUID, money(5)), + LedgerUnitPostingEdit.remove(taxUUID), + LedgerUnitPostingEdit.add(LedgerPostingType.TAX, money(6)))) + .build()); + + assertThat(posting(entry, LedgerPostingType.FEE).getUUID(), is(feeUUID)); + assertThat(posting(entry, LedgerPostingType.FEE).getAmount(), is(money(5).getAmount())); + assertTrue(entry.getPostings().stream().noneMatch(posting -> posting.getUUID().equals(taxUUID))); + assertThat(posting(entry, LedgerPostingType.TAX).getAmount(), is(money(6).getAmount())); + assertOK(client); + } + + /** + * Checks the ledger-backed editing scenario: buy/sell editor applies primary posting forex when supplied. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testBuySellEditorAppliesPrimaryPostingForexWhenSupplied() + { + var client = new Client(); + var account = account(); + var portfolio = portfolio(); + var entry = creator(client).createBuy(metadata(), cashLeg(account, 100), portfolioLeg(portfolio, 100), + LedgerCreationUnits.none()).getEntry(); + + LedgerProjectionService.materialize(client); + + var accountTransaction = (LedgerBackedAccountTransaction) account.getTransactions().get(0); + + new LedgerBuySellEditor().apply(accountTransaction, + LedgerBuySellEdit.builder() + .cashForexAmount(90L) + .cashForexCurrency(CurrencyUnit.USD) + .cashExchangeRate(new BigDecimal("1.10")) + .securityForexAmount(91L) + .securityForexCurrency(CurrencyUnit.USD) + .securityExchangeRate(new BigDecimal("1.11")) + .build()); + + assertPrimaryForex(entry, LedgerProjectionRole.ACCOUNT, CurrencyUnit.USD, 90L, new BigDecimal("1.10")); + assertPrimaryForex(entry, LedgerProjectionRole.PORTFOLIO, CurrencyUnit.USD, 91L, + new BigDecimal("1.11")); + assertOK(client); + } + + /** + * Checks the ledger-backed editing scenario: delivery creator update preserves omitted primary forex. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testDeliveryCreatorUpdatePreservesOmittedPrimaryForex() + { + var client = new Client(); + var portfolio = portfolio(); + var security = security(); + var transaction = new LedgerDeliveryTransactionCreator(client).create(portfolio, + PortfolioTransaction.Type.DELIVERY_INBOUND, DATE_TIME, 100L, CurrencyUnit.EUR, security, + Values.Share.factorize(5), Money.of(CurrencyUnit.USD, 90L), new BigDecimal("1.10"), + List.of(), "note", "source"); + var entry = ((LedgerBackedPortfolioTransaction) transaction).getLedgerEntry(); + + new LedgerDeliveryTransactionCreator(client).update(transaction, portfolio, + PortfolioTransaction.Type.DELIVERY_INBOUND, DATE_TIME.plusDays(1), 110L, CurrencyUnit.EUR, + security, Values.Share.factorize(6), null, null, List.of(), "changed", "changed-source"); + + assertPrimaryForex(entry, LedgerProjectionRole.DELIVERY_INBOUND, CurrencyUnit.USD, 90L, + new BigDecimal("1.10")); + assertOK(client); + } + + /** + * Checks the ledger-backed editing scenario: account only facade preserves and clears primary forex and hidden units. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testAccountOnlyFacadePreservesAndClearsPrimaryForexAndHiddenUnits() + { + var client = new Client(); + var account = account(); + var security = security(); + var creator = new LedgerAccountOnlyTransactionCreator(client); + var transaction = creator.create(account, name.abuchen.portfolio.model.AccountTransaction.Type.INTEREST, + DATE_TIME, 100L, CurrencyUnit.EUR, security, + LedgerForexAmount.of(Money.of(CurrencyUnit.USD, 90L), new BigDecimal("1.10")), + List.of(new name.abuchen.portfolio.model.Transaction.Unit( + name.abuchen.portfolio.model.Transaction.Unit.Type.FEE, money(3), + Money.of(CurrencyUnit.USD, Values.Amount.factorize(4)), + new BigDecimal("0.7500"))), + "note", "source"); + var ledgerTransaction = (LedgerBackedAccountTransaction) transaction; + var entry = ledgerTransaction.getLedgerEntry(); + var feeUUID = posting(entry, LedgerPostingType.FEE).getUUID(); + + creator.update(transaction, account, name.abuchen.portfolio.model.AccountTransaction.Type.INTEREST, + DATE_TIME.plusDays(1), 101L, CurrencyUnit.EUR, security, null, + LedgerUnitPostingPatch.none(), "changed", "changed-source"); + + assertPrimaryForex(entry, LedgerProjectionRole.ACCOUNT, CurrencyUnit.USD, 90L, new BigDecimal("1.10")); + assertThat(posting(entry, LedgerPostingType.FEE).getUUID(), is(feeUUID)); + assertThat(posting(entry, LedgerPostingType.FEE).getForexAmount(), is(Values.Amount.factorize(4))); + + creator.update(transaction, account, name.abuchen.portfolio.model.AccountTransaction.Type.INTEREST, + DATE_TIME.plusDays(2), 102L, CurrencyUnit.EUR, security, LedgerForexAmount.none(), + LedgerUnitPostingPatch.of(LedgerUnitPostingEdit.clearForex(feeUUID)), "changed", + "changed-source"); + + assertNoPrimaryForex(entry, LedgerProjectionRole.ACCOUNT); + assertNull(posting(entry, LedgerPostingType.FEE).getForexAmount()); + assertOK(client); + } + + /** + * Checks the ledger-backed editing scenario: deposit and removal facade creates units and preserves them on unrelated edit. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testDepositAndRemovalFacadeCreatesUnitsAndPreservesThemOnUnrelatedEdit() + { + var client = new Client(); + var account = account(); + var creator = new LedgerAccountOnlyTransactionCreator(client); + var units = List.of(new name.abuchen.portfolio.model.Transaction.Unit( + name.abuchen.portfolio.model.Transaction.Unit.Type.GROSS_VALUE, money(20), + Money.of(CurrencyUnit.USD, Values.Amount.factorize(18)), new BigDecimal("1.1111")), + new name.abuchen.portfolio.model.Transaction.Unit( + name.abuchen.portfolio.model.Transaction.Unit.Type.FEE, money(2), + Money.of(CurrencyUnit.USD, Values.Amount.factorize(3)), + new BigDecimal("0.6667")), + new name.abuchen.portfolio.model.Transaction.Unit( + name.abuchen.portfolio.model.Transaction.Unit.Type.TAX, money(4), + Money.of(CurrencyUnit.USD, Values.Amount.factorize(5)), + new BigDecimal("0.8000"))); + + var deposit = creator.create(account, name.abuchen.portfolio.model.AccountTransaction.Type.DEPOSIT, DATE_TIME, + 100L, CurrencyUnit.EUR, null, LedgerForexAmount.none(), units, "note", "source"); + var removal = creator.create(account, name.abuchen.portfolio.model.AccountTransaction.Type.REMOVAL, DATE_TIME, + 101L, CurrencyUnit.EUR, null, LedgerForexAmount.none(), units, "note", "source"); + var depositEntry = ((LedgerBackedAccountTransaction) deposit).getLedgerEntry(); + var removalEntry = ((LedgerBackedAccountTransaction) removal).getLedgerEntry(); + var depositFeeUUID = posting(depositEntry, LedgerPostingType.FEE).getUUID(); + var removalTaxUUID = posting(removalEntry, LedgerPostingType.TAX).getUUID(); + + assertThat(deposit.getUnit(name.abuchen.portfolio.model.Transaction.Unit.Type.GROSS_VALUE).orElseThrow() + .getForex().getAmount(), is(Values.Amount.factorize(18))); + assertThat(deposit.getUnit(name.abuchen.portfolio.model.Transaction.Unit.Type.FEE).orElseThrow() + .getForex().getAmount(), is(Values.Amount.factorize(3))); + assertThat(removal.getUnit(name.abuchen.portfolio.model.Transaction.Unit.Type.TAX).orElseThrow() + .getForex().getAmount(), is(Values.Amount.factorize(5))); + + creator.update(deposit, account, name.abuchen.portfolio.model.AccountTransaction.Type.DEPOSIT, + DATE_TIME.plusDays(1), 102L, CurrencyUnit.EUR, null, null, LedgerUnitPostingPatch.none(), + "changed", "changed-source"); + creator.update(removal, account, name.abuchen.portfolio.model.AccountTransaction.Type.REMOVAL, + DATE_TIME.plusDays(1), 103L, CurrencyUnit.EUR, null, null, LedgerUnitPostingPatch.none(), + "changed", "changed-source"); + + assertThat(posting(depositEntry, LedgerPostingType.FEE).getUUID(), is(depositFeeUUID)); + assertThat(posting(depositEntry, LedgerPostingType.FEE).getForexAmount(), is(Values.Amount.factorize(3))); + assertThat(posting(removalEntry, LedgerPostingType.TAX).getUUID(), is(removalTaxUUID)); + assertThat(posting(removalEntry, LedgerPostingType.TAX).getForexAmount(), is(Values.Amount.factorize(5))); + assertOK(client); + } + + /** + * Checks the ledger-backed editing scenario: buy/sell facade carries primary forex and preserves hidden units. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testBuySellFacadeCarriesPrimaryForexAndPreservesHiddenUnits() + { + var client = new Client(); + var account = account(); + var portfolio = portfolio(); + var security = security(); + var creator = new name.abuchen.portfolio.model.ledger.compatibility.LedgerBuySellTransactionCreator(client); + BuySellEntry entry = creator.create(portfolio, account, PortfolioTransaction.Type.BUY, DATE_TIME, 100L, + CurrencyUnit.EUR, security, Values.Share.factorize(5), + LedgerForexAmount.of(Money.of(CurrencyUnit.USD, 90L), new BigDecimal("1.10")), + LedgerForexAmount.of(Money.of(CurrencyUnit.USD, 91L), new BigDecimal("1.11")), + List.of(new name.abuchen.portfolio.model.Transaction.Unit( + name.abuchen.portfolio.model.Transaction.Unit.Type.TAX, money(4), + Money.of(CurrencyUnit.USD, Values.Amount.factorize(5)), + new BigDecimal("0.8000"))), + "note", "source"); + var ledgerEntry = ((LedgerBackedAccountTransaction) entry.getAccountTransaction()).getLedgerEntry(); + var taxUUID = posting(ledgerEntry, LedgerPostingType.TAX).getUUID(); + + creator.update(entry, portfolio, account, PortfolioTransaction.Type.BUY, DATE_TIME.plusDays(1), 101L, + CurrencyUnit.EUR, security, Values.Share.factorize(6), null, null, + LedgerUnitPostingPatch.none(), "changed", "changed-source"); + + assertPrimaryForex(ledgerEntry, LedgerProjectionRole.ACCOUNT, CurrencyUnit.USD, 90L, + new BigDecimal("1.10")); + assertPrimaryForex(ledgerEntry, LedgerProjectionRole.PORTFOLIO, CurrencyUnit.USD, 91L, + new BigDecimal("1.11")); + assertThat(posting(ledgerEntry, LedgerPostingType.TAX).getUUID(), is(taxUUID)); + assertThat(posting(ledgerEntry, LedgerPostingType.TAX).getForexAmount(), is(Values.Amount.factorize(5))); + assertOK(client); + } + + /** + * Checks the ledger-backed editing scenario: account transfer facade carries target forex and hidden units. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testAccountTransferFacadeCarriesTargetForexAndHiddenUnits() + { + var client = new Client(); + var source = account(); + var target = account(); + var creator = new LedgerAccountTransferTransactionCreator(client); + var transfer = creator.create(source, target, DATE_TIME, 100L, CurrencyUnit.EUR, 200L, CurrencyUnit.EUR, + LedgerForexAmount.of(Money.of(CurrencyUnit.USD, 90L), new BigDecimal("1.10")), + LedgerForexAmount.of(Money.of(CurrencyUnit.USD, 180L), new BigDecimal("1.20")), + LedgerUnitPostingPatch.of(LedgerUnitPostingEdit.add(LedgerPostingType.GROSS_VALUE, money(300), + LedgerForexAmount.of(Money.of(CurrencyUnit.USD, 270L), + new BigDecimal("1.1111")))), + "note", "source"); + var ledgerEntry = ((LedgerBackedAccountTransaction) transfer.getSourceTransaction()).getLedgerEntry(); + var grossValueUUID = posting(ledgerEntry, LedgerPostingType.GROSS_VALUE).getUUID(); + + creator.update(transfer, source, target, DATE_TIME.plusDays(1), 101L, CurrencyUnit.EUR, 201L, + CurrencyUnit.EUR, null, null, LedgerUnitPostingPatch.none(), "changed", + "changed-source"); + + assertPrimaryForex(ledgerEntry, LedgerProjectionRole.SOURCE_ACCOUNT, CurrencyUnit.USD, 90L, + new BigDecimal("1.10")); + assertPrimaryForex(ledgerEntry, LedgerProjectionRole.TARGET_ACCOUNT, CurrencyUnit.USD, 180L, + new BigDecimal("1.20")); + assertThat(posting(ledgerEntry, LedgerPostingType.GROSS_VALUE).getUUID(), is(grossValueUUID)); + assertThat(posting(ledgerEntry, LedgerPostingType.GROSS_VALUE).getForexAmount(), is(270L)); + assertOK(client); + } + + /** + * Checks the ledger-backed editing scenario: portfolio transfer facade carries primary forex and hidden units. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testPortfolioTransferFacadeCarriesPrimaryForexAndHiddenUnits() + { + var client = new Client(); + var source = portfolio(); + var target = portfolio(); + var security = security(); + var creator = new name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferTransactionCreator( + client); + PortfolioTransferEntry transfer = creator.create(source, target, security, DATE_TIME, + Values.Share.factorize(5), 100L, CurrencyUnit.EUR, + LedgerForexAmount.of(Money.of(CurrencyUnit.USD, 90L), new BigDecimal("1.10")), + LedgerForexAmount.of(Money.of(CurrencyUnit.USD, 180L), new BigDecimal("1.20")), + LedgerUnitPostingPatch.of(LedgerUnitPostingEdit.add(LedgerPostingType.FEE, money(3), + LedgerForexAmount.of(Money.of(CurrencyUnit.USD, 4L), + new BigDecimal("0.7500")))), + "note", "source"); + var ledgerEntry = ((LedgerBackedPortfolioTransaction) transfer.getSourceTransaction()).getLedgerEntry(); + var feeUUID = posting(ledgerEntry, LedgerPostingType.FEE).getUUID(); + + creator.update(transfer, source, target, security, DATE_TIME.plusDays(1), Values.Share.factorize(6), 101L, + CurrencyUnit.EUR, null, null, LedgerUnitPostingPatch.none(), "changed", + "changed-source"); + + assertPrimaryForex(ledgerEntry, LedgerProjectionRole.SOURCE_PORTFOLIO, CurrencyUnit.USD, 90L, + new BigDecimal("1.10")); + assertPrimaryForex(ledgerEntry, LedgerProjectionRole.TARGET_PORTFOLIO, CurrencyUnit.USD, 180L, + new BigDecimal("1.20")); + assertThat(posting(ledgerEntry, LedgerPostingType.FEE).getUUID(), is(feeUUID)); + assertThat(posting(ledgerEntry, LedgerPostingType.FEE).getForexAmount(), is(4L)); + assertOK(client); + } + + /** + * Checks the ledger-backed editing scenario: invalid edit does not partially mutate ledger truth. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testInvalidEditDoesNotPartiallyMutateLedgerTruth() + { + var client = new Client(); + var account = account(); + var entry = creator(client).createDeposit(metadata(), cashLeg(account, 100)).getEntry(); + var originalAmount = entry.getPostings().get(0).getAmount(); + var originalCurrency = entry.getPostings().get(0).getCurrency(); + + LedgerProjectionService.materialize(client); + + var transaction = (LedgerBackedAccountTransaction) account.getTransactions().get(0); + + assertThrows(IllegalArgumentException.class, () -> new LedgerAccountTransactionEditor().apply(transaction, + LedgerAccountTransactionEdit.builder().amount(-1L).currency(CurrencyUnit.USD).build())); + + assertThat(entry.getPostings().get(0).getAmount(), is(originalAmount)); + assertThat(entry.getPostings().get(0).getCurrency(), is(originalCurrency)); + } + + /** + * Checks the ledger-backed editing scenario: invalid security posting edit does not partially mutate ledger truth. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testInvalidSecurityPostingEditDoesNotPartiallyMutateLedgerTruth() + { + var client = new Client(); + var portfolio = portfolio(); + var entry = creator(client).createInboundDelivery(metadata(), deliveryLeg(portfolio)).getEntry(); + var posting = posting(entry, LedgerPostingType.SECURITY); + var originalSecurity = posting.getSecurity(); + var originalShares = posting.getShares(); + var replacementSecurity = security(); + + LedgerProjectionService.materialize(client); + + var transaction = (LedgerBackedPortfolioTransaction) portfolio.getTransactions().get(0); + + assertThrows(IllegalArgumentException.class, () -> new LedgerDeliveryTransactionEditor().apply(transaction, + LedgerDeliveryTransactionEdit.builder().security(replacementSecurity).shares(-1L).build())); + + assertSame(originalSecurity, posting(entry, LedgerPostingType.SECURITY).getSecurity()); + assertThat(posting(entry, LedgerPostingType.SECURITY).getShares(), is(originalShares)); + } + + /** + * Checks the ledger-backed editing scenario: invalid ex-date parameter edit does not partially mutate ledger truth. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testInvalidExDateParameterEditDoesNotPartiallyMutateLedgerTruth() + { + var client = new Client(); + var account = account(); + var dividend = LedgerDividend.withExDate(cashLeg(account, 30), LedgerOptionalSecurity.of(security()), + LedgerCreationUnits.none(), DATE_TIME.minusDays(5)); + var entry = creator(client).createDividend(metadata(), dividend).getEntry(); + var postingUUID = entry.getPostings().get(0).getUUID(); + + assertThrows(IllegalArgumentException.class, () -> LedgerEntryEditSupport.applyValidated(entry, editedEntry -> { + var posting = LedgerEntryEditSupport.postingByUUID(editedEntry, postingUUID); + + posting.getParameters().stream() // + .filter(parameter -> parameter.getType() == LedgerParameterType.EX_DATE) // + .toList().forEach(posting::removeParameter); + posting.addParameter(LedgerParameter.unchecked(LedgerParameterType.EX_DATE, + LedgerParameter.ValueKind.STRING, "invalid")); + })); + + assertThat(exDate(entry.getPostings().get(0)), is(DATE_TIME.minusDays(5))); + } + + /** + * Checks the ledger-backed editing scenario: invalid unit posting add update and remove do not partially mutate ledger truth. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testInvalidUnitPostingAddUpdateAndRemoveDoNotPartiallyMutateLedgerTruth() + { + var client = new Client(); + var account = account(); + var entry = creator(client).createDeposit(metadata(), cashLeg(account, 100)).getEntry(); + var updater = new LedgerUnitPostingUpdater(); + var originalPostingCount = entry.getPostings().size(); + + assertThrows(IllegalArgumentException.class, () -> updater.apply(entry, + LedgerUnitPostingPatch.of(LedgerUnitPostingEdit.add(LedgerPostingType.FEE, money(-1))))); + + assertThat(entry.getPostings().size(), is(originalPostingCount)); + + updater.apply(entry, LedgerUnitPostingPatch.of(LedgerUnitPostingEdit.add(LedgerPostingType.FEE, money(1)))); + + var fee = posting(entry, LedgerPostingType.FEE); + var feeUUID = fee.getUUID(); + var originalFeeAmount = fee.getAmount(); + var originalFeeCurrency = fee.getCurrency(); + + assertThrows(IllegalArgumentException.class, () -> updater.apply(entry, + LedgerUnitPostingPatch.of(LedgerUnitPostingEdit.update(feeUUID, + Money.of(CurrencyUnit.USD, -1L))))); + + assertThat(posting(entry, LedgerPostingType.FEE).getAmount(), is(originalFeeAmount)); + assertThat(posting(entry, LedgerPostingType.FEE).getCurrency(), is(originalFeeCurrency)); + + var cashPostingUUID = posting(entry, LedgerPostingType.CASH).getUUID(); + var postingCountBeforeInvalidRemove = entry.getPostings().size(); + + assertThrows(IllegalArgumentException.class, + () -> updater.apply(entry, LedgerUnitPostingPatch.of(LedgerUnitPostingEdit.remove(cashPostingUUID)))); + + assertThat(entry.getPostings().size(), is(postingCountBeforeInvalidRemove)); + assertThat(posting(entry, LedgerPostingType.CASH).getUUID(), is(cashPostingUUID)); + } + + /** + * Checks the ledger-backed editing scenario: materialized projection reflects edited ledger truth. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testMaterializedProjectionReflectsEditedLedgerTruth() + { + var client = new Client(); + var account = account(); + + creator(client).createDeposit(metadata(), cashLeg(account, 100)); + LedgerProjectionService.materialize(client); + + var transaction = (LedgerBackedAccountTransaction) account.getTransactions().get(0); + + new LedgerAccountTransactionEditor().apply(transaction, LedgerAccountTransactionEdit.builder().amount(222L).build()); + + assertThat(transaction.getAmount(), is(222L)); + } + + private LedgerTransactionCreator creator(Client client) + { + return new LedgerTransactionCreator(client); + } + + private LedgerTransactionMetadata metadata() + { + return LedgerTransactionMetadata.of(DATE_TIME).withNote("note").withSource("source"); + } + + private Account account() + { + return new Account(); + } + + private Portfolio portfolio() + { + return new Portfolio(); + } + + private Security security() + { + return new Security("Security", CurrencyUnit.EUR); + } + + private LedgerAccountCashLeg cashLeg(Account account, int amount) + { + return LedgerAccountCashLeg.of(account, money(amount)); + } + + private LedgerDeliveryLeg deliveryLeg(Portfolio portfolio) + { + return LedgerDeliveryLeg.of(portfolio, LedgerSecurityQuantity.of(security(), Values.Share.factorize(5)), + money(100)); + } + + private LedgerPortfolioSecurityLeg portfolioLeg(Portfolio portfolio, int amount) + { + return LedgerPortfolioSecurityLeg.of(portfolio, + LedgerSecurityQuantity.of(security(), Values.Share.factorize(5)), money(amount)); + } + + private Money money(int amount) + { + return Money.of(CurrencyUnit.EUR, Values.Amount.factorize(amount)); + } + + private LedgerPosting posting(LedgerEntry entry, LedgerPostingType type) + { + return entry.getPostings().stream().filter(posting -> posting.getType() == type).findFirst().orElseThrow(); + } + + private LedgerPosting primaryPosting(LedgerEntry entry, LedgerProjectionRole role) + { + return LedgerProjectionSupport.primaryPosting(entry, projection(entry, role)); + } + + private void assertPrimaryForex(LedgerEntry entry, LedgerProjectionRole role, String currency, long amount, + BigDecimal exchangeRate) + { + var posting = primaryPosting(entry, role); + + assertThat(posting.getForexCurrency(), is(currency)); + assertThat(posting.getForexAmount(), is(amount)); + assertThat(posting.getExchangeRate(), is(exchangeRate)); + } + + private void assertNoPrimaryForex(LedgerEntry entry, LedgerProjectionRole role) + { + var posting = primaryPosting(entry, role); + + assertThat(posting.getForexCurrency(), is((String) null)); + assertThat(posting.getForexAmount(), is((Long) null)); + assertThat(posting.getExchangeRate(), is((BigDecimal) null)); + } + + private void assertOK(Client client) + { + assertTrue(LedgerStructuralValidator.validate(client.getLedger()).isOK()); + } + + private LocalDateTime exDate(LedgerPosting posting) + { + return posting.getParameters().stream() // + .filter(parameter -> parameter.getType() == LedgerParameterType.EX_DATE) // + .map(LedgerParameter::getValue) // + .filter(LocalDateTime.class::isInstance) // + .map(LocalDateTime.class::cast) // + .findFirst().orElseThrow(); + } + + private LedgerProjectionRef projection(LedgerEntry entry, LedgerProjectionRole role) + { + return entry.getProjectionRefs().stream().filter(projection -> projection.getRole() == role).findFirst() + .orElseThrow(); + } + +} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerTransactionCreatorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerTransactionCreatorTest.java new file mode 100644 index 0000000000..928e9a7675 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerTransactionCreatorTest.java @@ -0,0 +1,657 @@ +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.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.lang.reflect.Modifier; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Arrays; + +import org.junit.Test; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.Client; +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.compatibility.LedgerAccountCashLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerCashTransferLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerCreationUnit; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerCreationUnits; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerDeliveryLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerDividend; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerForexAmount; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerOptionalSecurity; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioSecurityLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferSecurity; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerSecurityQuantity; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerTransactionCreator; +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.model.ledger.projection.LedgerBackedAccountTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionService; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.Values; + +/** + * Tests ledger-aware creation and editing of transactions in this family. + * These tests make sure user-visible rows are rebuilt from ledger truth and structural facts are not written through legacy projections. + */ +@SuppressWarnings("nls") +public class LedgerTransactionCreatorTest +{ + private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 1, 2, 0, 0); + + /** + * Checks the ledger-backed editing scenario: metadata allows absent blank note and blank source. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testMetadataAllowsAbsentBlankNoteAndBlankSource() + { + var absent = LedgerTransactionMetadata.of(DATE_TIME); + var blankNote = absent.withNote(" "); + var blankSource = absent.withSource(""); + + assertNull(absent.getNote()); + assertNull(absent.getSource()); + assertThat(blankNote.getNote(), is(" ")); + assertThat(blankSource.getSource(), is("")); + } + + /** + * Checks the ledger-backed editing scenario: cash leg allows zero and negative amounts. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testCashLegAllowsZeroAndNegativeAmounts() + { + var account = account(); + var zero = LedgerAccountCashLeg.of(account, Money.of(CurrencyUnit.EUR, 0L)); + var negative = LedgerAccountCashLeg.of(account, Money.of(CurrencyUnit.EUR, -1L)); + + assertThat(zero.getAmount().getAmount(), is(0L)); + assertThat(negative.getAmount().getAmount(), is(-1L)); + } + + /** + * Checks the ledger-backed editing scenario: security quantity allows zero and negative shares. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testSecurityQuantityAllowsZeroAndNegativeShares() + { + var security = security(); + var zero = LedgerSecurityQuantity.of(security, 0L); + var negative = LedgerSecurityQuantity.of(security, -1L); + + assertThat(zero.getShares(), is(0L)); + assertThat(negative.getShares(), is(-1L)); + } + + /** + * Checks the ledger-backed editing scenario: account cash leg does not reject currency mismatch. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testAccountCashLegDoesNotRejectCurrencyMismatch() + { + var account = account(); + + account.setCurrencyCode(CurrencyUnit.USD); + + var leg = LedgerAccountCashLeg.of(account, money(10)); + + assertSame(account, leg.getAccount()); + assertThat(leg.getAmount().getCurrencyCode(), is(CurrencyUnit.EUR)); + } + + /** + * Checks the ledger-backed editing scenario: create deposit adds one ledger entry. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testCreateDepositAddsOneLedgerEntry() + { + var client = new Client(); + var account = account(); + var creator = new LedgerTransactionCreator(client); + + var created = creator.createDeposit(metadata(), cashLeg(account, 100)); + var entry = created.getEntry(); + + assertThat(client.getLedger().getEntries().size(), is(1)); + assertSame(entry, client.getLedger().getEntries().get(0)); + assertThat(entry.getType(), is(LedgerEntryType.DEPOSIT)); + assertThat(entry.getPostings().size(), is(1)); + assertThat(entry.getPostings().get(0).getType(), is(LedgerPostingType.CASH)); + assertThat(entry.getPostings().get(0).getAmount(), is(Values.Amount.factorize(100))); + assertThat(entry.getProjectionRefs().size(), is(1)); + assertThat(entry.getProjectionRefs().get(0).getRole(), is(LedgerProjectionRole.ACCOUNT)); + assertSame(account, entry.getProjectionRefs().get(0).getAccount()); + assertThat(entry.getProjectionRefs().get(0).getPrimaryPostingUUID(), is(entry.getPostings().get(0).getUUID())); + assertOK(client); + } + + /** + * Checks the ledger-backed editing scenario: create removal uses positive amount. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testCreateRemovalUsesPositiveAmount() + { + var client = new Client(); + var creator = new LedgerTransactionCreator(client); + var created = creator.createRemoval(metadata(), cashLeg(account(), 25)); + + assertThat(created.getEntry().getType(), is(LedgerEntryType.REMOVAL)); + assertThat(created.getEntry().getPostings().get(0).getAmount(), is(Values.Amount.factorize(25))); + assertTrue(created.getEntry().getPostings().get(0).getAmount() > 0); + assertOK(client); + } + + /** + * Checks the ledger-backed editing scenario: create deposit and removal support units and unit forex. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testCreateDepositAndRemovalSupportUnitsAndUnitForex() + { + var client = new Client(); + var account = account(); + var creator = new LedgerTransactionCreator(client); + var units = LedgerCreationUnits.of( + LedgerCreationUnit.grossValue(money(100), + LedgerForexAmount.of(Money.of(CurrencyUnit.USD, Values.Amount.factorize(90)), + new BigDecimal("1.1111"))), + LedgerCreationUnit.fee(money(2), + LedgerForexAmount.of(Money.of(CurrencyUnit.USD, Values.Amount.factorize(3)), + new BigDecimal("0.6667"))), + LedgerCreationUnit.tax(money(4), + LedgerForexAmount.of(Money.of(CurrencyUnit.USD, Values.Amount.factorize(5)), + new BigDecimal("0.8000")))); + + var deposit = creator.createDeposit(metadata(), cashLeg(account, 10), units).getEntry(); + var removal = creator.createRemoval(metadata(), cashLeg(account, 11), units).getEntry(); + + assertThat(deposit.getType(), is(LedgerEntryType.DEPOSIT)); + assertThat(removal.getType(), is(LedgerEntryType.REMOVAL)); + assertUnitForex(deposit, LedgerPostingType.GROSS_VALUE, Values.Amount.factorize(90), + new BigDecimal("1.1111")); + assertUnitForex(deposit, LedgerPostingType.FEE, Values.Amount.factorize(3), new BigDecimal("0.6667")); + assertUnitForex(deposit, LedgerPostingType.TAX, Values.Amount.factorize(5), new BigDecimal("0.8000")); + assertUnitForex(removal, LedgerPostingType.GROSS_VALUE, Values.Amount.factorize(90), + new BigDecimal("1.1111")); + assertUnitForex(removal, LedgerPostingType.FEE, Values.Amount.factorize(3), new BigDecimal("0.6667")); + assertUnitForex(removal, LedgerPostingType.TAX, Values.Amount.factorize(5), new BigDecimal("0.8000")); + + LedgerProjectionService.materialize(client); + + var depositProjection = (LedgerBackedAccountTransaction) account.getTransactions().get(0); + var removalProjection = (LedgerBackedAccountTransaction) account.getTransactions().get(1); + + assertThat(depositProjection.getUnit(name.abuchen.portfolio.model.Transaction.Unit.Type.GROSS_VALUE) + .orElseThrow().getForex().getAmount(), is(Values.Amount.factorize(90))); + assertThat(removalProjection.getUnit(name.abuchen.portfolio.model.Transaction.Unit.Type.TAX) + .orElseThrow().getForex().getAmount(), is(Values.Amount.factorize(5))); + assertOK(client); + } + + /** + * Checks the ledger-backed editing scenario: create interest supports optional security and units. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testCreateInterestSupportsOptionalSecurityAndUnits() + { + var client = new Client(); + var creator = new LedgerTransactionCreator(client); + var security = security(); + var units = LedgerCreationUnits.of(LedgerCreationUnit.fee(money(1))); + + var created = creator.createInterest(metadata(), cashLeg(account(), 10), LedgerOptionalSecurity.of(security), + units); + var entry = created.getEntry(); + + assertThat(entry.getType(), is(LedgerEntryType.INTEREST)); + assertSame(security, entry.getPostings().get(0).getSecurity()); + assertTrue(entry.getPostings().stream().anyMatch(p -> p.getType() == LedgerPostingType.FEE)); + assertOK(client); + } + + /** + * Checks the ledger-backed editing scenario: create fee tax families create valid account shapes. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testCreateFeeTaxFamiliesCreateValidAccountShapes() + { + var client = new Client(); + var creator = new LedgerTransactionCreator(client); + var account = account(); + var security = LedgerOptionalSecurity.none(); + var units = LedgerCreationUnits.none(); + + assertThat(creator.createFee(metadata(), LedgerAccountCashLeg.of(account, money(1)), security, units) + .getEntry().getType(), is(LedgerEntryType.FEES)); + assertThat(creator.createFeeRefund(metadata(), LedgerAccountCashLeg.of(account, money(2)), security, units) + .getEntry().getType(), is(LedgerEntryType.FEES_REFUND)); + assertThat(creator.createTax(metadata(), LedgerAccountCashLeg.of(account, money(3)), security, units) + .getEntry().getType(), is(LedgerEntryType.TAXES)); + assertThat(creator.createTaxRefund(metadata(), LedgerAccountCashLeg.of(account, money(4)), security, units) + .getEntry().getType(), is(LedgerEntryType.TAX_REFUND)); + assertOK(client); + } + + /** + * Checks the ledger-backed editing scenario: create standard families bind projection refs to primary postings. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testCreateStandardFamiliesBindProjectionRefsToPrimaryPostings() + { + assertAccountProjectionTargetsPrimaryPosting(LedgerEntryType.DEPOSIT, + creator -> creator.createDeposit(metadata(), cashLeg(account(), 10)).getEntry()); + assertAccountProjectionTargetsPrimaryPosting(LedgerEntryType.REMOVAL, + creator -> creator.createRemoval(metadata(), cashLeg(account(), 10)).getEntry()); + assertAccountProjectionTargetsPrimaryPosting(LedgerEntryType.INTEREST, + creator -> creator.createInterest(metadata(), cashLeg(account(), 10), + LedgerOptionalSecurity.none(), LedgerCreationUnits.none()).getEntry()); + assertAccountProjectionTargetsPrimaryPosting(LedgerEntryType.INTEREST_CHARGE, + creator -> creator.createInterestCharge(metadata(), cashLeg(account(), 10), + LedgerOptionalSecurity.none(), LedgerCreationUnits.none()).getEntry()); + assertAccountProjectionTargetsPrimaryPosting(LedgerEntryType.FEES, + creator -> creator.createFee(metadata(), cashLeg(account(), 10), + LedgerOptionalSecurity.none(), LedgerCreationUnits.none()).getEntry()); + assertAccountProjectionTargetsPrimaryPosting(LedgerEntryType.FEES_REFUND, + creator -> creator.createFeeRefund(metadata(), cashLeg(account(), 10), + LedgerOptionalSecurity.none(), LedgerCreationUnits.none()).getEntry()); + assertAccountProjectionTargetsPrimaryPosting(LedgerEntryType.TAXES, + creator -> creator.createTax(metadata(), cashLeg(account(), 10), + LedgerOptionalSecurity.none(), LedgerCreationUnits.none()).getEntry()); + assertAccountProjectionTargetsPrimaryPosting(LedgerEntryType.TAX_REFUND, + creator -> creator.createTaxRefund(metadata(), cashLeg(account(), 10), + LedgerOptionalSecurity.none(), LedgerCreationUnits.none()).getEntry()); + assertAccountProjectionTargetsPrimaryPosting(LedgerEntryType.DIVIDENDS, + creator -> creator.createDividend(metadata(), + LedgerDividend.withoutExDate(cashLeg(account(), 10), + LedgerOptionalSecurity.of(security()), + LedgerCreationUnits.none())) + .getEntry()); + + assertTwoProjectionTargetsPrimaryPostings(LedgerEntryType.BUY, + creator -> creator.createBuy(metadata(), cashLeg(account(), 10), + portfolioLeg(new Portfolio(), 10), LedgerCreationUnits.none()).getEntry(), + LedgerProjectionRole.ACCOUNT, LedgerProjectionRole.PORTFOLIO); + assertTwoProjectionTargetsPrimaryPostings(LedgerEntryType.SELL, + creator -> creator.createSell(metadata(), cashLeg(account(), 10), + portfolioLeg(new Portfolio(), 10), LedgerCreationUnits.none()).getEntry(), + LedgerProjectionRole.ACCOUNT, LedgerProjectionRole.PORTFOLIO); + + assertPortfolioProjectionTargetsPrimaryPosting(LedgerEntryType.DELIVERY_INBOUND, + creator -> creator.createInboundDelivery(metadata(), deliveryLeg(new Portfolio())).getEntry()); + assertPortfolioProjectionTargetsPrimaryPosting(LedgerEntryType.DELIVERY_OUTBOUND, + creator -> creator.createOutboundDelivery(metadata(), deliveryLeg(new Portfolio())).getEntry()); + + assertTwoProjectionTargetsPrimaryPostings(LedgerEntryType.CASH_TRANSFER, + creator -> creator.createAccountTransfer(metadata(), + LedgerCashTransferLeg.of(account(), money(10)), + LedgerCashTransferLeg.of(account(), money(10))).getEntry(), + LedgerProjectionRole.SOURCE_ACCOUNT, LedgerProjectionRole.TARGET_ACCOUNT); + assertTwoProjectionTargetsPrimaryPostings(LedgerEntryType.SECURITY_TRANSFER, + creator -> creator.createPortfolioTransfer(metadata(), + LedgerPortfolioTransferSecurity.of(security(), Values.Share.factorize(5)), + LedgerPortfolioTransferLeg.of(new Portfolio(), money(10)), + LedgerPortfolioTransferLeg.of(new Portfolio(), money(10))).getEntry(), + LedgerProjectionRole.SOURCE_PORTFOLIO, LedgerProjectionRole.TARGET_PORTFOLIO); + } + + /** + * Checks the ledger-backed editing scenario: create dividend preserves ex-date units and forex. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testCreateDividendPreservesExDateUnitsAndForex() + { + var client = new Client(); + var creator = new LedgerTransactionCreator(client); + var exDate = LocalDateTime.of(2025, 12, 20, 0, 0); + var forex = LedgerForexAmount.of(Money.of(CurrencyUnit.USD, Values.Amount.factorize(30)), + BigDecimal.valueOf(0.91)); + var units = LedgerCreationUnits.of(LedgerCreationUnit.tax(money(3)), + LedgerCreationUnit.fee(money(2)), + LedgerCreationUnit.grossValue(money(35), forex)); + var dividend = LedgerDividend.withExDate(cashLeg(account(), 30), LedgerOptionalSecurity.of(security()), units, + exDate); + + var entry = creator.createDividend(metadata(), dividend).getEntry(); + var cashPosting = entry.getPostings().get(0); + var exDateParameter = cashPosting.getParameters().get(0); + var grossValuePosting = posting(entry, LedgerPostingType.GROSS_VALUE); + + assertThat(entry.getType(), is(LedgerEntryType.DIVIDENDS)); + assertThat(cashPosting.getType(), is(LedgerPostingType.CASH)); + assertThat(exDateParameter.getType(), is(LedgerParameterType.EX_DATE)); + assertThat(exDateParameter.getValueKind(), is(ValueKind.LOCAL_DATE_TIME)); + assertThat(exDateParameter.getValue(), is(exDate)); + assertTrue(entry.getPostings().stream().anyMatch(p -> p.getType() == LedgerPostingType.TAX)); + assertTrue(entry.getPostings().stream().anyMatch(p -> p.getType() == LedgerPostingType.FEE)); + assertThat(grossValuePosting.getForexAmount(), is(Values.Amount.factorize(30))); + assertThat(grossValuePosting.getForexCurrency(), is(CurrencyUnit.USD)); + assertThat(grossValuePosting.getExchangeRate(), is(BigDecimal.valueOf(0.91))); + assertOK(client); + } + + /** + * Checks the ledger-backed editing scenario: create buy creates two projection refs with positive magnitudes. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testCreateBuyCreatesTwoProjectionRefsWithPositiveMagnitudes() + { + var client = new Client(); + var account = account(); + var portfolio = new Portfolio(); + var creator = new LedgerTransactionCreator(client); + + var entry = creator.createBuy(metadata(), cashLeg(account, 100), portfolioLeg(portfolio, 100), + LedgerCreationUnits.of(LedgerCreationUnit.fee(money(1)))).getEntry(); + + assertThat(entry.getType(), is(LedgerEntryType.BUY)); + assertThat(entry.getProjectionRefs().size(), is(2)); + assertTrue(entry.getPostings().stream().allMatch(p -> p.getAmount() >= 0 && p.getShares() >= 0)); + assertSame(account, projection(entry, LedgerProjectionRole.ACCOUNT).getAccount()); + assertSame(portfolio, projection(entry, LedgerProjectionRole.PORTFOLIO).getPortfolio()); + assertTrue(account.getTransactions().isEmpty()); + assertTrue(portfolio.getTransactions().isEmpty()); + assertOK(client); + } + + /** + * Checks the ledger-backed editing scenario: create sell creates two projection refs with positive magnitudes. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testCreateSellCreatesTwoProjectionRefsWithPositiveMagnitudes() + { + var client = new Client(); + var creator = new LedgerTransactionCreator(client); + var entry = creator.createSell(metadata(), cashLeg(account(), 100), portfolioLeg(new Portfolio(), 100), + LedgerCreationUnits.none()).getEntry(); + + assertThat(entry.getType(), is(LedgerEntryType.SELL)); + assertThat(entry.getProjectionRefs().size(), is(2)); + assertTrue(entry.getPostings().stream().allMatch(p -> p.getAmount() >= 0 && p.getShares() >= 0)); + assertOK(client); + } + + /** + * Checks the ledger-backed editing scenario: create account transfer creates source and target account projections. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testCreateAccountTransferCreatesSourceAndTargetAccountProjections() + { + var client = new Client(); + var source = account(); + var target = account(); + var creator = new LedgerTransactionCreator(client); + + var entry = creator.createAccountTransfer(metadata(), LedgerCashTransferLeg.of(source, money(100)), + LedgerCashTransferLeg.of(target, money(100))).getEntry(); + + assertThat(entry.getType(), is(LedgerEntryType.CASH_TRANSFER)); + assertThat(entry.getProjectionRefs().size(), is(2)); + assertSame(source, projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT).getAccount()); + assertSame(target, projection(entry, LedgerProjectionRole.TARGET_ACCOUNT).getAccount()); + assertOK(client); + } + + /** + * Checks the ledger-backed editing scenario: create portfolio transfer creates source and target portfolio projections. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testCreatePortfolioTransferCreatesSourceAndTargetPortfolioProjections() + { + var client = new Client(); + var source = new Portfolio(); + var target = new Portfolio(); + var creator = new LedgerTransactionCreator(client); + + var entry = creator.createPortfolioTransfer(metadata(), + LedgerPortfolioTransferSecurity.of(security(), Values.Share.factorize(5)), + LedgerPortfolioTransferLeg.of(source, money(100)), + LedgerPortfolioTransferLeg.of(target, money(100))).getEntry(); + + assertThat(entry.getType(), is(LedgerEntryType.SECURITY_TRANSFER)); + assertThat(entry.getProjectionRefs().size(), is(2)); + assertSame(source, projection(entry, LedgerProjectionRole.SOURCE_PORTFOLIO).getPortfolio()); + assertSame(target, projection(entry, LedgerProjectionRole.TARGET_PORTFOLIO).getPortfolio()); + assertOK(client); + } + + /** + * Checks the ledger-backed editing scenario: create deliveries create one portfolio projection. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testCreateDeliveriesCreateOnePortfolioProjection() + { + var client = new Client(); + var portfolio = new Portfolio(); + var creator = new LedgerTransactionCreator(client); + + var inbound = creator.createInboundDelivery(metadata(), deliveryLeg(portfolio)).getEntry(); + var outbound = creator.createOutboundDelivery(metadata(), deliveryLeg(portfolio)).getEntry(); + + assertThat(inbound.getType(), is(LedgerEntryType.DELIVERY_INBOUND)); + assertThat(inbound.getProjectionRefs().size(), is(1)); + assertThat(inbound.getProjectionRefs().get(0).getRole(), is(LedgerProjectionRole.DELIVERY_INBOUND)); + assertThat(outbound.getType(), is(LedgerEntryType.DELIVERY_OUTBOUND)); + assertThat(outbound.getProjectionRefs().size(), is(1)); + assertThat(outbound.getProjectionRefs().get(0).getRole(), is(LedgerProjectionRole.DELIVERY_OUTBOUND)); + assertTrue(portfolio.getTransactions().isEmpty()); + assertOK(client); + } + + /** + * Checks the ledger-backed editing scenario: missing required input does not partially add entry. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testMissingRequiredInputDoesNotPartiallyAddEntry() + { + var client = new Client(); + var creator = new LedgerTransactionCreator(client); + + assertThrows(NullPointerException.class, () -> creator.createDeposit(metadata(), null)); + assertTrue(client.getLedger().getEntries().isEmpty()); + } + + /** + * Checks the ledger-backed editing scenario: invalid standard family sign fails through creator validation without partial mutation. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testInvalidStandardFamilySignFailsThroughCreatorValidationWithoutPartialMutation() + { + var client = new Client(); + var creator = new LedgerTransactionCreator(client); + + assertThrows(IllegalArgumentException.class, + () -> creator.createDeposit(metadata(), + LedgerAccountCashLeg.of(account(), Money.of(CurrencyUnit.EUR, -1L)))); + assertTrue(client.getLedger().getEntries().isEmpty()); + } + + /** + * Checks the ledger-backed editing scenario: invalid standard family shares fail through creator validation without partial mutation. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testInvalidStandardFamilySharesFailThroughCreatorValidationWithoutPartialMutation() + { + var client = new Client(); + var creator = new LedgerTransactionCreator(client); + + assertThrows(IllegalArgumentException.class, + () -> creator.createBuy(metadata(), cashLeg(account(), 100), + LedgerPortfolioSecurityLeg.of(new Portfolio(), + LedgerSecurityQuantity.of(security(), -1L), money(100)), + LedgerCreationUnits.none())); + assertTrue(client.getLedger().getEntries().isEmpty()); + } + + /** + * Checks the ledger-backed editing scenario: public creator methods do not use long positional parameter lists. + * The visible transaction must reflect the ledger entry after the operation. + * This protects structural facts from being written through legacy setters. + */ + @Test + public void testPublicCreatorMethodsDoNotUseLongPositionalParameterLists() + { + assertTrue(Arrays.stream(LedgerTransactionCreator.class.getDeclaredMethods()) + .filter(method -> Modifier.isPublic(method.getModifiers())) + .filter(method -> method.getName().startsWith("create")) + .allMatch(method -> method.getParameterCount() <= 4)); + assertFalse(Arrays.stream(LedgerTransactionCreator.class.getDeclaredMethods()) + .anyMatch(method -> method.getName().equals("createAccountTransaction"))); + assertFalse(Arrays.stream(LedgerTransactionCreator.class.getDeclaredMethods()) + .anyMatch(method -> method.getName().equals("createPortfolioTransaction"))); + assertFalse(Arrays.stream(LedgerTransactionCreator.class.getDeclaredMethods()) + .anyMatch(method -> method.getName().equals("createBuySell"))); + } + + private LedgerTransactionMetadata metadata() + { + return LedgerTransactionMetadata.of(DATE_TIME); + } + + private Account account() + { + return new Account(); + } + + private Security security() + { + return new Security("Security", CurrencyUnit.EUR); + } + + private LedgerAccountCashLeg cashLeg(Account account, int amount) + { + return LedgerAccountCashLeg.of(account, money(amount)); + } + + private LedgerDeliveryLeg deliveryLeg(Portfolio portfolio) + { + return LedgerDeliveryLeg.of(portfolio, LedgerSecurityQuantity.of(security(), Values.Share.factorize(5)), + money(100)); + } + + private LedgerPortfolioSecurityLeg portfolioLeg(Portfolio portfolio, int amount) + { + return LedgerPortfolioSecurityLeg.of(portfolio, + LedgerSecurityQuantity.of(security(), Values.Share.factorize(5)), money(amount)); + } + + private Money money(int amount) + { + return Money.of(CurrencyUnit.EUR, Values.Amount.factorize(amount)); + } + + private LedgerPosting posting(LedgerEntry entry, LedgerPostingType type) + { + return entry.getPostings().stream().filter(p -> p.getType() == type).findFirst().orElseThrow(); + } + + private void assertUnitForex(LedgerEntry entry, LedgerPostingType type, long forexAmount, BigDecimal exchangeRate) + { + var posting = posting(entry, type); + + assertThat(posting.getForexAmount(), is(forexAmount)); + assertThat(posting.getForexCurrency(), is(CurrencyUnit.USD)); + assertThat(posting.getExchangeRate(), is(exchangeRate)); + } + + private LedgerProjectionRef projection(LedgerEntry entry, LedgerProjectionRole role) + { + return entry.getProjectionRefs().stream().filter(p -> p.getRole() == role).findFirst().orElseThrow(); + } + + private void assertAccountProjectionTargetsPrimaryPosting(LedgerEntryType type, + java.util.function.Function factory) + { + var client = new Client(); + var entry = factory.apply(new LedgerTransactionCreator(client)); + var projection = projection(entry, LedgerProjectionRole.ACCOUNT); + var posting = entry.getPostings().stream().filter(p -> p.getAccount() == projection.getAccount()).findFirst() + .orElseThrow(); + + assertThat(entry.getType(), is(type)); + assertThat(projection.getPrimaryPostingUUID(), is(posting.getUUID())); + assertOK(client); + } + + private void assertPortfolioProjectionTargetsPrimaryPosting(LedgerEntryType type, + java.util.function.Function factory) + { + var client = new Client(); + var entry = factory.apply(new LedgerTransactionCreator(client)); + var projection = entry.getProjectionRefs().get(0); + var posting = entry.getPostings().stream().filter(p -> p.getPortfolio() == projection.getPortfolio()) + .findFirst().orElseThrow(); + + assertThat(entry.getType(), is(type)); + assertThat(projection.getPrimaryPostingUUID(), is(posting.getUUID())); + assertOK(client); + } + + private void assertTwoProjectionTargetsPrimaryPostings(LedgerEntryType type, + java.util.function.Function factory, + LedgerProjectionRole firstRole, LedgerProjectionRole secondRole) + { + var client = new Client(); + var entry = factory.apply(new LedgerTransactionCreator(client)); + + assertThat(entry.getType(), is(type)); + assertThat(projection(entry, firstRole).getPrimaryPostingUUID(), is(entry.getPostings().get(0).getUUID())); + assertThat(projection(entry, secondRole).getPrimaryPostingUUID(), is(entry.getPostings().get(1).getUUID())); + assertOK(client); + } + + private void assertOK(Client client) + { + var result = LedgerStructuralValidator.validate(client.getLedger()); + + assertTrue(result.getIssues().toString(), result.isOK()); + } +} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountOnlyTransactionCreatorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountOnlyTransactionCreatorTest.java new file mode 100644 index 0000000000..41797cc89b --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountOnlyTransactionCreatorTest.java @@ -0,0 +1,432 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import static org.hamcrest.CoreMatchers.instanceOf; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.Test; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.ClientFactory; +import name.abuchen.portfolio.model.ProtobufTestUtilities; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.Transaction.Unit; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; +import name.abuchen.portfolio.model.ledger.ProjectionMembershipRole; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.Values; + +/** + * Tests ledger-aware creation and editing of transactions in this family. + * These tests make sure user-visible rows are rebuilt from ledger truth and structural facts are not written through legacy projections. + */ +@SuppressWarnings("nls") +public class LedgerAccountOnlyTransactionCreatorTest +{ + private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 6, 7, 8, 9); + + /** + * Verifies that standard account-only booking families are created directly in the ledger. + * The resulting account transaction must be a projection of the persisted ledger entry. + */ + @Test + public void testCreatesLedgerEntryDirectlyForAllAccountOnlyFamilies() + { + for (var fixture : fixtures()) + { + var client = new Client(); + var account = account(); + client.addAccount(account); + + var transaction = create(client, account, fixture.type()); + var entry = client.getLedger().getEntries().get(0); + var posting = entry.getPostings().get(0); + var projection = entry.getProjectionRefs().get(0); + + assertThat(entry.getType(), is(fixture.entryType())); + assertThat(client.getLedger().getEntries().size(), is(1)); + assertThat(entry.getPostings().size(), is(1)); + assertThat(posting.getType(), is(fixture.postingType())); + assertThat(posting.getAmount(), is(Values.Amount.factorize(123))); + assertThat(posting.getCurrency(), is(CurrencyUnit.EUR)); + assertSame(account, posting.getAccount()); + assertThat(entry.getProjectionRefs().size(), is(1)); + assertSame(account, projection.getAccount()); + assertThat(projection.getPrimaryPostingUUID(), is(posting.getUUID())); + assertPrimaryMembership(projection, posting.getUUID()); + assertThat(transaction.getUUID(), is(projection.getUUID())); + assertThat(transaction, instanceOf(LedgerBackedTransaction.class)); + assertThat(transaction.getType(), is(fixture.type())); + assertThat(transaction.getDateTime(), is(DATE_TIME)); + assertThat(transaction.getAmount(), is(Values.Amount.factorize(123))); + assertThat(transaction.getCurrencyCode(), is(CurrencyUnit.EUR)); + assertThat(transaction.getNote(), is("note")); + assertThat(transaction.getSource(), is("source")); + assertThat(account.getTransactions(), is(List.of(transaction))); + assertThat(client.getAllTransactions().size(), is(1)); + assertValid(client); + } + } + + /** + * Verifies that dialog-style account-only fee/tax input is stored as the primary posting. + * The UI can provide the amount through the matching unit field while the total is still zero. + */ + @Test + public void testCreatesFeeTaxPrimaryPostingFromMatchingDialogUnit() + { + assertPrimaryPostingFromMatchingDialogUnit(AccountTransaction.Type.FEES, LedgerEntryType.FEES, + LedgerPostingType.FEE, Unit.Type.FEE); + assertPrimaryPostingFromMatchingDialogUnit(AccountTransaction.Type.FEES_REFUND, LedgerEntryType.FEES_REFUND, + LedgerPostingType.FEE, Unit.Type.FEE); + assertPrimaryPostingFromMatchingDialogUnit(AccountTransaction.Type.TAXES, LedgerEntryType.TAXES, + LedgerPostingType.TAX, Unit.Type.TAX); + assertPrimaryPostingFromMatchingDialogUnit(AccountTransaction.Type.TAX_REFUND, LedgerEntryType.TAX_REFUND, + LedgerPostingType.TAX, Unit.Type.TAX); + } + + /** + * Verifies that optional security and unit facts are stored with account-only ledger bookings. + * The projected account transaction must expose the same business values. + */ + @Test + public void testCreatesOptionalSecurityAndUnitPostings() + { + var client = new Client(); + var account = account(); + var security = new Security("Security", CurrencyUnit.USD); + var units = List.of( + new Unit(Unit.Type.FEE, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(1))), + new Unit(Unit.Type.TAX, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(2))), + new Unit(Unit.Type.GROSS_VALUE, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(20)), + Money.of(CurrencyUnit.USD, Values.Amount.factorize(25)), + BigDecimal.valueOf(0.8))); + + client.addAccount(account); + client.addSecurity(security); + + var transaction = new LedgerAccountOnlyTransactionCreator(client).create(account, AccountTransaction.Type.FEES, + DATE_TIME, Values.Amount.factorize(17), CurrencyUnit.EUR, security, units, "note", "source"); + var entry = client.getLedger().getEntries().get(0); + var projection = entry.getProjectionRefs().get(0); + + assertThat(transaction, instanceOf(LedgerBackedTransaction.class)); + assertSame(security, entry.getPostings().get(0).getSecurity()); + assertTrue(entry.getPostings().stream().anyMatch(posting -> posting.getType() == LedgerPostingType.FEE + && posting.getAmount() == Values.Amount.factorize(1))); + assertTrue(entry.getPostings().stream().anyMatch(posting -> posting.getType() == LedgerPostingType.TAX + && posting.getAmount() == Values.Amount.factorize(2))); + assertTrue(entry.getPostings().stream().anyMatch(posting -> posting.getType() == LedgerPostingType.GROSS_VALUE + && posting.getAmount() == Values.Amount.factorize(20) + && posting.getForexAmount() == Values.Amount.factorize(25) + && CurrencyUnit.USD.equals(posting.getForexCurrency()) + && BigDecimal.valueOf(0.8).equals(posting.getExchangeRate()))); + assertThat(projection.getMembershipsByRole(ProjectionMembershipRole.FEE_UNIT).size(), is(1)); + assertThat(projection.getMembershipsByRole(ProjectionMembershipRole.TAX_UNIT).size(), is(1)); + assertThat(projection.getMembershipsByRole(ProjectionMembershipRole.GROSS_VALUE_UNIT).size(), is(1)); + assertValid(client); + } + + /** + * Verifies that unsupported account-only families are rejected before creation. + * The creator must not leave a partial ledger entry behind. + */ + @Test + public void testRejectsUnsupportedFamiliesWithoutMutation() + { + var client = new Client(); + var account = account(); + client.addAccount(account); + + assertThrows(UnsupportedOperationException.class, + () -> create(client, account, AccountTransaction.Type.DIVIDENDS)); + assertThrows(UnsupportedOperationException.class, () -> create(client, account, AccountTransaction.Type.BUY)); + assertThrows(UnsupportedOperationException.class, () -> create(client, account, AccountTransaction.Type.SELL)); + assertThrows(UnsupportedOperationException.class, + () -> create(client, account, AccountTransaction.Type.TRANSFER_IN)); + assertThrows(UnsupportedOperationException.class, + () -> create(client, account, AccountTransaction.Type.TRANSFER_OUT)); + + assertTrue(client.getLedger().getEntries().isEmpty()); + assertTrue(account.getTransactions().isEmpty()); + } + + /** + * Verifies that metadata setters remain allowed on account-only projections. + * Structural setters must stay blocked so runtime projections do not become a second truth. + */ + @Test + public void testCreatedProjectionAllowsMetadataEditsAndRejectsStructuralSetters() + { + var client = new Client(); + var account = account(); + client.addAccount(account); + + var transaction = create(client, account, AccountTransaction.Type.DEPOSIT); + var updatedDateTime = DATE_TIME.plusDays(1); + + transaction.setDateTime(updatedDateTime); + transaction.setNote("updated note"); + transaction.setSource("updated source"); + + var entry = client.getLedger().getEntries().get(0); + + assertThat(entry.getDateTime(), is(updatedDateTime)); + assertThat(entry.getNote(), is("updated note")); + assertThat(entry.getSource(), is("updated source")); + assertThrows(UnsupportedOperationException.class, () -> transaction.setAmount(1L)); + assertThrows(UnsupportedOperationException.class, () -> transaction.setCurrencyCode(CurrencyUnit.USD)); + assertThrows(UnsupportedOperationException.class, transaction::clearUnits); + assertValid(client); + } + + /** + * Verifies that same-shape account edits and owner moves are applied through ledger paths. + * The projected booking must move owners without replaying legacy setters as persisted truth. + */ + @Test + public void testFacadeAppliesSameShapeLedgerEditAndMovesOwner() throws Exception + { + var client = new Client(); + var account = account(); + var otherAccount = account(); + client.addAccount(account); + client.addAccount(otherAccount); + var creator = new LedgerAccountOnlyTransactionCreator(client); + var transaction = create(client, account, AccountTransaction.Type.DEPOSIT); + var updatedDateTime = DATE_TIME.plusDays(2); + var entry = client.getLedger().getEntries().get(0); + var entryUUID = entry.getUUID(); + var postingUUID = entry.getPostings().get(0).getUUID(); + var projectionUUID = entry.getProjectionRefs().get(0).getUUID(); + + creator.update(transaction, account, AccountTransaction.Type.DEPOSIT, updatedDateTime, + Values.Amount.factorize(456), CurrencyUnit.EUR, null, List.of( + new Unit(Unit.Type.FEE, Money.of(CurrencyUnit.EUR, + Values.Amount.factorize(1)))), + "updated note", "updated source"); + + assertThat(entry.getDateTime(), is(updatedDateTime)); + assertThat(entry.getPostings().get(0).getAmount(), is(Values.Amount.factorize(456))); + assertThat(transaction.getNote(), is("updated note")); + assertThat(transaction.getSource(), is("updated source")); + assertTrue(entry.getPostings().stream().anyMatch(posting -> posting.getType() == LedgerPostingType.FEE + && posting.getAmount() == Values.Amount.factorize(1))); + + var moved = creator.update(transaction, otherAccount, AccountTransaction.Type.DEPOSIT, updatedDateTime, + Values.Amount.factorize(789), CurrencyUnit.EUR, null, List.of(), "moved note", + "moved source"); + + assertTrue(account.getTransactions().isEmpty()); + assertThat(otherAccount.getTransactions(), is(List.of(moved))); + assertThat(moved.getUUID(), is(projectionUUID)); + assertThat(client.getLedger().getEntries().get(0).getUUID(), is(entryUUID)); + assertThat(entry.getPostings().get(0).getUUID(), is(postingUUID)); + assertThat(entry.getProjectionRefs().get(0).getUUID(), is(projectionUUID)); + assertSame(otherAccount, entry.getPostings().get(0).getAccount()); + assertSame(otherAccount, entry.getProjectionRefs().get(0).getAccount()); + assertThat(moved.getAmount(), is(Values.Amount.factorize(789))); + assertThat(moved.getNote(), is("moved note")); + assertThat(client.getAllTransactions().size(), is(1)); + assertThat(projectionUUIDs(loadXml(saveXml(client))), is(List.of(projectionUUID))); + assertThat(projectionUUIDs(loadProtobuf(saveProtobuf(client))), is(List.of(projectionUUID))); + assertValid(client); + } + + /** + * Verifies that XML save/load/save preserves account-only projection identity. + * The booking must rematerialize from the same ledger entry after reload. + */ + @Test + public void testXmlSaveLoadSavePreservesAccountOnlyLedgerProjectionUUID() throws Exception + { + var client = accountOnlyClient(); + var expectedProjectionUUIDs = projectionUUIDs(client); + + var loaded = loadXml(saveXml(client)); + var reloaded = loadXml(saveXml(loaded)); + + assertThat(projectionUUIDs(reloaded), is(expectedProjectionUUIDs)); + assertThat(reloaded.getLedger().getEntries().size(), is(8)); + assertThat(reloaded.getAccounts().get(0).getTransactions().size(), is(8)); + assertTrue(reloaded.getAccounts().get(0).getTransactions().stream() + .allMatch(LedgerBackedTransaction.class::isInstance)); + assertThat(reloaded.getAllTransactions().size(), is(8)); + assertValid(reloaded); + } + + /** + * Verifies that protobuf save/load/save preserves account-only projection identity. + * The booking must rematerialize from the same ledger entry after reload. + */ + @Test + public void testProtobufSaveLoadSavePreservesAccountOnlyLedgerProjectionUUID() throws Exception + { + var client = accountOnlyClient(); + var expectedProjectionUUIDs = projectionUUIDs(client); + + var loaded = loadProtobuf(saveProtobuf(client)); + var reloaded = loadProtobuf(saveProtobuf(loaded)); + + assertThat(projectionUUIDs(reloaded), is(expectedProjectionUUIDs)); + assertThat(reloaded.getLedger().getEntries().size(), is(8)); + assertThat(reloaded.getAccounts().get(0).getTransactions().size(), is(8)); + assertTrue(reloaded.getAccounts().get(0).getTransactions().stream() + .allMatch(LedgerBackedTransaction.class::isInstance)); + assertThat(reloaded.getAllTransactions().size(), is(8)); + assertValid(reloaded); + } + + private AccountTransaction create(Client client, Account account, AccountTransaction.Type type) + { + return new LedgerAccountOnlyTransactionCreator(client).create(account, type, DATE_TIME, + Values.Amount.factorize(123), CurrencyUnit.EUR, null, List.of(), "note", "source"); + } + + private void assertPrimaryPostingFromMatchingDialogUnit(AccountTransaction.Type type, LedgerEntryType entryType, + LedgerPostingType postingType, Unit.Type unitType) + { + var client = new Client(); + var account = account(); + var units = List.of(new Unit(unitType, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(123)))); + + client.addAccount(account); + + var transaction = new LedgerAccountOnlyTransactionCreator(client).create(account, type, DATE_TIME, 0, + CurrencyUnit.EUR, null, units, "note", "source"); + var entry = client.getLedger().getEntries().get(0); + var posting = entry.getPostings().get(0); + var projection = entry.getProjectionRefs().get(0); + + assertThat(client.getLedger().getEntries().size(), is(1)); + assertThat(entry.getType(), is(entryType)); + assertThat(entry.getPostings().size(), is(1)); + assertThat(posting.getType(), is(postingType)); + assertThat(posting.getAmount(), is(Values.Amount.factorize(123))); + assertThat(posting.getCurrency(), is(CurrencyUnit.EUR)); + assertSame(account, posting.getAccount()); + assertThat(entry.getProjectionRefs().size(), is(1)); + assertThat(projection.getPrimaryPostingUUID(), is(posting.getUUID())); + assertPrimaryMembership(projection, posting.getUUID()); + assertThat(transaction, instanceOf(LedgerBackedTransaction.class)); + assertThat(transaction.getType(), is(type)); + assertThat(transaction.getAmount(), is(Values.Amount.factorize(123))); + assertThat(transaction.getCurrencyCode(), is(CurrencyUnit.EUR)); + assertThat(account.getTransactions(), is(List.of(transaction))); + assertThat(client.getAllTransactions().size(), is(1)); + assertValid(client); + } + + private Client accountOnlyClient() + { + var client = new Client(); + var account = account(); + client.addAccount(account); + + for (var fixture : fixtures()) + create(client, account, fixture.type()); + + return client; + } + + private List projectionUUIDs(Client client) + { + return client.getLedger().getEntries().stream() + .flatMap(entry -> entry.getProjectionRefs().stream()) + .map(LedgerProjectionRef::getUUID) + .toList(); + } + + private void assertPrimaryMembership(LedgerProjectionRef projection, String postingUUID) + { + assertThat(projection.getPrimaryMembership().orElseThrow().getPostingUUID(), is(postingUUID)); + assertThat(projection.getPrimaryMembership().orElseThrow().getRole(), is(ProjectionMembershipRole.PRIMARY)); + } + + private String saveXml(Client client) throws IOException + { + var file = File.createTempFile("ledger-account-only", ".xml"); + try + { + ClientFactory.save(client, file); + return Files.readString(file.toPath(), StandardCharsets.UTF_8); + } + finally + { + Files.deleteIfExists(file.toPath()); + } + } + + private Client loadXml(String xml) throws IOException + { + return ClientFactory.load(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); + } + + private byte[] saveProtobuf(Client client) throws IOException + { + return ProtobufTestUtilities.save(client); + } + + private Client loadProtobuf(byte[] bytes) throws IOException + { + return ProtobufTestUtilities.load(bytes); + } + + private void assertValid(Client client) + { + var result = LedgerStructuralValidator.validate(client.getLedger()); + + assertThat(result.getIssues().toString(), result.isOK(), is(true)); + } + + private Account account() + { + var account = new Account("Account"); + account.setCurrencyCode(CurrencyUnit.EUR); + return account; + } + + private List fixtures() + { + return List.of( + new Fixture(AccountTransaction.Type.DEPOSIT, LedgerEntryType.DEPOSIT, + LedgerPostingType.CASH), + new Fixture(AccountTransaction.Type.REMOVAL, LedgerEntryType.REMOVAL, + LedgerPostingType.CASH), + new Fixture(AccountTransaction.Type.INTEREST, LedgerEntryType.INTEREST, + LedgerPostingType.CASH), + new Fixture(AccountTransaction.Type.INTEREST_CHARGE, LedgerEntryType.INTEREST_CHARGE, + LedgerPostingType.CASH), + new Fixture(AccountTransaction.Type.FEES, LedgerEntryType.FEES, + LedgerPostingType.FEE), + new Fixture(AccountTransaction.Type.FEES_REFUND, LedgerEntryType.FEES_REFUND, + LedgerPostingType.FEE), + new Fixture(AccountTransaction.Type.TAXES, LedgerEntryType.TAXES, + LedgerPostingType.TAX), + new Fixture(AccountTransaction.Type.TAX_REFUND, LedgerEntryType.TAX_REFUND, + LedgerPostingType.TAX)); + } + + private record Fixture(AccountTransaction.Type type, LedgerEntryType entryType, LedgerPostingType postingType) + { + } +} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferTransactionCreatorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferTransactionCreatorTest.java new file mode 100644 index 0000000000..cd17b19c3e --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferTransactionCreatorTest.java @@ -0,0 +1,414 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import static org.hamcrest.CoreMatchers.instanceOf; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.Test; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.AccountTransferEntry; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.ClientFactory; +import name.abuchen.portfolio.model.ProtobufTestUtilities; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.Values; + +/** + * Tests ledger-aware creation and editing of transactions in this family. + * These tests make sure user-visible rows are rebuilt from ledger truth and structural facts are not written through legacy projections. + */ +@SuppressWarnings("nls") +public class LedgerAccountTransferTransactionCreatorTest +{ + private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 6, 7, 8, 9); + private static final BigDecimal EXCHANGE_RATE = BigDecimal.valueOf(0.5); + + /** + * Verifies that an account transfer is created directly in the ledger. + * Source and target account rows must be projections of one persisted transfer booking. + */ + @Test + public void testCreatesLedgerAccountTransferDirectly() + { + var fixture = fixture(CurrencyUnit.EUR, CurrencyUnit.USD); + var transfer = createTransfer(fixture); + var sourceTransaction = transfer.getSourceTransaction(); + var targetTransaction = transfer.getTargetTransaction(); + var entry = fixture.client().getLedger().getEntries().get(0); + var sourcePosting = entry.getPostings().get(0); + var targetPosting = entry.getPostings().get(1); + var sourceProjection = projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT); + var targetProjection = projection(entry, LedgerProjectionRole.TARGET_ACCOUNT); + + assertThat(entry.getType(), is(LedgerEntryType.CASH_TRANSFER)); + assertThat(entry.getDateTime(), is(DATE_TIME)); + assertThat(entry.getNote(), is("note")); + assertThat(entry.getSource(), is("source")); + assertThat(entry.getPostings().size(), is(2)); + assertThat(sourcePosting.getType(), is(LedgerPostingType.CASH)); + assertThat(sourcePosting.getAmount(), is(Values.Amount.factorize(100))); + assertThat(sourcePosting.getCurrency(), is(CurrencyUnit.EUR)); + assertThat(sourcePosting.getForexAmount(), is(Values.Amount.factorize(200))); + assertThat(sourcePosting.getForexCurrency(), is(CurrencyUnit.USD)); + assertThat(sourcePosting.getExchangeRate(), is(EXCHANGE_RATE)); + assertSame(fixture.source(), sourcePosting.getAccount()); + assertThat(targetPosting.getType(), is(LedgerPostingType.CASH)); + assertThat(targetPosting.getAmount(), is(Values.Amount.factorize(200))); + assertThat(targetPosting.getCurrency(), is(CurrencyUnit.USD)); + assertSame(fixture.target(), targetPosting.getAccount()); + assertTrue(entry.getPostings().stream().allMatch(posting -> posting.getPortfolio() == null)); + assertTrue(entry.getPostings().stream().allMatch(posting -> posting.getSecurity() == null)); + + assertThat(sourceProjection.getRole(), is(LedgerProjectionRole.SOURCE_ACCOUNT)); + assertSame(fixture.source(), sourceProjection.getAccount()); + assertThat(sourceProjection.getPrimaryPostingUUID(), is(sourcePosting.getUUID())); + assertThat(sourceProjection.getPrimaryMembership().orElseThrow().getPostingUUID(), is(sourcePosting.getUUID())); + assertThat(targetProjection.getRole(), is(LedgerProjectionRole.TARGET_ACCOUNT)); + assertSame(fixture.target(), targetProjection.getAccount()); + assertThat(targetProjection.getPrimaryPostingUUID(), is(targetPosting.getUUID())); + assertThat(targetProjection.getPrimaryMembership().orElseThrow().getPostingUUID(), is(targetPosting.getUUID())); + assertThat(sourceTransaction.getUUID(), is(sourceProjection.getUUID())); + assertThat(targetTransaction.getUUID(), is(targetProjection.getUUID())); + assertThat(sourceTransaction, instanceOf(LedgerBackedTransaction.class)); + assertThat(targetTransaction, instanceOf(LedgerBackedTransaction.class)); + assertThat(sourceTransaction.getType(), is(AccountTransaction.Type.TRANSFER_OUT)); + assertThat(targetTransaction.getType(), is(AccountTransaction.Type.TRANSFER_IN)); + assertThat(sourceTransaction.getAmount(), is(Values.Amount.factorize(100))); + assertThat(sourceTransaction.getCurrencyCode(), is(CurrencyUnit.EUR)); + assertThat(targetTransaction.getAmount(), is(Values.Amount.factorize(200))); + assertThat(targetTransaction.getCurrencyCode(), is(CurrencyUnit.USD)); + assertThat(sourceTransaction.getNote(), is("note")); + assertThat(sourceTransaction.getSource(), is("source")); + assertThat(fixture.source().getTransactions(), is(List.of(sourceTransaction))); + assertThat(fixture.target().getTransactions(), is(List.of(targetTransaction))); + assertThat(fixture.client().getAllTransactions().size(), is(1)); + assertSame(sourceTransaction, fixture.client().getAllTransactions().get(0).getTransaction()); + assertCrossEntryReadCompatibility(sourceTransaction, targetTransaction, fixture.source(), fixture.target()); + assertValid(fixture.client()); + } + + /** + * Verifies that same-shape transfer edits and owner moves are applied through ledger paths. + * Source and target projections must move without legacy delete/insert replay. + */ + @Test + public void testFacadeAppliesSameShapeEditAndMovesOwners() throws Exception + { + var fixture = fixture(CurrencyUnit.EUR, CurrencyUnit.USD); + var otherAccount = account("Other", CurrencyUnit.EUR); + var otherTarget = account("Other Target", CurrencyUnit.USD); + fixture.client().addAccount(otherAccount); + fixture.client().addAccount(otherTarget); + var creator = new LedgerAccountTransferTransactionCreator(fixture.client()); + var transfer = createTransfer(fixture); + var sourceTransaction = transfer.getSourceTransaction(); + var targetTransaction = transfer.getTargetTransaction(); + var sourcePostingUUID = fixture.client().getLedger().getEntries().get(0).getPostings().get(0).getUUID(); + var targetPostingUUID = fixture.client().getLedger().getEntries().get(0).getPostings().get(1).getUUID(); + var entryUUID = fixture.client().getLedger().getEntries().get(0).getUUID(); + var expectedProjectionUUIDs = projectionUUIDs(fixture.client()); + + creator.update(transfer, fixture.source(), fixture.target(), DATE_TIME.plusDays(1), + Values.Amount.factorize(150), CurrencyUnit.EUR, Values.Amount.factorize(300), + CurrencyUnit.USD, Money.of(CurrencyUnit.USD, Values.Amount.factorize(300)), EXCHANGE_RATE, + "updated note", "updated source"); + + var entry = fixture.client().getLedger().getEntries().get(0); + + assertThat(entry.getDateTime(), is(DATE_TIME.plusDays(1))); + assertThat(entry.getNote(), is("updated note")); + assertThat(entry.getSource(), is("updated source")); + assertThat(entry.getPostings().get(0).getUUID(), is(sourcePostingUUID)); + assertThat(entry.getPostings().get(0).getAmount(), is(Values.Amount.factorize(150))); + assertThat(entry.getPostings().get(0).getForexAmount(), is(Values.Amount.factorize(300))); + assertThat(entry.getPostings().get(1).getUUID(), is(targetPostingUUID)); + assertThat(entry.getPostings().get(1).getAmount(), is(Values.Amount.factorize(300))); + assertThat(sourceTransaction.getAmount(), is(Values.Amount.factorize(150))); + assertThat(targetTransaction.getAmount(), is(Values.Amount.factorize(300))); + + var moved = creator.update(transfer, otherAccount, otherTarget, DATE_TIME.plusDays(2), + Values.Amount.factorize(175), CurrencyUnit.EUR, Values.Amount.factorize(350), + CurrencyUnit.USD, Money.of(CurrencyUnit.USD, Values.Amount.factorize(350)), EXCHANGE_RATE, + "moved note", "moved source"); + var movedSourceTransaction = moved.getSourceTransaction(); + var movedTargetTransaction = moved.getTargetTransaction(); + + assertTrue(fixture.source().getTransactions().isEmpty()); + assertTrue(fixture.target().getTransactions().isEmpty()); + assertThat(otherAccount.getTransactions(), is(List.of(movedSourceTransaction))); + assertThat(otherTarget.getTransactions(), is(List.of(movedTargetTransaction))); + assertThat(movedSourceTransaction.getUUID(), is(expectedProjectionUUIDs.get(0))); + assertThat(movedTargetTransaction.getUUID(), is(expectedProjectionUUIDs.get(1))); + assertThat(entry.getUUID(), is(entryUUID)); + assertThat(entry.getPostings().get(0).getUUID(), is(sourcePostingUUID)); + assertThat(entry.getPostings().get(1).getUUID(), is(targetPostingUUID)); + assertSame(otherAccount, entry.getPostings().get(0).getAccount()); + assertSame(otherTarget, entry.getPostings().get(1).getAccount()); + assertSame(otherAccount, projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT).getAccount()); + assertSame(otherTarget, projection(entry, LedgerProjectionRole.TARGET_ACCOUNT).getAccount()); + assertCrossEntryReadCompatibility(movedSourceTransaction, movedTargetTransaction, otherAccount, otherTarget); + assertThrows(UnsupportedOperationException.class, () -> sourceTransaction.setType(AccountTransaction.Type.DEPOSIT)); + assertThrows(UnsupportedOperationException.class, () -> sourceTransaction.setAmount(1L)); + sourceTransaction.getCrossEntry().updateFrom(sourceTransaction); + assertTrue(fixture.source().getTransactions().isEmpty()); + assertThat(otherAccount.getTransactions(), is(List.of(movedSourceTransaction))); + assertThrows(UnsupportedOperationException.class, + () -> sourceTransaction.getCrossEntry().setOwner(sourceTransaction, otherAccount)); + assertThrows(UnsupportedOperationException.class, () -> sourceTransaction.getCrossEntry().insert()); + assertThat(fixture.client().getAllTransactions().size(), is(1)); + assertThat(projectionUUIDs(loadXml(saveXml(fixture.client()))), is(expectedProjectionUUIDs)); + assertThat(projectionUUIDs(loadProtobuf(saveProtobuf(fixture.client()))), is(expectedProjectionUUIDs)); + assertValid(fixture.client()); + } + + /** + * Verifies that invalid transfer units are rejected before a ledger entry is added. + * The creator must not leave partial transfer truth behind. + */ + @Test + public void testCreateRejectsInvalidUnitsWithoutPartialLedgerEntry() + { + var fixture = fixture(CurrencyUnit.EUR, CurrencyUnit.USD); + var creator = new LedgerAccountTransferTransactionCreator(fixture.client()); + var invalidUnits = LedgerUnitPostingPatch.of(LedgerUnitPostingEdit.add(LedgerPostingType.FEE, + Money.of(CurrencyUnit.EUR, Values.Amount.factorize(-1)))); + + assertThrows(IllegalArgumentException.class, + () -> creator.create(fixture.source(), fixture.target(), DATE_TIME, + Values.Amount.factorize(100), CurrencyUnit.EUR, + Values.Amount.factorize(200), CurrencyUnit.USD, LedgerForexAmount.none(), + LedgerForexAmount.none(), invalidUnits, "note", "source")); + + assertTrue(fixture.client().getLedger().getEntries().isEmpty()); + assertTrue(fixture.source().getTransactions().isEmpty()); + assertTrue(fixture.target().getTransactions().isEmpty()); + } + + /** + * Verifies that mutable legacy setters stay blocked on ledger-backed account transfers. + * A failed setter attempt must leave the ledger and both owner lists unchanged. + */ + @Test + public void testReadOnlyWrapperRejectsAllMutableSettersWithoutPartialMutation() + { + var fixture = fixture(CurrencyUnit.EUR, CurrencyUnit.USD); + var otherAccount = account("Other", CurrencyUnit.EUR); + var otherSourceTransaction = new AccountTransaction(); + var otherTargetTransaction = new AccountTransaction(); + var transfer = createTransfer(fixture); + var sourceTransaction = transfer.getSourceTransaction(); + var targetTransaction = transfer.getTargetTransaction(); + + assertThrows(UnsupportedOperationException.class, () -> transfer.setSourceAccount(otherAccount)); + assertThrows(UnsupportedOperationException.class, () -> transfer.setTargetAccount(otherAccount)); + assertThrows(UnsupportedOperationException.class, () -> transfer.setSourceTransaction(otherSourceTransaction)); + assertThrows(UnsupportedOperationException.class, () -> transfer.setTargetTransaction(otherTargetTransaction)); + + assertSame(fixture.source(), transfer.getSourceAccount()); + assertSame(fixture.target(), transfer.getTargetAccount()); + assertSame(sourceTransaction, transfer.getSourceTransaction()); + assertSame(targetTransaction, transfer.getTargetTransaction()); + assertCrossEntryReadCompatibility(sourceTransaction, targetTransaction, fixture.source(), fixture.target()); + assertValid(fixture.client()); + } + + /** + * Verifies that normal legacy transfer entries still allow their mutable setters. + * Ledger read-only protection must not change non-ledger transfer behavior. + */ + @Test + public void testLegacyTransferEntryMutableSettersStillWork() + { + var source = account("Source", CurrencyUnit.EUR); + var target = account("Target", CurrencyUnit.EUR); + var otherSource = account("Other Source", CurrencyUnit.EUR); + var otherTarget = account("Other Target", CurrencyUnit.EUR); + var replacementSourceTransaction = new AccountTransaction(); + var replacementTargetTransaction = new AccountTransaction(); + var transfer = new AccountTransferEntry(source, target); + + transfer.setSourceAccount(otherSource); + transfer.setTargetAccount(otherTarget); + transfer.setSourceTransaction(replacementSourceTransaction); + transfer.setTargetTransaction(replacementTargetTransaction); + + assertSame(otherSource, transfer.getSourceAccount()); + assertSame(otherTarget, transfer.getTargetAccount()); + assertSame(replacementSourceTransaction, transfer.getSourceTransaction()); + assertSame(replacementTargetTransaction, transfer.getTargetTransaction()); + } + + /** + * Verifies that XML save/load/save preserves account-transfer projection identities and fields. + * Source and target rows must rematerialize from the same ledger entry. + */ + @Test + public void testXmlSaveLoadSavePreservesAccountTransferProjectionUUIDsAndFields() throws Exception + { + var client = transferClient(); + var expectedProjectionUUIDs = projectionUUIDs(client); + + var loaded = loadXml(saveXml(client)); + var reloaded = loadXml(saveXml(loaded)); + + assertThat(projectionUUIDs(reloaded), is(expectedProjectionUUIDs)); + assertThat(reloaded.getLedger().getEntries().size(), is(1)); + assertThat(reloaded.getAccounts().get(0).getTransactions().get(0), instanceOf(LedgerBackedTransaction.class)); + assertThat(reloaded.getAccounts().get(1).getTransactions().get(0), instanceOf(LedgerBackedTransaction.class)); + assertThat(reloaded.getAccounts().get(0).getTransactions().get(0).getType(), + is(AccountTransaction.Type.TRANSFER_OUT)); + assertThat(reloaded.getAccounts().get(1).getTransactions().get(0).getType(), + is(AccountTransaction.Type.TRANSFER_IN)); + assertThat(reloaded.getAllTransactions().size(), is(1)); + assertValid(reloaded); + } + + /** + * Verifies that protobuf save/load/save preserves account-transfer projection identities and fields. + * Source and target rows must rematerialize from the same ledger entry. + */ + @Test + public void testProtobufSaveLoadSavePreservesAccountTransferProjectionUUIDsAndFields() throws Exception + { + var client = transferClient(); + var expectedProjectionUUIDs = projectionUUIDs(client); + + var loaded = loadProtobuf(saveProtobuf(client)); + var reloaded = loadProtobuf(saveProtobuf(loaded)); + + assertThat(projectionUUIDs(reloaded), is(expectedProjectionUUIDs)); + assertThat(reloaded.getLedger().getEntries().size(), is(1)); + assertThat(reloaded.getAccounts().get(0).getTransactions().get(0), instanceOf(LedgerBackedTransaction.class)); + assertThat(reloaded.getAccounts().get(1).getTransactions().get(0), instanceOf(LedgerBackedTransaction.class)); + assertThat(reloaded.getAccounts().get(0).getTransactions().get(0).getType(), + is(AccountTransaction.Type.TRANSFER_OUT)); + assertThat(reloaded.getAccounts().get(1).getTransactions().get(0).getType(), + is(AccountTransaction.Type.TRANSFER_IN)); + assertThat(reloaded.getAllTransactions().size(), is(1)); + assertValid(reloaded); + } + + private void assertCrossEntryReadCompatibility(AccountTransaction sourceTransaction, + AccountTransaction targetTransaction, Account source, Account target) + { + assertThat(sourceTransaction.getCrossEntry(), instanceOf(AccountTransferEntry.class)); + assertThat(targetTransaction.getCrossEntry(), instanceOf(AccountTransferEntry.class)); + assertSame(targetTransaction, sourceTransaction.getCrossEntry().getCrossTransaction(sourceTransaction)); + assertSame(sourceTransaction, targetTransaction.getCrossEntry().getCrossTransaction(targetTransaction)); + assertSame(target, sourceTransaction.getCrossEntry().getCrossOwner(sourceTransaction)); + assertSame(source, targetTransaction.getCrossEntry().getCrossOwner(targetTransaction)); + } + + private AccountTransferEntry createTransfer(Fixture fixture) + { + return new LedgerAccountTransferTransactionCreator(fixture.client()).create(fixture.source(), fixture.target(), + DATE_TIME, Values.Amount.factorize(100), fixture.source().getCurrencyCode(), + Values.Amount.factorize(200), fixture.target().getCurrencyCode(), + Money.of(fixture.target().getCurrencyCode(), Values.Amount.factorize(200)), EXCHANGE_RATE, + "note", "source"); + } + + private Client transferClient() + { + var fixture = fixture(CurrencyUnit.EUR, CurrencyUnit.USD); + + createTransfer(fixture); + + return fixture.client(); + } + + private LedgerProjectionRef projection(name.abuchen.portfolio.model.ledger.LedgerEntry entry, + LedgerProjectionRole role) + { + return entry.getProjectionRefs().stream().filter(projection -> projection.getRole() == role).findFirst() + .orElseThrow(); + } + + private List projectionUUIDs(Client client) + { + return client.getLedger().getEntries().stream() + .flatMap(entry -> entry.getProjectionRefs().stream()) + .map(LedgerProjectionRef::getUUID) + .toList(); + } + + private String saveXml(Client client) throws IOException + { + var file = File.createTempFile("ledger-account-transfer", ".xml"); + try + { + ClientFactory.save(client, file); + return Files.readString(file.toPath(), StandardCharsets.UTF_8); + } + finally + { + Files.deleteIfExists(file.toPath()); + } + } + + private Client loadXml(String xml) throws IOException + { + return ClientFactory.load(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); + } + + private byte[] saveProtobuf(Client client) throws IOException + { + return ProtobufTestUtilities.save(client); + } + + private Client loadProtobuf(byte[] bytes) throws IOException + { + return ProtobufTestUtilities.load(bytes); + } + + private void assertValid(Client client) + { + var result = LedgerStructuralValidator.validate(client.getLedger()); + + assertThat(result.getIssues().toString(), result.isOK(), is(true)); + } + + private Fixture fixture(String sourceCurrency, String targetCurrency) + { + var client = new Client(); + var source = account("Source", sourceCurrency); + var target = account("Target", targetCurrency); + + client.addAccount(source); + client.addAccount(target); + + return new Fixture(client, source, target); + } + + private Account account(String name, String currency) + { + var account = new Account(name); + account.setCurrencyCode(currency); + return account; + } + + private record Fixture(Client client, Account source, Account target) + { + } +} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellTransactionCreatorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellTransactionCreatorTest.java new file mode 100644 index 0000000000..14f9726267 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellTransactionCreatorTest.java @@ -0,0 +1,515 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import static org.hamcrest.CoreMatchers.instanceOf; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.Test; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.BuySellEntry; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.ClientFactory; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.PortfolioTransaction; +import name.abuchen.portfolio.model.ProtobufTestUtilities; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.Transaction.Unit; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; +import name.abuchen.portfolio.model.ledger.ProjectionMembershipRole; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.Values; + +/** + * Tests ledger-aware creation and editing of transactions in this family. + * These tests make sure user-visible rows are rebuilt from ledger truth and structural facts are not written through legacy projections. + */ +@SuppressWarnings("nls") +public class LedgerBuySellTransactionCreatorTest +{ + private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 6, 7, 8, 9); + + /** + * Verifies that a buy booking is created directly in the ledger. + * The account and portfolio rows must be derived projections of one persisted booking. + */ + @Test + public void testCreatesLedgerBuyDirectly() + { + var fixture = fixture(); + var entry = createBuy(fixture); + + assertCreatedBuySell(fixture, entry, LedgerEntryType.BUY, PortfolioTransaction.Type.BUY, + AccountTransaction.Type.BUY); + assertValid(fixture.client()); + } + + /** + * Verifies that a sell booking is created directly in the ledger. + * The account and portfolio rows must be derived projections of one persisted booking. + */ + @Test + public void testCreatesLedgerSellDirectly() + { + var fixture = fixture(); + var entry = createSell(fixture); + + assertCreatedBuySell(fixture, entry, LedgerEntryType.SELL, PortfolioTransaction.Type.SELL, + AccountTransaction.Type.SELL); + assertValid(fixture.client()); + } + + /** + * Verifies that ledger-backed buy/sell projections expose gross-value read methods. + * UI and import code must be able to read unit-derived values without mutating projections. + */ + @Test + public void testLedgerBackedBuySellSupportsGrossValueReadMethods() + { + var fixture = fixture(); + var buy = createBuy(fixture); + var sell = createSell(fixture); + + assertThat(buy.getAccountTransaction().getGrossValueAmount(), is(Values.Amount.factorize(116))); + assertThat(buy.getAccountTransaction().getGrossValue(), is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(116)))); + assertTrue(buy.getAccountTransaction().toString().contains("BUY")); + assertThat(buy.getPortfolioTransaction().getGrossValueAmount(), is(Values.Amount.factorize(116))); + assertThat(buy.getPortfolioTransaction().getGrossValue(), is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(116)))); + assertThat(buy.getPortfolioTransaction().getGrossPricePerShare().getCurrencyCode(), is(CurrencyUnit.EUR)); + assertTrue(buy.getPortfolioTransaction().toString().contains("BUY")); + + assertThat(sell.getAccountTransaction().getGrossValueAmount(), is(Values.Amount.factorize(130))); + assertThat(sell.getAccountTransaction().getGrossValue(), is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(130)))); + assertTrue(sell.getAccountTransaction().toString().contains("SELL")); + assertThat(sell.getPortfolioTransaction().getGrossValueAmount(), is(Values.Amount.factorize(130))); + assertThat(sell.getPortfolioTransaction().getGrossValue(), is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(130)))); + assertThat(sell.getPortfolioTransaction().getGrossPricePerShare().getCurrencyCode(), is(CurrencyUnit.EUR)); + assertTrue(sell.getPortfolioTransaction().toString().contains("SELL")); + } + + /** + * Verifies that same-shape buy/sell edits and owner moves are applied through ledger paths. + * The account and portfolio projections must refresh without creating duplicate booking truth. + */ + @Test + public void testFacadeAppliesSameShapeEditAndMovesOwners() throws Exception + { + var fixture = fixture(); + var otherAccount = account("Other Account"); + var otherPortfolio = new Portfolio("Other Portfolio"); + otherPortfolio.setUpdatedAt(Instant.now()); + var otherSecurity = new Security("Other Security", CurrencyUnit.EUR); + otherSecurity.setUpdatedAt(Instant.now()); + fixture.client().addAccount(otherAccount); + fixture.client().addPortfolio(otherPortfolio); + fixture.client().addSecurity(otherSecurity); + var creator = new LedgerBuySellTransactionCreator(fixture.client()); + var entry = createBuy(fixture); + var accountTransaction = entry.getAccountTransaction(); + var portfolioTransaction = entry.getPortfolioTransaction(); + var cashPostingUUID = fixture.client().getLedger().getEntries().get(0).getPostings().get(0).getUUID(); + var securityPostingUUID = fixture.client().getLedger().getEntries().get(0).getPostings().get(1).getUUID(); + var entryUUID = fixture.client().getLedger().getEntries().get(0).getUUID(); + var expectedProjectionUUIDs = projectionUUIDs(fixture.client()); + + creator.update(entry, fixture.portfolio(), fixture.account(), PortfolioTransaction.Type.BUY, + DATE_TIME.plusDays(1), Values.Amount.factorize(150), CurrencyUnit.EUR, otherSecurity, + Values.Share.factorize(7), List.of(unit(Unit.Type.FEE, 5)), "updated", "updated source"); + + var ledgerEntry = fixture.client().getLedger().getEntries().get(0); + + assertThat(ledgerEntry.getDateTime(), is(DATE_TIME.plusDays(1))); + assertThat(ledgerEntry.getNote(), is("updated")); + assertThat(ledgerEntry.getSource(), is("updated source")); + assertThat(ledgerEntry.getPostings().get(0).getUUID(), is(cashPostingUUID)); + assertThat(ledgerEntry.getPostings().get(0).getAmount(), is(Values.Amount.factorize(150))); + assertThat(ledgerEntry.getPostings().get(1).getUUID(), is(securityPostingUUID)); + assertSame(otherSecurity, ledgerEntry.getPostings().get(1).getSecurity()); + assertThat(ledgerEntry.getPostings().get(1).getShares(), is(Values.Share.factorize(7))); + assertThat(portfolioTransaction.getUnits().count(), is(1L)); + assertThat(accountTransaction.getAmount(), is(Values.Amount.factorize(150))); + assertThat(portfolioTransaction.getAmount(), is(Values.Amount.factorize(150))); + + var moved = creator.update(entry, otherPortfolio, otherAccount, PortfolioTransaction.Type.BUY, + DATE_TIME.plusDays(2), Values.Amount.factorize(175), CurrencyUnit.EUR, otherSecurity, + Values.Share.factorize(8), List.of(), "moved note", "moved source"); + var movedAccountTransaction = moved.getAccountTransaction(); + var movedPortfolioTransaction = moved.getPortfolioTransaction(); + + assertTrue(fixture.account().getTransactions().isEmpty()); + assertTrue(fixture.portfolio().getTransactions().isEmpty()); + assertThat(otherAccount.getTransactions(), is(List.of(movedAccountTransaction))); + assertThat(otherPortfolio.getTransactions(), is(List.of(movedPortfolioTransaction))); + assertThat(movedAccountTransaction.getUUID(), is(expectedProjectionUUIDs.get(0))); + assertThat(movedPortfolioTransaction.getUUID(), is(expectedProjectionUUIDs.get(1))); + assertThat(ledgerEntry.getUUID(), is(entryUUID)); + assertThat(ledgerEntry.getPostings().get(0).getUUID(), is(cashPostingUUID)); + assertThat(ledgerEntry.getPostings().get(1).getUUID(), is(securityPostingUUID)); + assertSame(otherAccount, ledgerEntry.getPostings().get(0).getAccount()); + assertSame(otherPortfolio, ledgerEntry.getPostings().get(1).getPortfolio()); + assertSame(otherAccount, projection(ledgerEntry, LedgerProjectionRole.ACCOUNT).getAccount()); + assertSame(otherPortfolio, projection(ledgerEntry, LedgerProjectionRole.PORTFOLIO).getPortfolio()); + assertCrossEntryReadCompatibility(movedAccountTransaction, movedPortfolioTransaction, otherAccount, + otherPortfolio); + assertThrows(UnsupportedOperationException.class, + () -> creator.update(entry, fixture.portfolio(), fixture.account(), PortfolioTransaction.Type.SELL, + DATE_TIME, Values.Amount.factorize(150), CurrencyUnit.EUR, otherSecurity, + Values.Share.factorize(7), List.of(), "note", "source")); + assertThrows(UnsupportedOperationException.class, + () -> portfolioTransaction.setType(PortfolioTransaction.Type.DELIVERY_INBOUND)); + assertThrows(UnsupportedOperationException.class, () -> portfolioTransaction.setAmount(1L)); + accountTransaction.getCrossEntry().updateFrom(accountTransaction); + assertTrue(fixture.account().getTransactions().isEmpty()); + assertThat(otherAccount.getTransactions(), is(List.of(movedAccountTransaction))); + assertThrows(UnsupportedOperationException.class, + () -> accountTransaction.getCrossEntry().setOwner(accountTransaction, otherAccount)); + assertThrows(UnsupportedOperationException.class, () -> accountTransaction.getCrossEntry().insert()); + assertThat(fixture.client().getAllTransactions().size(), is(1)); + assertThat(projectionUUIDs(loadXml(saveXml(fixture.client()))), is(expectedProjectionUUIDs)); + assertThat(projectionUUIDs(loadProtobuf(saveProtobuf(fixture.client()))), is(expectedProjectionUUIDs)); + assertValid(fixture.client()); + } + + /** + * Verifies that mutable legacy setters stay blocked on ledger-backed buy/sell wrappers. + * A failed setter attempt must leave the ledger and both owner lists unchanged. + */ + @Test + public void testReadOnlyWrapperRejectsAllMutableSettersWithoutPartialMutation() + { + var fixture = fixture(); + var entry = createBuy(fixture); + var accountTransaction = entry.getAccountTransaction(); + var portfolioTransaction = entry.getPortfolioTransaction(); + + assertThrows(UnsupportedOperationException.class, () -> entry.setPortfolio(new Portfolio("Other"))); + assertThrows(UnsupportedOperationException.class, () -> entry.setAccount(account("Other"))); + assertThrows(UnsupportedOperationException.class, () -> entry.setDate(DATE_TIME.plusDays(1))); + assertThrows(UnsupportedOperationException.class, () -> entry.setType(PortfolioTransaction.Type.SELL)); + assertThrows(UnsupportedOperationException.class, () -> entry.setSecurity(fixture.security())); + assertThrows(UnsupportedOperationException.class, () -> entry.setShares(1L)); + assertThrows(UnsupportedOperationException.class, () -> entry.setAmount(1L)); + assertThrows(UnsupportedOperationException.class, () -> entry.setCurrencyCode(CurrencyUnit.USD)); + assertThrows(UnsupportedOperationException.class, () -> entry.setMonetaryAmount(Money.of(CurrencyUnit.EUR, 1L))); + assertThrows(UnsupportedOperationException.class, () -> entry.setNote("other")); + assertThrows(UnsupportedOperationException.class, () -> entry.setSource("other")); + + assertSame(fixture.account(), entry.getAccount()); + assertSame(fixture.portfolio(), entry.getPortfolio()); + assertSame(accountTransaction, entry.getAccountTransaction()); + assertSame(portfolioTransaction, entry.getPortfolioTransaction()); + assertCrossEntryReadCompatibility(accountTransaction, portfolioTransaction, fixture.account(), + fixture.portfolio()); + assertValid(fixture.client()); + } + + /** + * Verifies that normal legacy buy/sell entries still allow their mutable setters. + * Ledger read-only protection must not change non-ledger transaction behavior. + */ + @Test + public void testLegacyBuySellEntryMutableSettersStillWork() + { + var portfolio = new Portfolio("Portfolio"); + var account = account("Account"); + var otherPortfolio = new Portfolio("Other Portfolio"); + var otherAccount = account("Other Account"); + var entry = new BuySellEntry(portfolio, account); + + entry.setPortfolio(otherPortfolio); + entry.setAccount(otherAccount); + entry.setType(PortfolioTransaction.Type.BUY); + entry.setDate(DATE_TIME); + entry.setAmount(Values.Amount.factorize(10)); + entry.setCurrencyCode(CurrencyUnit.EUR); + + assertSame(otherPortfolio, entry.getPortfolio()); + assertSame(otherAccount, entry.getAccount()); + assertThat(entry.getPortfolioTransaction().getType(), is(PortfolioTransaction.Type.BUY)); + assertThat(entry.getAccountTransaction().getType(), is(AccountTransaction.Type.BUY)); + assertThat(entry.getPortfolioTransaction().getAmount(), is(Values.Amount.factorize(10))); + assertThat(entry.getAccountTransaction().getAmount(), is(Values.Amount.factorize(10))); + } + + /** + * Verifies that XML save/load/save preserves buy/sell projection identities and fields. + * Both account and portfolio rows must rematerialize from the same ledger entry. + */ + @Test + public void testXmlSaveLoadSavePreservesBuySellProjectionUUIDsAndFields() throws Exception + { + var client = buySellClient(); + var expectedProjectionUUIDs = projectionUUIDs(client); + + var loaded = loadXml(saveXml(client)); + var reloaded = loadXml(saveXml(loaded)); + + assertThat(projectionUUIDs(reloaded), is(expectedProjectionUUIDs)); + assertThat(reloaded.getLedger().getEntries().size(), is(2)); + assertThat(reloaded.getAccounts().get(0).getTransactions().size(), is(2)); + assertThat(reloaded.getPortfolios().get(0).getTransactions().size(), is(2)); + assertThat(reloaded.getAllTransactions().size(), is(2)); + assertValid(reloaded); + } + + /** + * Verifies that protobuf save/load/save preserves buy/sell projection identities and fields. + * Both account and portfolio rows must rematerialize from the same ledger entry. + */ + @Test + public void testProtobufSaveLoadSavePreservesBuySellProjectionUUIDsAndFields() throws Exception + { + var client = buySellClient(); + var expectedProjectionUUIDs = projectionUUIDs(client); + + var loaded = loadProtobuf(saveProtobuf(client)); + var reloaded = loadProtobuf(saveProtobuf(loaded)); + + assertThat(projectionUUIDs(reloaded), is(expectedProjectionUUIDs)); + assertThat(reloaded.getLedger().getEntries().size(), is(2)); + assertThat(reloaded.getAccounts().get(0).getTransactions().size(), is(2)); + assertThat(reloaded.getPortfolios().get(0).getTransactions().size(), is(2)); + assertThat(reloaded.getAllTransactions().size(), is(2)); + assertValid(reloaded); + } + + private void assertCreatedBuySell(Fixture fixture, BuySellEntry entry, LedgerEntryType entryType, + PortfolioTransaction.Type portfolioType, AccountTransaction.Type accountType) + { + var accountTransaction = entry.getAccountTransaction(); + var portfolioTransaction = entry.getPortfolioTransaction(); + var ledgerEntry = fixture.client().getLedger().getEntries().get(0); + var cashPosting = ledgerEntry.getPostings().get(0); + var securityPosting = ledgerEntry.getPostings().get(1); + var feePosting = posting(ledgerEntry, LedgerPostingType.FEE); + var taxPosting = posting(ledgerEntry, LedgerPostingType.TAX); + var grossValuePosting = posting(ledgerEntry, LedgerPostingType.GROSS_VALUE); + var accountProjection = projection(ledgerEntry, LedgerProjectionRole.ACCOUNT); + var portfolioProjection = projection(ledgerEntry, LedgerProjectionRole.PORTFOLIO); + + assertThat(ledgerEntry.getType(), is(entryType)); + assertThat(ledgerEntry.getDateTime(), is(DATE_TIME)); + assertThat(ledgerEntry.getNote(), is("note")); + assertThat(ledgerEntry.getSource(), is("source")); + assertThat(cashPosting.getType(), is(LedgerPostingType.CASH)); + assertThat(cashPosting.getAmount(), is(Values.Amount.factorize(123))); + assertThat(cashPosting.getCurrency(), is(CurrencyUnit.EUR)); + assertSame(fixture.account(), cashPosting.getAccount()); + assertThat(securityPosting.getType(), is(LedgerPostingType.SECURITY)); + assertThat(securityPosting.getAmount(), is(Values.Amount.factorize(123))); + assertThat(securityPosting.getCurrency(), is(CurrencyUnit.EUR)); + assertSame(fixture.portfolio(), securityPosting.getPortfolio()); + assertSame(fixture.security(), securityPosting.getSecurity()); + assertThat(securityPosting.getShares(), is(Values.Share.factorize(5))); + assertThat(feePosting.getForexAmount(), is(Long.valueOf(Values.Amount.factorize(6)))); + assertThat(feePosting.getForexCurrency(), is(CurrencyUnit.USD)); + assertThat(feePosting.getExchangeRate(), is(new BigDecimal("0.5000"))); + assertThat(taxPosting.getAmount(), is(Values.Amount.factorize(4))); + assertThat(grossValuePosting.getForexAmount(), is(Long.valueOf(Values.Amount.factorize(240)))); + assertThat(grossValuePosting.getForexCurrency(), is(CurrencyUnit.USD)); + assertThat(grossValuePosting.getExchangeRate(), is(new BigDecimal("0.5000"))); + assertTrue(ledgerEntry.getPostings().stream().noneMatch(posting -> posting.getAccount() != null + && posting.getPortfolio() != null)); + + assertThat(accountProjection.getRole(), is(LedgerProjectionRole.ACCOUNT)); + assertSame(fixture.account(), accountProjection.getAccount()); + assertPrimaryMembership(accountProjection, cashPosting.getUUID()); + assertThat(portfolioProjection.getRole(), is(LedgerProjectionRole.PORTFOLIO)); + assertSame(fixture.portfolio(), portfolioProjection.getPortfolio()); + assertPrimaryMembership(portfolioProjection, securityPosting.getUUID()); + assertUnitMemberships(accountProjection); + assertUnitMemberships(portfolioProjection); + assertThat(accountTransaction.getUUID(), is(accountProjection.getUUID())); + assertThat(portfolioTransaction.getUUID(), is(portfolioProjection.getUUID())); + assertThat(accountTransaction, instanceOf(LedgerBackedTransaction.class)); + assertThat(portfolioTransaction, instanceOf(LedgerBackedTransaction.class)); + assertThat(accountTransaction.getType(), is(accountType)); + assertThat(portfolioTransaction.getType(), is(portfolioType)); + assertThat(accountTransaction.getAmount(), is(Values.Amount.factorize(123))); + assertThat(portfolioTransaction.getAmount(), is(Values.Amount.factorize(123))); + assertSame(fixture.security(), portfolioTransaction.getSecurity()); + assertThat(portfolioTransaction.getShares(), is(Values.Share.factorize(5))); + assertThat(portfolioTransaction.getUnits().count(), is(3L)); + assertThat(fixture.account().getTransactions(), is(List.of(accountTransaction))); + assertThat(fixture.portfolio().getTransactions(), is(List.of(portfolioTransaction))); + assertThat(fixture.client().getAllTransactions().size(), is(1)); + assertSame(portfolioTransaction, fixture.client().getAllTransactions().get(0).getTransaction()); + assertCrossEntryReadCompatibility(accountTransaction, portfolioTransaction, fixture.account(), + fixture.portfolio()); + } + + private void assertCrossEntryReadCompatibility(AccountTransaction accountTransaction, + PortfolioTransaction portfolioTransaction, Account account, Portfolio portfolio) + { + assertThat(accountTransaction.getCrossEntry(), instanceOf(BuySellEntry.class)); + assertThat(portfolioTransaction.getCrossEntry(), instanceOf(BuySellEntry.class)); + assertSame(portfolioTransaction, accountTransaction.getCrossEntry().getCrossTransaction(accountTransaction)); + assertSame(accountTransaction, portfolioTransaction.getCrossEntry().getCrossTransaction(portfolioTransaction)); + assertSame(portfolio, accountTransaction.getCrossEntry().getCrossOwner(accountTransaction)); + assertSame(account, portfolioTransaction.getCrossEntry().getCrossOwner(portfolioTransaction)); + } + + private void assertPrimaryMembership(LedgerProjectionRef projection, String postingUUID) + { + assertThat(projection.getPrimaryPostingUUID(), is(postingUUID)); + assertThat(projection.getPrimaryMembership().orElseThrow().getPostingUUID(), is(postingUUID)); + } + + private void assertUnitMemberships(LedgerProjectionRef projection) + { + assertThat(projection.getMembershipsByRole(ProjectionMembershipRole.FEE_UNIT).size(), is(1)); + assertThat(projection.getMembershipsByRole(ProjectionMembershipRole.TAX_UNIT).size(), is(1)); + assertThat(projection.getMembershipsByRole(ProjectionMembershipRole.GROSS_VALUE_UNIT).size(), is(1)); + } + + private BuySellEntry createBuy(Fixture fixture) + { + return create(fixture, PortfolioTransaction.Type.BUY); + } + + private BuySellEntry createSell(Fixture fixture) + { + return create(fixture, PortfolioTransaction.Type.SELL); + } + + private BuySellEntry create(Fixture fixture, PortfolioTransaction.Type type) + { + return new LedgerBuySellTransactionCreator(fixture.client()).create(fixture.portfolio(), fixture.account(), + type, DATE_TIME, Values.Amount.factorize(123), CurrencyUnit.EUR, fixture.security(), + Values.Share.factorize(5), units(), "note", "source"); + } + + private List units() + { + return List.of( + new Unit(Unit.Type.FEE, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(3)), + Money.of(CurrencyUnit.USD, Values.Amount.factorize(6)), + new BigDecimal("0.5000")), + unit(Unit.Type.TAX, 4), + new Unit(Unit.Type.GROSS_VALUE, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(120)), + Money.of(CurrencyUnit.USD, Values.Amount.factorize(240)), + new BigDecimal("0.5000"))); + } + + private Unit unit(Unit.Type type, int amount) + { + return new Unit(type, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(amount))); + } + + private Client buySellClient() + { + var fixture = fixture(); + + createBuy(fixture); + createSell(fixture); + + return fixture.client(); + } + + private LedgerProjectionRef projection(name.abuchen.portfolio.model.ledger.LedgerEntry entry, + LedgerProjectionRole role) + { + return entry.getProjectionRefs().stream().filter(projection -> projection.getRole() == role).findFirst() + .orElseThrow(); + } + + private LedgerPosting posting(name.abuchen.portfolio.model.ledger.LedgerEntry entry, LedgerPostingType type) + { + return entry.getPostings().stream().filter(posting -> posting.getType() == type).findFirst().orElseThrow(); + } + + private List projectionUUIDs(Client client) + { + return client.getLedger().getEntries().stream() + .flatMap(entry -> entry.getProjectionRefs().stream()) + .map(LedgerProjectionRef::getUUID) + .toList(); + } + + private String saveXml(Client client) throws IOException + { + var file = File.createTempFile("ledger-buy-sell", ".xml"); + try + { + ClientFactory.save(client, file); + return Files.readString(file.toPath(), StandardCharsets.UTF_8); + } + finally + { + Files.deleteIfExists(file.toPath()); + } + } + + private Client loadXml(String xml) throws IOException + { + return ClientFactory.load(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); + } + + private byte[] saveProtobuf(Client client) throws IOException + { + return ProtobufTestUtilities.save(client); + } + + private Client loadProtobuf(byte[] bytes) throws IOException + { + return ProtobufTestUtilities.load(bytes); + } + + private void assertValid(Client client) + { + var result = LedgerStructuralValidator.validate(client.getLedger()); + + assertThat(result.getIssues().toString(), result.isOK(), is(true)); + } + + private Fixture fixture() + { + var client = new Client(); + var account = account("Account"); + var portfolio = new Portfolio("Portfolio"); + var security = new Security("Security", CurrencyUnit.EUR); + account.setUpdatedAt(Instant.now()); + portfolio.setUpdatedAt(Instant.now()); + security.setUpdatedAt(Instant.now()); + + client.addAccount(account); + client.addPortfolio(portfolio); + client.addSecurity(security); + + return new Fixture(client, account, portfolio, security); + } + + private Account account(String name) + { + var account = new Account(name); + + account.setCurrencyCode(CurrencyUnit.EUR); + + return account; + } + + private record Fixture(Client client, Account account, Portfolio portfolio, Security security) + { + } +} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryTransactionCreatorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryTransactionCreatorTest.java new file mode 100644 index 0000000000..b4759d71b2 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryTransactionCreatorTest.java @@ -0,0 +1,420 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import static org.hamcrest.CoreMatchers.instanceOf; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.Test; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.ClientFactory; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.PortfolioTransaction; +import name.abuchen.portfolio.model.ProtobufTestUtilities; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.Transaction.Unit; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; +import name.abuchen.portfolio.model.ledger.ProjectionMembershipRole; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.Values; + +/** + * Tests ledger-aware creation and editing of transactions in this family. + * These tests make sure user-visible rows are rebuilt from ledger truth and structural facts are not written through legacy projections. + */ +@SuppressWarnings("nls") +public class LedgerDeliveryTransactionCreatorTest +{ + private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 6, 7, 8, 9); + private static final BigDecimal EXCHANGE_RATE = BigDecimal.valueOf(2); + + /** + * Verifies that inbound and outbound deliveries are created directly in the ledger. + * The portfolio row must be a projection of the persisted delivery booking. + */ + @Test + public void testCreatesLedgerDeliveryDirectlyForInboundAndOutbound() + { + assertCreatesDelivery(PortfolioTransaction.Type.DELIVERY_INBOUND, LedgerEntryType.DELIVERY_INBOUND, + LedgerProjectionRole.DELIVERY_INBOUND); + assertCreatesDelivery(PortfolioTransaction.Type.DELIVERY_OUTBOUND, LedgerEntryType.DELIVERY_OUTBOUND, + LedgerProjectionRole.DELIVERY_OUTBOUND); + } + + /** + * Verifies that metadata setters remain allowed on delivery projections. + * Structural setters must stay blocked so runtime projections do not become a second truth. + */ + @Test + public void testCreatedDeliveryAllowsMetadataSetterAndRejectsStructuralSetters() + { + var fixture = fixture(); + var transaction = createDelivery(fixture.client(), fixture.portfolio(), fixture.security(), + PortfolioTransaction.Type.DELIVERY_INBOUND); + var updatedDateTime = DATE_TIME.plusDays(1); + + transaction.setDateTime(updatedDateTime); + transaction.setNote("updated note"); + transaction.setSource("updated source"); + + var entry = fixture.client().getLedger().getEntries().get(0); + + assertThat(entry.getDateTime(), is(updatedDateTime)); + assertThat(entry.getNote(), is("updated note")); + assertThat(entry.getSource(), is("updated source")); + assertThrows(UnsupportedOperationException.class, () -> transaction.setAmount(1L)); + assertThrows(UnsupportedOperationException.class, () -> transaction.setCurrencyCode(CurrencyUnit.USD)); + assertThrows(UnsupportedOperationException.class, () -> transaction.setSecurity(fixture.security())); + assertThrows(UnsupportedOperationException.class, () -> transaction.setShares(1L)); + assertThrows(UnsupportedOperationException.class, transaction::clearUnits); + assertThrows(UnsupportedOperationException.class, + () -> transaction.setType(PortfolioTransaction.Type.DELIVERY_OUTBOUND)); + assertValid(fixture.client()); + } + + /** + * Verifies that ledger-backed delivery projections expose gross-value read methods. + * UI code must be able to read unit-derived values without mutating projections. + */ + @Test + public void testLedgerBackedDeliverySupportsGrossValueReadMethods() + { + var fixture = fixture(); + var inbound = createDelivery(fixture.client(), fixture.portfolio(), fixture.security(), + PortfolioTransaction.Type.DELIVERY_INBOUND); + var outbound = createDelivery(fixture.client(), fixture.portfolio(), fixture.security(), + PortfolioTransaction.Type.DELIVERY_OUTBOUND); + + assertThat(inbound.getGrossValueAmount(), is(Values.Amount.factorize(80))); + assertThat(inbound.getGrossValue(), is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(80)))); + assertThat(inbound.getGrossPricePerShare().getCurrencyCode(), is(CurrencyUnit.EUR)); + assertThat(inbound.getUnits().count(), is(3L)); + assertTrue(inbound.toString().contains("DELIVERY_INBOUND")); + + assertThat(outbound.getGrossValueAmount(), is(Values.Amount.factorize(200))); + assertThat(outbound.getGrossValue(), is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(200)))); + assertThat(outbound.getGrossPricePerShare().getCurrencyCode(), is(CurrencyUnit.EUR)); + assertThat(outbound.getUnits().count(), is(3L)); + assertTrue(outbound.toString().contains("DELIVERY_OUTBOUND")); + } + + /** + * Verifies that same-shape delivery edits and owner moves are applied through ledger paths. + * The projected delivery must refresh without creating duplicate booking truth. + */ + @Test + public void testFacadeAppliesDeliverySameShapeEditAndMovesOwner() throws Exception + { + var fixture = fixture(); + var otherPortfolio = portfolio(fixture.account()); + otherPortfolio.setUpdatedAt(Instant.now()); + fixture.client().addPortfolio(otherPortfolio); + var otherSecurity = new Security("Other Security", CurrencyUnit.USD); + otherSecurity.setUpdatedAt(Instant.now()); + fixture.client().addSecurity(otherSecurity); + var creator = new LedgerDeliveryTransactionCreator(fixture.client()); + var transaction = createDelivery(fixture.client(), fixture.portfolio(), fixture.security(), + PortfolioTransaction.Type.DELIVERY_INBOUND); + var projectionUUID = transaction.getUUID(); + var entry = fixture.client().getLedger().getEntries().get(0); + var entryUUID = entry.getUUID(); + var postingUUID = entry.getPostings().get(0).getUUID(); + var updatedUnits = List.of(new Unit(Unit.Type.FEE, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(3)), + Money.of(CurrencyUnit.USD, Values.Amount.factorize(2)), BigDecimal.valueOf(1.5))); + + creator.update(transaction, fixture.portfolio(), PortfolioTransaction.Type.DELIVERY_INBOUND, + DATE_TIME.plusDays(2), Values.Amount.factorize(150), CurrencyUnit.EUR, otherSecurity, + Values.Share.factorize(20), Money.of(CurrencyUnit.USD, Values.Amount.factorize(75)), + EXCHANGE_RATE, updatedUnits, "updated note", "updated source"); + + var posting = entry.getPostings().get(0); + + assertThat(entry.getDateTime(), is(DATE_TIME.plusDays(2))); + assertThat(entry.getNote(), is("updated note")); + assertThat(entry.getSource(), is("updated source")); + assertThat(posting.getAmount(), is(Values.Amount.factorize(150))); + assertThat(posting.getCurrency(), is(CurrencyUnit.EUR)); + assertThat(posting.getForexAmount(), is(Values.Amount.factorize(75))); + assertThat(posting.getForexCurrency(), is(CurrencyUnit.USD)); + assertThat(posting.getExchangeRate(), is(EXCHANGE_RATE)); + assertSame(otherSecurity, posting.getSecurity()); + assertThat(posting.getShares(), is(Values.Share.factorize(20))); + assertTrue(entry.getPostings().stream().anyMatch(p -> p.getType() == LedgerPostingType.FEE + && p.getAmount() == Values.Amount.factorize(3) + && p.getForexAmount() == Values.Amount.factorize(2) + && CurrencyUnit.USD.equals(p.getForexCurrency()) + && BigDecimal.valueOf(1.5).equals(p.getExchangeRate()))); + var moved = creator.update(transaction, otherPortfolio, PortfolioTransaction.Type.DELIVERY_INBOUND, + DATE_TIME.plusDays(3), Values.Amount.factorize(151), CurrencyUnit.EUR, otherSecurity, + Values.Share.factorize(21), null, null, List.of(), "moved note", "moved source"); + + assertTrue(fixture.portfolio().getTransactions().isEmpty()); + assertThat(otherPortfolio.getTransactions(), is(List.of(moved))); + assertThat(moved.getUUID(), is(projectionUUID)); + assertThat(entry.getUUID(), is(entryUUID)); + assertThat(entry.getPostings().get(0).getUUID(), is(postingUUID)); + assertThat(entry.getProjectionRefs().get(0).getUUID(), is(projectionUUID)); + assertSame(otherPortfolio, entry.getPostings().get(0).getPortfolio()); + assertSame(otherPortfolio, entry.getProjectionRefs().get(0).getPortfolio()); + assertThat(moved.getAmount(), is(Values.Amount.factorize(151))); + assertThat(moved.getShares(), is(Values.Share.factorize(21))); + assertThat(moved.getNote(), is("moved note")); + assertThrows(UnsupportedOperationException.class, + () -> creator.update(transaction, fixture.portfolio(), + PortfolioTransaction.Type.DELIVERY_OUTBOUND, DATE_TIME, + Values.Amount.factorize(150), CurrencyUnit.EUR, otherSecurity, + Values.Share.factorize(20), null, null, List.of(), "note", "source")); + assertThrows(UnsupportedOperationException.class, + () -> creator.update(transaction, fixture.portfolio(), PortfolioTransaction.Type.BUY, + DATE_TIME, Values.Amount.factorize(150), CurrencyUnit.EUR, otherSecurity, + Values.Share.factorize(20), null, null, List.of(), "note", "source")); + assertThat(fixture.client().getAllTransactions().size(), is(1)); + assertThat(projectionUUIDs(loadXml(saveXml(fixture.client()))), is(List.of(projectionUUID))); + assertThat(projectionUUIDs(loadProtobuf(saveProtobuf(fixture.client()))), is(List.of(projectionUUID))); + assertValid(fixture.client()); + } + + /** + * Verifies that XML save/load/save preserves delivery projection identity and fields. + * The delivery row must rematerialize from the same ledger entry. + */ + @Test + public void testXmlSaveLoadSavePreservesDeliveryProjectionUUIDAndFields() throws Exception + { + var client = deliveryClient(); + var expectedProjectionUUIDs = projectionUUIDs(client); + + var loaded = loadXml(saveXml(client)); + var reloaded = loadXml(saveXml(loaded)); + + assertThat(projectionUUIDs(reloaded), is(expectedProjectionUUIDs)); + assertThat(reloaded.getLedger().getEntries().size(), is(2)); + assertThat(reloaded.getPortfolios().get(0).getTransactions().size(), is(2)); + assertTrue(reloaded.getPortfolios().get(0).getTransactions().stream() + .allMatch(LedgerBackedTransaction.class::isInstance)); + assertTrue(reloaded.getPortfolios().get(0).getTransactions().stream() + .allMatch(transaction -> transaction.getUnits().count() == 3L)); + assertThat(reloaded.getAllTransactions().size(), is(2)); + assertValid(reloaded); + } + + /** + * Verifies that protobuf save/load/save preserves delivery projection identity and fields. + * The delivery row must rematerialize from the same ledger entry. + */ + @Test + public void testProtobufSaveLoadSavePreservesDeliveryProjectionUUIDAndFields() throws Exception + { + var client = deliveryClient(); + var expectedProjectionUUIDs = projectionUUIDs(client); + + var loaded = loadProtobuf(saveProtobuf(client)); + var reloaded = loadProtobuf(saveProtobuf(loaded)); + + assertThat(projectionUUIDs(reloaded), is(expectedProjectionUUIDs)); + assertThat(reloaded.getLedger().getEntries().size(), is(2)); + assertThat(reloaded.getPortfolios().get(0).getTransactions().size(), is(2)); + assertTrue(reloaded.getPortfolios().get(0).getTransactions().stream() + .allMatch(LedgerBackedTransaction.class::isInstance)); + assertTrue(reloaded.getPortfolios().get(0).getTransactions().stream() + .allMatch(transaction -> transaction.getUnits().count() == 3L)); + assertThat(reloaded.getAllTransactions().size(), is(2)); + assertValid(reloaded); + } + + private void assertCreatesDelivery(PortfolioTransaction.Type type, LedgerEntryType entryType, + LedgerProjectionRole projectionRole) + { + var fixture = fixture(); + var transaction = createDelivery(fixture.client(), fixture.portfolio(), fixture.security(), type); + var entry = fixture.client().getLedger().getEntries().get(0); + var posting = entry.getPostings().get(0); + var projection = entry.getProjectionRefs().get(0); + + assertThat(entry.getType(), is(entryType)); + assertThat(entry.getDateTime(), is(DATE_TIME)); + assertThat(entry.getNote(), is("note")); + assertThat(entry.getSource(), is("source")); + assertThat(posting.getType(), is(LedgerPostingType.SECURITY)); + assertThat(posting.getAmount(), is(Values.Amount.factorize(140))); + assertThat(posting.getCurrency(), is(CurrencyUnit.EUR)); + assertThat(posting.getForexAmount(), is(Values.Amount.factorize(70))); + assertThat(posting.getForexCurrency(), is(CurrencyUnit.USD)); + assertThat(posting.getExchangeRate(), is(EXCHANGE_RATE)); + assertSame(fixture.portfolio(), posting.getPortfolio()); + assertSame(fixture.security(), posting.getSecurity()); + assertThat(posting.getShares(), is(Values.Share.factorize(12))); + assertTrue(entry.getPostings().stream().allMatch(p -> p.getAccount() == null)); + assertThat(entry.getProjectionRefs().size(), is(1)); + assertThat(projection.getRole(), is(projectionRole)); + assertSame(fixture.portfolio(), projection.getPortfolio()); + assertThat(projection.getPrimaryPostingUUID(), is(posting.getUUID())); + assertThat(projection.getPrimaryMembership().orElseThrow().getPostingUUID(), is(posting.getUUID())); + assertThat(projection.getMembershipsByRole(ProjectionMembershipRole.FEE_UNIT).size(), is(1)); + assertThat(projection.getMembershipsByRole(ProjectionMembershipRole.TAX_UNIT).size(), is(1)); + assertThat(projection.getMembershipsByRole(ProjectionMembershipRole.GROSS_VALUE_UNIT).size(), is(1)); + assertThat(transaction.getUUID(), is(projection.getUUID())); + assertThat(transaction, instanceOf(LedgerBackedTransaction.class)); + assertThat(transaction.getType(), is(type)); + assertThat(transaction.getDateTime(), is(DATE_TIME)); + assertThat(transaction.getAmount(), is(Values.Amount.factorize(140))); + assertThat(transaction.getCurrencyCode(), is(CurrencyUnit.EUR)); + assertSame(fixture.security(), transaction.getSecurity()); + assertThat(transaction.getShares(), is(Values.Share.factorize(12))); + assertThat(transaction.getNote(), is("note")); + assertThat(transaction.getSource(), is("source")); + assertThat(fixture.portfolio().getTransactions(), is(List.of(transaction))); + assertThat(fixture.client().getAllTransactions().size(), is(1)); + assertUnitPostings(entry.getPostings()); + assertValid(fixture.client()); + } + + private Client deliveryClient() + { + var fixture = fixture(); + + createDelivery(fixture.client(), fixture.portfolio(), fixture.security(), + PortfolioTransaction.Type.DELIVERY_INBOUND); + createDelivery(fixture.client(), fixture.portfolio(), fixture.security(), + PortfolioTransaction.Type.DELIVERY_OUTBOUND); + + return fixture.client(); + } + + private PortfolioTransaction createDelivery(Client client, Portfolio portfolio, Security security, + PortfolioTransaction.Type type) + { + return new LedgerDeliveryTransactionCreator(client).create(portfolio, type, DATE_TIME, + Values.Amount.factorize(140), CurrencyUnit.EUR, security, Values.Share.factorize(12), + Money.of(CurrencyUnit.USD, Values.Amount.factorize(70)), EXCHANGE_RATE, deliveryUnits(), + "note", "source"); + } + + private List deliveryUnits() + { + return List.of( + new Unit(Unit.Type.GROSS_VALUE, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(200)), + Money.of(CurrencyUnit.USD, Values.Amount.factorize(100)), EXCHANGE_RATE), + new Unit(Unit.Type.FEE, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(20)), + Money.of(CurrencyUnit.USD, Values.Amount.factorize(10)), EXCHANGE_RATE), + new Unit(Unit.Type.TAX, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(40)), + Money.of(CurrencyUnit.USD, Values.Amount.factorize(20)), EXCHANGE_RATE)); + } + + private void assertUnitPostings(List postings) + { + assertTrue(postings.stream().anyMatch(posting -> posting.getType() == LedgerPostingType.GROSS_VALUE + && posting.getAmount() == Values.Amount.factorize(200) + && posting.getForexAmount() == Values.Amount.factorize(100) + && CurrencyUnit.USD.equals(posting.getForexCurrency()) + && EXCHANGE_RATE.equals(posting.getExchangeRate()))); + assertTrue(postings.stream().anyMatch(posting -> posting.getType() == LedgerPostingType.FEE + && posting.getAmount() == Values.Amount.factorize(20) + && posting.getForexAmount() == Values.Amount.factorize(10) + && CurrencyUnit.USD.equals(posting.getForexCurrency()) + && EXCHANGE_RATE.equals(posting.getExchangeRate()))); + assertTrue(postings.stream().anyMatch(posting -> posting.getType() == LedgerPostingType.TAX + && posting.getAmount() == Values.Amount.factorize(40) + && posting.getForexAmount() == Values.Amount.factorize(20) + && CurrencyUnit.USD.equals(posting.getForexCurrency()) + && EXCHANGE_RATE.equals(posting.getExchangeRate()))); + } + + private List projectionUUIDs(Client client) + { + return client.getLedger().getEntries().stream() + .flatMap(entry -> entry.getProjectionRefs().stream()) + .map(LedgerProjectionRef::getUUID) + .toList(); + } + + private String saveXml(Client client) throws IOException + { + var file = File.createTempFile("ledger-delivery", ".xml"); + try + { + ClientFactory.save(client, file); + return Files.readString(file.toPath(), StandardCharsets.UTF_8); + } + finally + { + Files.deleteIfExists(file.toPath()); + } + } + + private Client loadXml(String xml) throws IOException + { + return ClientFactory.load(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); + } + + private byte[] saveProtobuf(Client client) throws IOException + { + return ProtobufTestUtilities.save(client); + } + + private Client loadProtobuf(byte[] bytes) throws IOException + { + return ProtobufTestUtilities.load(bytes); + } + + private void assertValid(Client client) + { + var result = LedgerStructuralValidator.validate(client.getLedger()); + + assertThat(result.getIssues().toString(), result.isOK(), is(true)); + } + + private Fixture fixture() + { + var client = new Client(); + var account = account(); + var portfolio = portfolio(account); + var security = new Security("Security", CurrencyUnit.USD); + security.setUpdatedAt(Instant.now()); + + client.addAccount(account); + client.addPortfolio(portfolio); + client.addSecurity(security); + + return new Fixture(client, account, portfolio, security); + } + + private Account account() + { + var account = new Account("Account"); + account.setCurrencyCode(CurrencyUnit.EUR); + return account; + } + + private Portfolio portfolio(Account account) + { + var portfolio = new Portfolio("Portfolio"); + portfolio.setReferenceAccount(account); + return portfolio; + } + + private record Fixture(Client client, Account account, Portfolio portfolio, Security security) + { + } +} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDividendTransactionCreatorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDividendTransactionCreatorTest.java new file mode 100644 index 0000000000..c4007a94de --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDividendTransactionCreatorTest.java @@ -0,0 +1,389 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import static org.hamcrest.CoreMatchers.instanceOf; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.Test; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.ClientFactory; +import name.abuchen.portfolio.model.ProtobufTestUtilities; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.Transaction.Unit; +import name.abuchen.portfolio.model.ledger.LedgerParameter; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; +import name.abuchen.portfolio.model.ledger.ProjectionMembershipRole; +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.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.Values; + +/** + * Tests ledger-aware creation and editing of transactions in this family. + * These tests make sure user-visible rows are rebuilt from ledger truth and structural facts are not written through legacy projections. + */ +@SuppressWarnings("nls") +public class LedgerDividendTransactionCreatorTest +{ + private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 6, 7, 8, 9); + private static final LocalDateTime EX_DATE = LocalDateTime.of(2026, 6, 1, 0, 0); + private static final BigDecimal EXCHANGE_RATE = BigDecimal.valueOf(2); + + /** + * Verifies that a dividend booking is created directly in the ledger. + * The account row must expose dividend facts from the persisted ledger entry. + */ + @Test + public void testCreatesLedgerDividendDirectly() + { + var fixture = fixture(); + var transaction = createDividend(fixture.client(), fixture.account(), fixture.security()); + var entry = fixture.client().getLedger().getEntries().get(0); + var cashPosting = entry.getPostings().get(0); + var projection = entry.getProjectionRefs().get(0); + + assertThat(entry.getType(), is(LedgerEntryType.DIVIDENDS)); + assertThat(entry.getDateTime(), is(DATE_TIME)); + assertThat(entry.getNote(), is("note")); + assertThat(entry.getSource(), is("source")); + assertThat(cashPosting.getType(), is(LedgerPostingType.CASH)); + assertThat(cashPosting.getAmount(), is(Values.Amount.factorize(140))); + assertThat(cashPosting.getCurrency(), is(CurrencyUnit.EUR)); + assertThat(cashPosting.getForexAmount(), is(Values.Amount.factorize(70))); + assertThat(cashPosting.getForexCurrency(), is(CurrencyUnit.USD)); + assertThat(cashPosting.getExchangeRate(), is(EXCHANGE_RATE)); + assertSame(fixture.account(), cashPosting.getAccount()); + assertSame(fixture.security(), cashPosting.getSecurity()); + assertThat(cashPosting.getShares(), is(Values.Share.factorize(12))); + assertThat(exDate(cashPosting), is(EX_DATE)); + assertThat(entry.getProjectionRefs().size(), is(1)); + assertSame(fixture.account(), projection.getAccount()); + assertThat(projection.getPrimaryPostingUUID(), is(cashPosting.getUUID())); + assertThat(projection.getPrimaryMembership().orElseThrow().getPostingUUID(), is(cashPosting.getUUID())); + assertThat(projection.getMembershipsByRole(ProjectionMembershipRole.FEE_UNIT).size(), is(1)); + assertThat(projection.getMembershipsByRole(ProjectionMembershipRole.TAX_UNIT).size(), is(1)); + assertThat(projection.getMembershipsByRole(ProjectionMembershipRole.GROSS_VALUE_UNIT).size(), is(1)); + assertThat(transaction.getUUID(), is(projection.getUUID())); + assertThat(transaction, instanceOf(LedgerBackedTransaction.class)); + assertThat(transaction.getType(), is(AccountTransaction.Type.DIVIDENDS)); + assertThat(transaction.getDateTime(), is(DATE_TIME)); + assertThat(transaction.getAmount(), is(Values.Amount.factorize(140))); + assertThat(transaction.getCurrencyCode(), is(CurrencyUnit.EUR)); + assertSame(fixture.security(), transaction.getSecurity()); + assertThat(transaction.getShares(), is(Values.Share.factorize(12))); + assertThat(transaction.getExDate(), is(EX_DATE)); + assertThat(transaction.getNote(), is("note")); + assertThat(transaction.getSource(), is("source")); + assertThat(fixture.account().getTransactions(), is(List.of(transaction))); + assertThat(fixture.client().getAllTransactions().size(), is(1)); + assertUnitPostings(entry.getPostings()); + assertValid(fixture.client()); + } + + /** + * Verifies that metadata setters remain allowed on dividend projections. + * Structural setters must stay blocked so runtime projections do not become a second truth. + */ + @Test + public void testCreatedDividendAllowsMetadataSetterAndRejectsStructuralSetters() + { + var fixture = fixture(); + var transaction = createDividend(fixture.client(), fixture.account(), fixture.security()); + var updatedDateTime = DATE_TIME.plusDays(1); + + transaction.setDateTime(updatedDateTime); + transaction.setNote("updated note"); + transaction.setSource("updated source"); + + var entry = fixture.client().getLedger().getEntries().get(0); + + assertThat(entry.getDateTime(), is(updatedDateTime)); + assertThat(entry.getNote(), is("updated note")); + assertThat(entry.getSource(), is("updated source")); + assertThrows(UnsupportedOperationException.class, () -> transaction.setAmount(1L)); + assertThrows(UnsupportedOperationException.class, () -> transaction.setCurrencyCode(CurrencyUnit.USD)); + assertThrows(UnsupportedOperationException.class, () -> transaction.setShares(1L)); + assertThrows(UnsupportedOperationException.class, () -> transaction.setExDate(EX_DATE.plusDays(1))); + assertThrows(UnsupportedOperationException.class, transaction::clearUnits); + assertValid(fixture.client()); + } + + /** + * Verifies that ledger-backed dividend projections expose gross-value read methods. + * UI code must be able to read unit-derived values without mutating projections. + */ + @Test + public void testLedgerBackedDividendSupportsGrossValueReadMethods() + { + var fixture = fixture(); + var transaction = createDividend(fixture.client(), fixture.account(), fixture.security()); + + assertThat(transaction.getGrossValueAmount(), is(Values.Amount.factorize(200))); + assertThat(transaction.getGrossValue(), is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(200)))); + assertThat(transaction.getUnits().count(), is(3L)); + assertTrue(transaction.toString().contains("DIVIDENDS")); + } + + /** + * Verifies that same-shape dividend edits and owner moves are applied through ledger paths. + * The projected dividend must refresh without creating duplicate booking truth. + */ + @Test + public void testFacadeAppliesDividendSameShapeEditAndMovesOwner() throws Exception + { + var fixture = fixture(); + var otherAccount = account(); + fixture.client().addAccount(otherAccount); + var otherSecurity = new Security("Other Security", CurrencyUnit.USD); + otherSecurity.setUpdatedAt(Instant.now()); + fixture.client().addSecurity(otherSecurity); + var creator = new LedgerDividendTransactionCreator(fixture.client()); + var transaction = createDividend(fixture.client(), fixture.account(), fixture.security()); + var projectionUUID = transaction.getUUID(); + var entry = fixture.client().getLedger().getEntries().get(0); + var entryUUID = entry.getUUID(); + var postingUUID = entry.getPostings().get(0).getUUID(); + var updatedUnits = List.of(new Unit(Unit.Type.FEE, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(3)), + Money.of(CurrencyUnit.USD, Values.Amount.factorize(2)), BigDecimal.valueOf(1.5))); + + creator.update(transaction, fixture.account(), AccountTransaction.Type.DIVIDENDS, DATE_TIME.plusDays(2), + Values.Amount.factorize(150), CurrencyUnit.EUR, otherSecurity, Values.Share.factorize(20), + EX_DATE.plusDays(1), Money.of(CurrencyUnit.USD, Values.Amount.factorize(75)), EXCHANGE_RATE, + updatedUnits, "updated note", "updated source"); + + var cashPosting = entry.getPostings().get(0); + + assertThat(entry.getDateTime(), is(DATE_TIME.plusDays(2))); + assertThat(entry.getNote(), is("updated note")); + assertThat(entry.getSource(), is("updated source")); + assertThat(cashPosting.getAmount(), is(Values.Amount.factorize(150))); + assertThat(cashPosting.getCurrency(), is(CurrencyUnit.EUR)); + assertThat(cashPosting.getForexAmount(), is(Values.Amount.factorize(75))); + assertThat(cashPosting.getForexCurrency(), is(CurrencyUnit.USD)); + assertThat(cashPosting.getExchangeRate(), is(EXCHANGE_RATE)); + assertSame(otherSecurity, cashPosting.getSecurity()); + assertThat(cashPosting.getShares(), is(Values.Share.factorize(20))); + assertThat(exDate(cashPosting), is(EX_DATE.plusDays(1))); + assertTrue(entry.getPostings().stream().anyMatch(posting -> posting.getType() == LedgerPostingType.FEE + && posting.getAmount() == Values.Amount.factorize(3) + && posting.getForexAmount() == Values.Amount.factorize(2) + && CurrencyUnit.USD.equals(posting.getForexCurrency()) + && BigDecimal.valueOf(1.5).equals(posting.getExchangeRate()))); + var moved = creator.update(transaction, otherAccount, AccountTransaction.Type.DIVIDENDS, DATE_TIME.plusDays(3), + Values.Amount.factorize(151), CurrencyUnit.EUR, otherSecurity, Values.Share.factorize(21), + EX_DATE.plusDays(2), null, null, List.of(), "moved note", "moved source"); + + assertTrue(fixture.account().getTransactions().isEmpty()); + assertThat(otherAccount.getTransactions(), is(List.of(moved))); + assertThat(moved.getUUID(), is(projectionUUID)); + assertThat(entry.getUUID(), is(entryUUID)); + assertThat(entry.getPostings().get(0).getUUID(), is(postingUUID)); + assertThat(entry.getProjectionRefs().get(0).getUUID(), is(projectionUUID)); + assertSame(otherAccount, entry.getPostings().get(0).getAccount()); + assertSame(otherAccount, entry.getProjectionRefs().get(0).getAccount()); + assertThat(moved.getAmount(), is(Values.Amount.factorize(151))); + assertThat(moved.getShares(), is(Values.Share.factorize(21))); + assertThat(moved.getNote(), is("moved note")); + assertThrows(UnsupportedOperationException.class, + () -> creator.update(transaction, fixture.account(), AccountTransaction.Type.BUY, DATE_TIME, + Values.Amount.factorize(150), CurrencyUnit.EUR, otherSecurity, + Values.Share.factorize(20), EX_DATE, null, null, List.of(), "note", "source")); + assertThat(fixture.client().getAllTransactions().size(), is(1)); + assertThat(projectionUUIDs(loadXml(saveXml(fixture.client()))), is(List.of(projectionUUID))); + assertThat(projectionUUIDs(loadProtobuf(saveProtobuf(fixture.client()))), is(List.of(projectionUUID))); + assertValid(fixture.client()); + } + + /** + * Verifies that XML save/load/save preserves dividend projection identity and fields. + * The dividend row must rematerialize from the same ledger entry with ex-date and units intact. + */ + @Test + public void testXmlSaveLoadSavePreservesDividendProjectionUUIDAndFields() throws Exception + { + var client = dividendClient(); + var expectedProjectionUUIDs = projectionUUIDs(client); + + var loaded = loadXml(saveXml(client)); + var reloaded = loadXml(saveXml(loaded)); + var transaction = reloaded.getAccounts().get(0).getTransactions().get(0); + + assertThat(projectionUUIDs(reloaded), is(expectedProjectionUUIDs)); + assertThat(reloaded.getLedger().getEntries().size(), is(1)); + assertThat(transaction, instanceOf(LedgerBackedTransaction.class)); + assertThat(transaction.getType(), is(AccountTransaction.Type.DIVIDENDS)); + assertThat(transaction.getExDate(), is(EX_DATE)); + assertThat(transaction.getShares(), is(Values.Share.factorize(12))); + assertThat(transaction.getUnits().count(), is(3L)); + assertValid(reloaded); + } + + /** + * Verifies that protobuf save/load/save preserves dividend projection identity and fields. + * The dividend row must rematerialize from the same ledger entry with ex-date and units intact. + */ + @Test + public void testProtobufSaveLoadSavePreservesDividendProjectionUUIDAndFields() throws Exception + { + var client = dividendClient(); + var expectedProjectionUUIDs = projectionUUIDs(client); + + var loaded = loadProtobuf(saveProtobuf(client)); + var reloaded = loadProtobuf(saveProtobuf(loaded)); + var transaction = reloaded.getAccounts().get(0).getTransactions().get(0); + + assertThat(projectionUUIDs(reloaded), is(expectedProjectionUUIDs)); + assertThat(reloaded.getLedger().getEntries().size(), is(1)); + assertThat(transaction, instanceOf(LedgerBackedTransaction.class)); + assertThat(transaction.getType(), is(AccountTransaction.Type.DIVIDENDS)); + assertThat(transaction.getExDate(), is(EX_DATE)); + assertThat(transaction.getShares(), is(Values.Share.factorize(12))); + assertThat(transaction.getUnits().count(), is(3L)); + assertValid(reloaded); + } + + private Client dividendClient() + { + var fixture = fixture(); + createDividend(fixture.client(), fixture.account(), fixture.security()); + return fixture.client(); + } + + private AccountTransaction createDividend(Client client, Account account, Security security) + { + return new LedgerDividendTransactionCreator(client).create(account, DATE_TIME, Values.Amount.factorize(140), + CurrencyUnit.EUR, security, Values.Share.factorize(12), EX_DATE, + Money.of(CurrencyUnit.USD, Values.Amount.factorize(70)), EXCHANGE_RATE, dividendUnits(), + "note", "source"); + } + + private List dividendUnits() + { + return List.of( + new Unit(Unit.Type.GROSS_VALUE, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(200)), + Money.of(CurrencyUnit.USD, Values.Amount.factorize(100)), EXCHANGE_RATE), + new Unit(Unit.Type.FEE, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(20)), + Money.of(CurrencyUnit.USD, Values.Amount.factorize(10)), EXCHANGE_RATE), + new Unit(Unit.Type.TAX, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(40)), + Money.of(CurrencyUnit.USD, Values.Amount.factorize(20)), EXCHANGE_RATE)); + } + + private void assertUnitPostings(List postings) + { + assertTrue(postings.stream().anyMatch(posting -> posting.getType() == LedgerPostingType.GROSS_VALUE + && posting.getAmount() == Values.Amount.factorize(200) + && posting.getForexAmount() == Values.Amount.factorize(100) + && CurrencyUnit.USD.equals(posting.getForexCurrency()) + && EXCHANGE_RATE.equals(posting.getExchangeRate()))); + assertTrue(postings.stream().anyMatch(posting -> posting.getType() == LedgerPostingType.FEE + && posting.getAmount() == Values.Amount.factorize(20) + && posting.getForexAmount() == Values.Amount.factorize(10) + && CurrencyUnit.USD.equals(posting.getForexCurrency()) + && EXCHANGE_RATE.equals(posting.getExchangeRate()))); + assertTrue(postings.stream().anyMatch(posting -> posting.getType() == LedgerPostingType.TAX + && posting.getAmount() == Values.Amount.factorize(40) + && posting.getForexAmount() == Values.Amount.factorize(20) + && CurrencyUnit.USD.equals(posting.getForexCurrency()) + && EXCHANGE_RATE.equals(posting.getExchangeRate()))); + } + + private LocalDateTime exDate(LedgerPosting posting) + { + return posting.getParameters().stream() + .filter(parameter -> parameter.getType() == LedgerParameterType.EX_DATE) + .filter(parameter -> parameter.getValueKind() == LedgerParameter.ValueKind.LOCAL_DATE_TIME) + .map(LedgerParameter::getValue) + .map(LocalDateTime.class::cast) + .findFirst() + .orElse(null); + } + + private List projectionUUIDs(Client client) + { + return client.getLedger().getEntries().stream() + .flatMap(entry -> entry.getProjectionRefs().stream()) + .map(LedgerProjectionRef::getUUID) + .toList(); + } + + private String saveXml(Client client) throws IOException + { + var file = File.createTempFile("ledger-dividend", ".xml"); + try + { + ClientFactory.save(client, file); + return Files.readString(file.toPath(), StandardCharsets.UTF_8); + } + finally + { + Files.deleteIfExists(file.toPath()); + } + } + + private Client loadXml(String xml) throws IOException + { + return ClientFactory.load(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); + } + + private byte[] saveProtobuf(Client client) throws IOException + { + return ProtobufTestUtilities.save(client); + } + + private Client loadProtobuf(byte[] bytes) throws IOException + { + return ProtobufTestUtilities.load(bytes); + } + + private void assertValid(Client client) + { + var result = LedgerStructuralValidator.validate(client.getLedger()); + + assertThat(result.getIssues().toString(), result.isOK(), is(true)); + } + + private Fixture fixture() + { + var client = new Client(); + var account = account(); + var security = new Security("Security", CurrencyUnit.USD); + security.setUpdatedAt(Instant.now()); + + client.addAccount(account); + client.addSecurity(security); + + return new Fixture(client, account, security); + } + + private Account account() + { + var account = new Account("Account"); + account.setCurrencyCode(CurrencyUnit.EUR); + return account; + } + + private record Fixture(Client client, Account account, Security security) + { + } +} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferTransactionCreatorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferTransactionCreatorTest.java new file mode 100644 index 0000000000..ee8af78ba5 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferTransactionCreatorTest.java @@ -0,0 +1,427 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import static org.hamcrest.CoreMatchers.instanceOf; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.Test; + +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.ClientFactory; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.PortfolioTransaction; +import name.abuchen.portfolio.model.PortfolioTransferEntry; +import name.abuchen.portfolio.model.ProtobufTestUtilities; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.Values; + +/** + * Tests ledger-aware creation and editing of transactions in this family. + * These tests make sure user-visible rows are rebuilt from ledger truth and structural facts are not written through legacy projections. + */ +@SuppressWarnings("nls") +public class LedgerPortfolioTransferTransactionCreatorTest +{ + private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 6, 7, 8, 9); + + /** + * Verifies that a portfolio transfer is created directly in the ledger. + * Source and target depot rows must be projections of one persisted transfer booking. + */ + @Test + public void testCreatesLedgerPortfolioTransferDirectly() + { + var fixture = fixture(); + var transfer = createTransfer(fixture); + var sourceTransaction = transfer.getSourceTransaction(); + var targetTransaction = transfer.getTargetTransaction(); + var entry = fixture.client().getLedger().getEntries().get(0); + var sourcePosting = entry.getPostings().get(0); + var targetPosting = entry.getPostings().get(1); + var sourceProjection = projection(entry, LedgerProjectionRole.SOURCE_PORTFOLIO); + var targetProjection = projection(entry, LedgerProjectionRole.TARGET_PORTFOLIO); + + assertThat(entry.getType(), is(LedgerEntryType.SECURITY_TRANSFER)); + assertThat(entry.getDateTime(), is(DATE_TIME)); + assertThat(entry.getNote(), is("note")); + assertThat(entry.getSource(), is("source")); + assertThat(sourcePosting.getType(), is(LedgerPostingType.SECURITY)); + assertThat(sourcePosting.getAmount(), is(Values.Amount.factorize(100))); + assertThat(sourcePosting.getCurrency(), is(CurrencyUnit.EUR)); + assertSame(fixture.source(), sourcePosting.getPortfolio()); + assertSame(fixture.security(), sourcePosting.getSecurity()); + assertThat(sourcePosting.getShares(), is(Values.Share.factorize(5))); + assertThat(targetPosting.getType(), is(LedgerPostingType.SECURITY)); + assertThat(targetPosting.getAmount(), is(Values.Amount.factorize(100))); + assertThat(targetPosting.getCurrency(), is(CurrencyUnit.EUR)); + assertSame(fixture.target(), targetPosting.getPortfolio()); + assertSame(fixture.security(), targetPosting.getSecurity()); + assertThat(targetPosting.getShares(), is(Values.Share.factorize(5))); + assertTrue(entry.getPostings().stream().allMatch(posting -> posting.getAccount() == null)); + + assertThat(sourceProjection.getRole(), is(LedgerProjectionRole.SOURCE_PORTFOLIO)); + assertSame(fixture.source(), sourceProjection.getPortfolio()); + assertThat(sourceProjection.getPrimaryPostingUUID(), is(sourcePosting.getUUID())); + assertThat(sourceProjection.getPrimaryMembership().orElseThrow().getPostingUUID(), is(sourcePosting.getUUID())); + assertThat(targetProjection.getRole(), is(LedgerProjectionRole.TARGET_PORTFOLIO)); + assertSame(fixture.target(), targetProjection.getPortfolio()); + assertThat(targetProjection.getPrimaryPostingUUID(), is(targetPosting.getUUID())); + assertThat(targetProjection.getPrimaryMembership().orElseThrow().getPostingUUID(), is(targetPosting.getUUID())); + assertThat(sourceTransaction.getUUID(), is(sourceProjection.getUUID())); + assertThat(targetTransaction.getUUID(), is(targetProjection.getUUID())); + assertThat(sourceTransaction, instanceOf(LedgerBackedTransaction.class)); + assertThat(targetTransaction, instanceOf(LedgerBackedTransaction.class)); + assertThat(sourceTransaction.getType(), is(PortfolioTransaction.Type.TRANSFER_OUT)); + assertThat(targetTransaction.getType(), is(PortfolioTransaction.Type.TRANSFER_IN)); + assertThat(sourceTransaction.getAmount(), is(Values.Amount.factorize(100))); + assertThat(targetTransaction.getAmount(), is(Values.Amount.factorize(100))); + assertThat(sourceTransaction.getCurrencyCode(), is(CurrencyUnit.EUR)); + assertThat(targetTransaction.getCurrencyCode(), is(CurrencyUnit.EUR)); + assertSame(fixture.security(), sourceTransaction.getSecurity()); + assertSame(fixture.security(), targetTransaction.getSecurity()); + assertThat(sourceTransaction.getShares(), is(Values.Share.factorize(5))); + assertThat(targetTransaction.getShares(), is(Values.Share.factorize(5))); + assertThat(sourceTransaction.getNote(), is("note")); + assertThat(sourceTransaction.getSource(), is("source")); + assertThat(fixture.source().getTransactions(), is(List.of(sourceTransaction))); + assertThat(fixture.target().getTransactions(), is(List.of(targetTransaction))); + assertThat(fixture.client().getAllTransactions().size(), is(1)); + assertSame(sourceTransaction, fixture.client().getAllTransactions().get(0).getTransaction()); + assertCrossEntryReadCompatibility(sourceTransaction, targetTransaction, fixture.source(), fixture.target()); + assertValid(fixture.client()); + } + + /** + * Verifies that same-shape portfolio transfer edits and owner moves are applied through ledger paths. + * Source and target projections must move without legacy delete/insert replay. + */ + @Test + public void testFacadeAppliesSameShapeEditAndMovesOwners() throws Exception + { + var fixture = fixture(); + var otherPortfolio = new Portfolio("Other"); + var otherTarget = new Portfolio("Other Target"); + otherPortfolio.setUpdatedAt(Instant.now()); + otherTarget.setUpdatedAt(Instant.now()); + var otherSecurity = new Security("Other Security", CurrencyUnit.EUR); + otherSecurity.setUpdatedAt(Instant.now()); + fixture.client().addPortfolio(otherPortfolio); + fixture.client().addPortfolio(otherTarget); + fixture.client().addSecurity(otherSecurity); + var creator = new LedgerPortfolioTransferTransactionCreator(fixture.client()); + var transfer = createTransfer(fixture); + var sourceTransaction = transfer.getSourceTransaction(); + var targetTransaction = transfer.getTargetTransaction(); + var sourcePostingUUID = fixture.client().getLedger().getEntries().get(0).getPostings().get(0).getUUID(); + var targetPostingUUID = fixture.client().getLedger().getEntries().get(0).getPostings().get(1).getUUID(); + var entryUUID = fixture.client().getLedger().getEntries().get(0).getUUID(); + var expectedProjectionUUIDs = projectionUUIDs(fixture.client()); + + creator.update(transfer, fixture.source(), fixture.target(), otherSecurity, DATE_TIME.plusDays(1), + Values.Share.factorize(7), Values.Amount.factorize(150), CurrencyUnit.EUR, "updated", + "updated source"); + + var entry = fixture.client().getLedger().getEntries().get(0); + + assertThat(entry.getDateTime(), is(DATE_TIME.plusDays(1))); + assertThat(entry.getNote(), is("updated")); + assertThat(entry.getSource(), is("updated source")); + assertThat(entry.getPostings().get(0).getUUID(), is(sourcePostingUUID)); + assertThat(entry.getPostings().get(0).getAmount(), is(Values.Amount.factorize(150))); + assertSame(otherSecurity, entry.getPostings().get(0).getSecurity()); + assertThat(entry.getPostings().get(0).getShares(), is(Values.Share.factorize(7))); + assertThat(entry.getPostings().get(1).getUUID(), is(targetPostingUUID)); + assertThat(entry.getPostings().get(1).getAmount(), is(Values.Amount.factorize(150))); + assertSame(otherSecurity, entry.getPostings().get(1).getSecurity()); + assertThat(entry.getPostings().get(1).getShares(), is(Values.Share.factorize(7))); + assertThat(sourceTransaction.getAmount(), is(Values.Amount.factorize(150))); + assertThat(targetTransaction.getAmount(), is(Values.Amount.factorize(150))); + + var moved = creator.update(transfer, otherPortfolio, otherTarget, otherSecurity, DATE_TIME.plusDays(2), + Values.Share.factorize(8), Values.Amount.factorize(175), CurrencyUnit.EUR, "moved note", + "moved source"); + var movedSourceTransaction = moved.getSourceTransaction(); + var movedTargetTransaction = moved.getTargetTransaction(); + + assertTrue(fixture.source().getTransactions().isEmpty()); + assertTrue(fixture.target().getTransactions().isEmpty()); + assertThat(otherPortfolio.getTransactions(), is(List.of(movedSourceTransaction))); + assertThat(otherTarget.getTransactions(), is(List.of(movedTargetTransaction))); + assertThat(movedSourceTransaction.getUUID(), is(expectedProjectionUUIDs.get(0))); + assertThat(movedTargetTransaction.getUUID(), is(expectedProjectionUUIDs.get(1))); + assertThat(entry.getUUID(), is(entryUUID)); + assertThat(entry.getPostings().get(0).getUUID(), is(sourcePostingUUID)); + assertThat(entry.getPostings().get(1).getUUID(), is(targetPostingUUID)); + assertSame(otherPortfolio, entry.getPostings().get(0).getPortfolio()); + assertSame(otherTarget, entry.getPostings().get(1).getPortfolio()); + assertSame(otherPortfolio, projection(entry, LedgerProjectionRole.SOURCE_PORTFOLIO).getPortfolio()); + assertSame(otherTarget, projection(entry, LedgerProjectionRole.TARGET_PORTFOLIO).getPortfolio()); + assertCrossEntryReadCompatibility(movedSourceTransaction, movedTargetTransaction, otherPortfolio, otherTarget); + assertThrows(UnsupportedOperationException.class, + () -> sourceTransaction.setType(PortfolioTransaction.Type.DELIVERY_INBOUND)); + assertThrows(UnsupportedOperationException.class, () -> sourceTransaction.setAmount(1L)); + sourceTransaction.getCrossEntry().updateFrom(sourceTransaction); + assertTrue(fixture.source().getTransactions().isEmpty()); + assertThat(otherPortfolio.getTransactions(), is(List.of(movedSourceTransaction))); + assertThrows(UnsupportedOperationException.class, + () -> sourceTransaction.getCrossEntry().setOwner(sourceTransaction, otherPortfolio)); + assertThrows(UnsupportedOperationException.class, () -> sourceTransaction.getCrossEntry().insert()); + assertThat(fixture.client().getAllTransactions().size(), is(1)); + assertThat(projectionUUIDs(loadXml(saveXml(fixture.client()))), is(expectedProjectionUUIDs)); + assertThat(projectionUUIDs(loadProtobuf(saveProtobuf(fixture.client()))), is(expectedProjectionUUIDs)); + assertValid(fixture.client()); + } + + /** + * Verifies that invalid portfolio transfer units are rejected before a ledger entry is added. + * The creator must not leave partial transfer truth behind. + */ + @Test + public void testCreateRejectsInvalidUnitsWithoutPartialLedgerEntry() + { + var fixture = fixture(); + var creator = new LedgerPortfolioTransferTransactionCreator(fixture.client()); + var invalidUnits = LedgerUnitPostingPatch.of(LedgerUnitPostingEdit.add(LedgerPostingType.FEE, + Money.of(CurrencyUnit.EUR, Values.Amount.factorize(-1)))); + + assertThrows(IllegalArgumentException.class, + () -> creator.create(fixture.source(), fixture.target(), fixture.security(), DATE_TIME, + Values.Share.factorize(5), Values.Amount.factorize(100), CurrencyUnit.EUR, + LedgerForexAmount.none(), LedgerForexAmount.none(), invalidUnits, "note", + "source")); + + assertTrue(fixture.client().getLedger().getEntries().isEmpty()); + assertTrue(fixture.source().getTransactions().isEmpty()); + assertTrue(fixture.target().getTransactions().isEmpty()); + } + + /** + * Verifies that mutable legacy setters stay blocked on ledger-backed portfolio transfers. + * A failed setter attempt must leave the ledger and both owner lists unchanged. + */ + @Test + public void testReadOnlyWrapperRejectsAllMutableSettersWithoutPartialMutation() + { + var fixture = fixture(); + var otherPortfolio = new Portfolio("Other"); + var otherSourceTransaction = new PortfolioTransaction(); + var otherTargetTransaction = new PortfolioTransaction(); + var transfer = createTransfer(fixture); + var sourceTransaction = transfer.getSourceTransaction(); + var targetTransaction = transfer.getTargetTransaction(); + + assertThrows(UnsupportedOperationException.class, () -> transfer.setSourcePortfolio(otherPortfolio)); + assertThrows(UnsupportedOperationException.class, () -> transfer.setTargetPortfolio(otherPortfolio)); + assertThrows(UnsupportedOperationException.class, () -> transfer.setSourceTransaction(otherSourceTransaction)); + assertThrows(UnsupportedOperationException.class, () -> transfer.setTargetTransaction(otherTargetTransaction)); + assertThrows(UnsupportedOperationException.class, () -> transfer.setDate(DATE_TIME.plusDays(1))); + assertThrows(UnsupportedOperationException.class, () -> transfer.setSecurity(fixture.security())); + assertThrows(UnsupportedOperationException.class, () -> transfer.setShares(1L)); + assertThrows(UnsupportedOperationException.class, () -> transfer.setAmount(1L)); + assertThrows(UnsupportedOperationException.class, () -> transfer.setCurrencyCode(CurrencyUnit.USD)); + assertThrows(UnsupportedOperationException.class, () -> transfer.setNote("other")); + assertThrows(UnsupportedOperationException.class, () -> transfer.setSource("other")); + + assertSame(fixture.source(), transfer.getSourcePortfolio()); + assertSame(fixture.target(), transfer.getTargetPortfolio()); + assertSame(sourceTransaction, transfer.getSourceTransaction()); + assertSame(targetTransaction, transfer.getTargetTransaction()); + assertCrossEntryReadCompatibility(sourceTransaction, targetTransaction, fixture.source(), fixture.target()); + assertValid(fixture.client()); + } + + /** + * Verifies that normal legacy transfer entries still allow their mutable setters. + * Ledger read-only protection must not change non-ledger portfolio transfer behavior. + */ + @Test + public void testLegacyTransferEntryMutableSettersStillWork() + { + var source = new Portfolio("Source"); + var target = new Portfolio("Target"); + var otherSource = new Portfolio("Other Source"); + var otherTarget = new Portfolio("Other Target"); + var replacementSourceTransaction = new PortfolioTransaction(); + var replacementTargetTransaction = new PortfolioTransaction(); + var transfer = new PortfolioTransferEntry(source, target); + + transfer.setSourcePortfolio(otherSource); + transfer.setTargetPortfolio(otherTarget); + transfer.setSourceTransaction(replacementSourceTransaction); + transfer.setTargetTransaction(replacementTargetTransaction); + + assertSame(otherSource, transfer.getSourcePortfolio()); + assertSame(otherTarget, transfer.getTargetPortfolio()); + assertSame(replacementSourceTransaction, transfer.getSourceTransaction()); + assertSame(replacementTargetTransaction, transfer.getTargetTransaction()); + } + + /** + * Verifies that XML save/load/save preserves portfolio-transfer projection identities and fields. + * Source and target depot rows must rematerialize from the same ledger entry. + */ + @Test + public void testXmlSaveLoadSavePreservesPortfolioTransferProjectionUUIDsAndFields() throws Exception + { + var client = transferClient(); + var expectedProjectionUUIDs = projectionUUIDs(client); + + var loaded = loadXml(saveXml(client)); + var reloaded = loadXml(saveXml(loaded)); + + assertThat(projectionUUIDs(reloaded), is(expectedProjectionUUIDs)); + assertThat(reloaded.getLedger().getEntries().size(), is(1)); + assertThat(reloaded.getPortfolios().get(0).getTransactions().get(0), instanceOf(LedgerBackedTransaction.class)); + assertThat(reloaded.getPortfolios().get(1).getTransactions().get(0), instanceOf(LedgerBackedTransaction.class)); + assertThat(reloaded.getPortfolios().get(0).getTransactions().get(0).getType(), + is(PortfolioTransaction.Type.TRANSFER_OUT)); + assertThat(reloaded.getPortfolios().get(1).getTransactions().get(0).getType(), + is(PortfolioTransaction.Type.TRANSFER_IN)); + assertThat(reloaded.getAllTransactions().size(), is(1)); + assertValid(reloaded); + } + + /** + * Verifies that protobuf save/load/save preserves portfolio-transfer projection identities and fields. + * Source and target depot rows must rematerialize from the same ledger entry. + */ + @Test + public void testProtobufSaveLoadSavePreservesPortfolioTransferProjectionUUIDsAndFields() throws Exception + { + var client = transferClient(); + var expectedProjectionUUIDs = projectionUUIDs(client); + + var loaded = loadProtobuf(saveProtobuf(client)); + var reloaded = loadProtobuf(saveProtobuf(loaded)); + + assertThat(projectionUUIDs(reloaded), is(expectedProjectionUUIDs)); + assertThat(reloaded.getLedger().getEntries().size(), is(1)); + assertThat(reloaded.getPortfolios().get(0).getTransactions().get(0), instanceOf(LedgerBackedTransaction.class)); + assertThat(reloaded.getPortfolios().get(1).getTransactions().get(0), instanceOf(LedgerBackedTransaction.class)); + assertThat(reloaded.getPortfolios().get(0).getTransactions().get(0).getType(), + is(PortfolioTransaction.Type.TRANSFER_OUT)); + assertThat(reloaded.getPortfolios().get(1).getTransactions().get(0).getType(), + is(PortfolioTransaction.Type.TRANSFER_IN)); + assertThat(reloaded.getAllTransactions().size(), is(1)); + assertValid(reloaded); + } + + private void assertCrossEntryReadCompatibility(PortfolioTransaction sourceTransaction, + PortfolioTransaction targetTransaction, Portfolio source, Portfolio target) + { + assertThat(sourceTransaction.getCrossEntry(), instanceOf(PortfolioTransferEntry.class)); + assertThat(targetTransaction.getCrossEntry(), instanceOf(PortfolioTransferEntry.class)); + assertSame(targetTransaction, sourceTransaction.getCrossEntry().getCrossTransaction(sourceTransaction)); + assertSame(sourceTransaction, targetTransaction.getCrossEntry().getCrossTransaction(targetTransaction)); + assertSame(target, sourceTransaction.getCrossEntry().getCrossOwner(sourceTransaction)); + assertSame(source, targetTransaction.getCrossEntry().getCrossOwner(targetTransaction)); + } + + private PortfolioTransferEntry createTransfer(Fixture fixture) + { + return new LedgerPortfolioTransferTransactionCreator(fixture.client()).create(fixture.source(), + fixture.target(), fixture.security(), DATE_TIME, Values.Share.factorize(5), + Values.Amount.factorize(100), CurrencyUnit.EUR, "note", "source"); + } + + private Client transferClient() + { + var fixture = fixture(); + + createTransfer(fixture); + + return fixture.client(); + } + + private LedgerProjectionRef projection(name.abuchen.portfolio.model.ledger.LedgerEntry entry, + LedgerProjectionRole role) + { + return entry.getProjectionRefs().stream().filter(projection -> projection.getRole() == role).findFirst() + .orElseThrow(); + } + + private List projectionUUIDs(Client client) + { + return client.getLedger().getEntries().stream() + .flatMap(entry -> entry.getProjectionRefs().stream()) + .map(LedgerProjectionRef::getUUID) + .toList(); + } + + private String saveXml(Client client) throws IOException + { + var file = File.createTempFile("ledger-portfolio-transfer", ".xml"); + try + { + ClientFactory.save(client, file); + return Files.readString(file.toPath(), StandardCharsets.UTF_8); + } + finally + { + Files.deleteIfExists(file.toPath()); + } + } + + private Client loadXml(String xml) throws IOException + { + return ClientFactory.load(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); + } + + private byte[] saveProtobuf(Client client) throws IOException + { + return ProtobufTestUtilities.save(client); + } + + private Client loadProtobuf(byte[] bytes) throws IOException + { + return ProtobufTestUtilities.load(bytes); + } + + private void assertValid(Client client) + { + var result = LedgerStructuralValidator.validate(client.getLedger()); + + assertThat(result.getIssues().toString(), result.isOK(), is(true)); + } + + private Fixture fixture() + { + var client = new Client(); + var source = new Portfolio("Source"); + var target = new Portfolio("Target"); + var security = new Security("Security", CurrencyUnit.EUR); + source.setUpdatedAt(Instant.now()); + target.setUpdatedAt(Instant.now()); + security.setUpdatedAt(Instant.now()); + + client.addPortfolio(source); + client.addPortfolio(target); + client.addSecurity(security); + + return new Fixture(client, source, target, security); + } + + private record Fixture(Client client, Portfolio source, Portfolio target, Security security) + { + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountCashLeg.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountCashLeg.java new file mode 100644 index 0000000000..6308a930bb --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountCashLeg.java @@ -0,0 +1,50 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.util.Objects; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.money.Money; + +/** + * Carries account cash leg data for Ledger compatibility creators, editors, or patchers. + * This is a compatibility-layer value object. Contributor code may pass it to Ledger write + * paths, but it does not mutate Ledger truth by itself. + */ +public final class LedgerAccountCashLeg +{ + private final Account account; + private final Money amount; + private final LedgerForexAmount forex; + + private LedgerAccountCashLeg(Account account, Money amount, LedgerForexAmount forex) + { + this.account = Objects.requireNonNull(account); + this.amount = Objects.requireNonNull(amount); + this.forex = Objects.requireNonNull(forex); + } + + public static LedgerAccountCashLeg of(Account account, Money amount) + { + return new LedgerAccountCashLeg(account, amount, LedgerForexAmount.none()); + } + + public static LedgerAccountCashLeg of(Account account, Money amount, LedgerForexAmount forex) + { + return new LedgerAccountCashLeg(account, amount, forex); + } + + public Account getAccount() + { + return account; + } + + public Money getAmount() + { + return amount; + } + + public LedgerForexAmount getForex() + { + return forex; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountOnlyTransactionCreator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountOnlyTransactionCreator.java new file mode 100644 index 0000000000..8a25f1711f --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountOnlyTransactionCreator.java @@ -0,0 +1,288 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.Transaction; +import name.abuchen.portfolio.model.ledger.LedgerEntryMetadataPatch; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerTransactionMetadata; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedAccountTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionService; +import name.abuchen.portfolio.money.Money; + +/** + * Creates and updates ledger-backed account-only transactions. + * This class is part of the Ledger compatibility layer for existing UI, import, and action + * code. Contributor code should use it instead of mutating projected legacy transactions. + */ +public final class LedgerAccountOnlyTransactionCreator +{ + private final Client client; + + public LedgerAccountOnlyTransactionCreator(Client client) + { + this.client = Objects.requireNonNull(client); + } + + public AccountTransaction create(Account account, AccountTransaction.Type type, LocalDateTime dateTime, long amount, + String currencyCode, Security security, List units, String note, String source) + { + return create(account, type, dateTime, amount, currencyCode, security, LedgerForexAmount.none(), units, note, + source); + } + + public AccountTransaction create(Account account, AccountTransaction.Type type, LocalDateTime dateTime, long amount, + String currencyCode, Security security, LedgerForexAmount forex, List units, + String note, String source) + { + Objects.requireNonNull(account); + Objects.requireNonNull(type); + Objects.requireNonNull(dateTime); + Objects.requireNonNull(currencyCode); + Objects.requireNonNull(forex); + Objects.requireNonNull(units); + + var facts = normalizePrimaryUnit(type, amount, currencyCode, units); + var metadata = LedgerTransactionMetadata.of(dateTime).withNote(note).withSource(source); + var cashLeg = LedgerAccountCashLeg.of(account, Money.of(currencyCode, facts.amount()), forex); + var optionalSecurity = security != null ? LedgerOptionalSecurity.of(security) : LedgerOptionalSecurity.none(); + var creationUnits = creationUnits(facts.units()); + var creator = new LedgerTransactionCreator(client); + + var created = switch (type) + { + case DEPOSIT -> creator.createDeposit(metadata, cashLeg, creationUnits); + case REMOVAL -> creator.createRemoval(metadata, cashLeg, creationUnits); + case INTEREST -> creator.createInterest(metadata, cashLeg, optionalSecurity, creationUnits); + case INTEREST_CHARGE -> creator.createInterestCharge(metadata, cashLeg, optionalSecurity, creationUnits); + case FEES -> creator.createFee(metadata, cashLeg, optionalSecurity, creationUnits); + case FEES_REFUND -> creator.createFeeRefund(metadata, cashLeg, optionalSecurity, creationUnits); + case TAXES -> creator.createTax(metadata, cashLeg, optionalSecurity, creationUnits); + case TAX_REFUND -> creator.createTaxRefund(metadata, cashLeg, optionalSecurity, creationUnits); + case BUY, SELL, TRANSFER_IN, TRANSFER_OUT, DIVIDENDS -> throw new UnsupportedOperationException( + LedgerDiagnosticCode.LEDGER_CONVERT_003.message("Unsupported account-only ledger production type: " + type)); //$NON-NLS-1$ + }; + + return materializeAndFind(account, created); + } + + private NormalizedAccountOnlyFacts normalizePrimaryUnit(AccountTransaction.Type type, long amount, + String currencyCode, List units) + { + var primaryUnitType = primaryUnitType(type); + + if (primaryUnitType == null || amount != 0) + return new NormalizedAccountOnlyFacts(amount, units); + + var remainingUnits = new ArrayList(); + long primaryUnitAmount = 0; + + for (var unit : units) + { + if (unit.getType() == primaryUnitType && unit.getForex() == null + && currencyCode.equals(unit.getAmount().getCurrencyCode())) + { + primaryUnitAmount += unit.getAmount().getAmount(); + } + else + { + remainingUnits.add(unit); + } + } + + return new NormalizedAccountOnlyFacts(amount != 0 ? amount : primaryUnitAmount, List.copyOf(remainingUnits)); + } + + private Transaction.Unit.Type primaryUnitType(AccountTransaction.Type type) + { + return switch (type) + { + case FEES, FEES_REFUND -> Transaction.Unit.Type.FEE; + case TAXES, TAX_REFUND -> Transaction.Unit.Type.TAX; + case DEPOSIT, REMOVAL, INTEREST, INTEREST_CHARGE, BUY, SELL, TRANSFER_IN, TRANSFER_OUT, DIVIDENDS -> null; + }; + } + + public boolean canUpdate(AccountTransaction transaction) + { + return transaction instanceof LedgerBackedAccountTransaction && isSupportedAccountOnlyType(transaction.getType()); + } + + public AccountTransaction update(AccountTransaction transaction, Account account, AccountTransaction.Type type, + LocalDateTime dateTime, long amount, String currencyCode, Security security, + List units, String note, String source) + { + return update(transaction, account, type, dateTime, amount, currencyCode, security, null, + unitPostingPatch(transaction, units), note, source); + } + + public AccountTransaction update(AccountTransaction transaction, Account account, AccountTransaction.Type type, + LocalDateTime dateTime, long amount, String currencyCode, Security security, + LedgerForexAmount forex, LedgerUnitPostingPatch units, String note, String source) + { + Objects.requireNonNull(transaction); + Objects.requireNonNull(account); + Objects.requireNonNull(type); + Objects.requireNonNull(dateTime); + Objects.requireNonNull(currencyCode); + Objects.requireNonNull(units); + + if (!(transaction instanceof LedgerBackedAccountTransaction ledgerTransaction)) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_CONVERT_004 + .message("Only ledger-backed account transactions can be updated")); //$NON-NLS-1$ + if (!isSupportedAccountOnlyType(type) || transaction.getType() != type) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_CONVERT_005.message("Unsupported account-only ledger production edit type: " + type)); //$NON-NLS-1$ + + var editBuilder = LedgerAccountTransactionEdit.builder() + .metadata(LedgerEntryMetadataPatch.builder().dateTime(dateTime).note(note).source(source) + .build()) + .amount(amount) + .currency(currencyCode) + .security(security) + .clearExDate() + .units(units); + + applyForex(editBuilder, forex); + + var edit = editBuilder.build(); + var editor = new LedgerAccountTransactionEditor(); + + if (ledgerTransaction.getLedgerProjectionRef().getAccount() != account) + { + var projectionUUID = ledgerTransaction.getLedgerProjectionRef().getUUID(); + + editor.validate(ledgerTransaction, edit); + new LedgerOwnerPatchHelper(client).moveAccountOnly(ledgerTransaction, account); + ledgerTransaction = (LedgerBackedAccountTransaction) find(account, projectionUUID); + } + + editor.apply(ledgerTransaction, edit); + + return ledgerTransaction; + } + + private void applyForex(LedgerAccountTransactionEdit.Builder builder, LedgerForexAmount forex) + { + if (forex == null) + return; + + if (forex.isPresent()) + builder.forexAmount(forex.getForexAmount().getAmount()) + .forexCurrency(forex.getForexAmount().getCurrencyCode()) + .exchangeRate(forex.getExchangeRate()); + else + builder.forexAmount(null).forexCurrency(null).exchangeRate(null); + } + + private AccountTransaction find(Account account, String projectionUUID) + { + return account.getTransactions().stream() + .filter(LedgerBackedTransaction.class::isInstance) + .filter(item -> projectionUUID.equals(item.getUUID())) + .findFirst() + .orElseThrow(() -> new IllegalStateException( + "Ledger account projection was not materialized: " + projectionUUID)); //$NON-NLS-1$ + } + + private boolean isSupportedAccountOnlyType(AccountTransaction.Type type) + { + return switch (type) + { + case DEPOSIT, REMOVAL, INTEREST, INTEREST_CHARGE, FEES, FEES_REFUND, TAXES, TAX_REFUND -> true; + case BUY, SELL, TRANSFER_IN, TRANSFER_OUT, DIVIDENDS -> false; + }; + } + + private LedgerCreationUnits creationUnits(List units) + { + if (units.isEmpty()) + return LedgerCreationUnits.none(); + + var ledgerUnits = units.stream().map(this::creationUnit).toList(); + return LedgerCreationUnits.of(ledgerUnits.get(0), + ledgerUnits.subList(1, ledgerUnits.size()).toArray(LedgerCreationUnit[]::new)); + } + + private LedgerCreationUnit creationUnit(Transaction.Unit unit) + { + var forex = unit.getForex() != null ? LedgerForexAmount.of(unit.getForex(), unit.getExchangeRate()) + : LedgerForexAmount.none(); + + return switch (unit.getType()) + { + case FEE -> forex.isPresent() ? LedgerCreationUnit.fee(unit.getAmount(), forex) + : LedgerCreationUnit.fee(unit.getAmount()); + case TAX -> forex.isPresent() ? LedgerCreationUnit.tax(unit.getAmount(), forex) + : LedgerCreationUnit.tax(unit.getAmount()); + case GROSS_VALUE -> LedgerCreationUnit.grossValue(unit.getAmount(), forex); + }; + } + + private LedgerUnitPostingPatch unitPostingPatch(AccountTransaction transaction, List units) + { + Objects.requireNonNull(units); + + if (!(transaction instanceof LedgerBackedAccountTransaction ledgerTransaction)) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_CONVERT_006 + .message("Only ledger-backed account transactions can be updated")); //$NON-NLS-1$ + + var edits = new java.util.ArrayList(); + + ledgerTransaction.getLedgerEntry().getPostings().stream() + .filter(posting -> isUnitPosting(posting.getType())) + .map(LedgerPosting::getUUID) + .map(LedgerUnitPostingEdit::remove) + .forEach(edits::add); + + units.stream().map(this::unitPostingEdit).forEach(edits::add); + + if (edits.isEmpty()) + return LedgerUnitPostingPatch.none(); + + return LedgerUnitPostingPatch.of(edits.get(0), + edits.subList(1, edits.size()).toArray(LedgerUnitPostingEdit[]::new)); + } + + private LedgerUnitPostingEdit unitPostingEdit(Transaction.Unit unit) + { + var ledgerUnit = creationUnit(unit); + var forex = ledgerUnit.getForex(); + + return forex.isPresent() + ? LedgerUnitPostingEdit.add(ledgerUnit.getPostingType(), ledgerUnit.getAmount(), forex) + : LedgerUnitPostingEdit.add(ledgerUnit.getPostingType(), ledgerUnit.getAmount()); + } + + private boolean isUnitPosting(LedgerPostingType type) + { + return type == LedgerPostingType.FEE || type == LedgerPostingType.TAX || type == LedgerPostingType.GROSS_VALUE; + } + + private record NormalizedAccountOnlyFacts(long amount, List units) + { + } + + private AccountTransaction materializeAndFind(Account account, LedgerTransactionCreator.CreatedTransaction created) + { + var projectionUUID = created.getProjectionRefs().get(0).getUUID(); + + LedgerProjectionService.materialize(client); + + return account.getTransactions().stream() + .filter(LedgerBackedTransaction.class::isInstance) + .filter(transaction -> projectionUUID.equals(transaction.getUUID())) + .findFirst() + .orElseThrow(() -> new IllegalStateException( + "Ledger account projection was not materialized: " + projectionUUID)); //$NON-NLS-1$ + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransactionEdit.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransactionEdit.java new file mode 100644 index 0000000000..2c4fca4938 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransactionEdit.java @@ -0,0 +1,137 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.ledger.LedgerEntryMetadataPatch; +import name.abuchen.portfolio.model.ledger.LedgerFieldEdit; + +/** + * Carries account transaction data for Ledger compatibility creators, editors, or patchers. + * This is a compatibility-layer value object. Contributor code may pass it to Ledger write + * paths, but it does not mutate Ledger truth by itself. + */ +public final class LedgerAccountTransactionEdit +{ + private final LedgerEntryMetadataPatch metadata; + private final LedgerPostingPatch posting; + private final LedgerFieldEdit exDate; + private final LedgerUnitPostingPatch units; + + private LedgerAccountTransactionEdit(Builder builder) + { + this.metadata = builder.metadata; + this.posting = builder.postingBuilder.build(); + this.exDate = builder.exDate; + this.units = builder.units; + } + + public static Builder builder() + { + return new Builder(); + } + + LedgerEntryMetadataPatch getMetadata() + { + return metadata; + } + + LedgerPostingPatch getPosting() + { + return posting; + } + + LedgerFieldEdit getExDate() + { + return exDate; + } + + LedgerUnitPostingPatch getUnits() + { + return units; + } + + public static final class Builder + { + private LedgerEntryMetadataPatch metadata = LedgerEntryMetadataPatch.none(); + private final LedgerPostingPatch.Builder postingBuilder = LedgerPostingPatch.builder(); + private LedgerFieldEdit exDate = LedgerFieldEdit.omitted(); + private LedgerUnitPostingPatch units = LedgerUnitPostingPatch.none(); + + private Builder() + { + } + + public Builder metadata(LedgerEntryMetadataPatch metadata) + { + this.metadata = java.util.Objects.requireNonNull(metadata); + return this; + } + + public Builder amount(long amount) + { + postingBuilder.amount(amount); + return this; + } + + public Builder currency(String currency) + { + postingBuilder.currency(currency); + return this; + } + + public Builder forexAmount(Long forexAmount) + { + postingBuilder.forexAmount(forexAmount); + return this; + } + + public Builder forexCurrency(String forexCurrency) + { + postingBuilder.forexCurrency(forexCurrency); + return this; + } + + public Builder exchangeRate(BigDecimal exchangeRate) + { + postingBuilder.exchangeRate(exchangeRate); + return this; + } + + public Builder security(Security security) + { + postingBuilder.security(security); + return this; + } + + public Builder shares(long shares) + { + postingBuilder.shares(shares); + return this; + } + + public Builder exDate(LocalDateTime exDate) + { + this.exDate = LedgerFieldEdit.set(exDate); + return this; + } + + public Builder clearExDate() + { + this.exDate = LedgerFieldEdit.clear(); + return this; + } + + public Builder units(LedgerUnitPostingPatch units) + { + this.units = java.util.Objects.requireNonNull(units); + return this; + } + + public LedgerAccountTransactionEdit build() + { + return new LedgerAccountTransactionEdit(this); + } + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransactionEditor.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransactionEditor.java new file mode 100644 index 0000000000..c12ea09ce3 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransactionEditor.java @@ -0,0 +1,98 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.util.EnumSet; +import java.util.Objects; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerEntryEditSupport; +import name.abuchen.portfolio.model.ledger.LedgerEntryMetadataPatchHelper; +import name.abuchen.portfolio.model.ledger.LedgerFieldEdit; +import name.abuchen.portfolio.model.ledger.LedgerParameter; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedAccountTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionSupport; + +/** + * Updates same-shape ledger-backed account transaction transactions. + * This class is part of the Ledger compatibility layer. Contributor code should use it when + * an existing UI or import path needs to edit Ledger truth safely. + */ +public final class LedgerAccountTransactionEditor +{ + private static final EnumSet ACCOUNT_ONLY_TYPES = EnumSet.of(LedgerEntryType.DEPOSIT, + LedgerEntryType.REMOVAL, LedgerEntryType.INTEREST, LedgerEntryType.INTEREST_CHARGE, + LedgerEntryType.FEES, LedgerEntryType.FEES_REFUND, LedgerEntryType.TAXES, + LedgerEntryType.TAX_REFUND, LedgerEntryType.DIVIDENDS); + + private final LedgerUnitPostingUpdater unitPostingUpdater = new LedgerUnitPostingUpdater(); + + public void apply(LedgerBackedAccountTransaction transaction, LedgerAccountTransactionEdit edit) + { + Objects.requireNonNull(transaction); + Objects.requireNonNull(edit); + + var entry = transaction.getLedgerEntry(); + + if (!ACCOUNT_ONLY_TYPES.contains(entry.getType())) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_007 + .message("Unsupported account transaction edit for " + entry.getType())); //$NON-NLS-1$ + + var projectionUUID = transaction.getLedgerProjectionRef().getUUID(); + var postingUUID = LedgerProjectionSupport.primaryPosting(entry, transaction.getLedgerProjectionRef()).getUUID(); + + LedgerEntryEditSupport.applyValidated(entry, editedEntry -> applyEdit(editedEntry, edit, projectionUUID, + postingUUID)); + } + + public void validate(LedgerBackedAccountTransaction transaction, LedgerAccountTransactionEdit edit) + { + Objects.requireNonNull(transaction); + Objects.requireNonNull(edit); + + var entry = transaction.getLedgerEntry(); + + if (!ACCOUNT_ONLY_TYPES.contains(entry.getType())) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_008 + .message("Unsupported account transaction edit for " + entry.getType())); //$NON-NLS-1$ + + var projectionUUID = transaction.getLedgerProjectionRef().getUUID(); + var postingUUID = LedgerProjectionSupport.primaryPosting(entry, transaction.getLedgerProjectionRef()).getUUID(); + + LedgerEntryEditSupport.validatePatch(entry, editedEntry -> applyEdit(editedEntry, edit, projectionUUID, + postingUUID)); + } + + private void applyEdit(LedgerEntry editedEntry, LedgerAccountTransactionEdit edit, String projectionUUID, + String postingUUID) + { + LedgerEntryMetadataPatchHelper.apply(editedEntry, edit.getMetadata()); + edit.getPosting().applyTo(LedgerEntryEditSupport.postingByUUID(editedEntry, postingUUID)); + applyExDate(LedgerEntryEditSupport.postingByUUID(editedEntry, postingUUID), edit.getExDate()); + unitPostingUpdater.apply(editedEntry, edit.getUnits()); + ensureProjectionExists(editedEntry, projectionUUID); + } + + private void applyExDate(LedgerPosting posting, LedgerFieldEdit edit) + { + if (edit.isOmitted()) + return; + + posting.getParameters().stream() // + .filter(parameter -> parameter.getType() == LedgerParameterType.EX_DATE) // + .toList().forEach(posting::removeParameter); + + if (edit.isSet()) + posting.addParameter(LedgerParameter.ofLocalDateTime(LedgerParameterType.EX_DATE, + edit.getValue())); + } + + private void ensureProjectionExists(LedgerEntry entry, String projectionUUID) + { + if (entry.getProjectionRefs().stream().noneMatch(projection -> projection.getUUID().equals(projectionUUID))) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_004 + .message("Projection was removed by edit: " + projectionUUID)); //$NON-NLS-1$ + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferEdit.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferEdit.java new file mode 100644 index 0000000000..c6c28f0601 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferEdit.java @@ -0,0 +1,140 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.math.BigDecimal; + +import name.abuchen.portfolio.model.ledger.LedgerEntryMetadataPatch; + +/** + * Carries account transfer data for Ledger compatibility creators, editors, or patchers. + * This is a compatibility-layer value object. Contributor code may pass it to Ledger write + * paths, but it does not mutate Ledger truth by itself. + */ +public final class LedgerAccountTransferEdit +{ + private final LedgerEntryMetadataPatch metadata; + private final LedgerPostingPatch sourcePosting; + private final LedgerPostingPatch targetPosting; + private final LedgerUnitPostingPatch units; + + private LedgerAccountTransferEdit(Builder builder) + { + this.metadata = builder.metadata; + this.sourcePosting = builder.sourcePostingBuilder.build(); + this.targetPosting = builder.targetPostingBuilder.build(); + this.units = builder.units; + } + + public static Builder builder() + { + return new Builder(); + } + + LedgerEntryMetadataPatch getMetadata() + { + return metadata; + } + + LedgerPostingPatch getSourcePosting() + { + return sourcePosting; + } + + LedgerPostingPatch getTargetPosting() + { + return targetPosting; + } + + LedgerUnitPostingPatch getUnits() + { + return units; + } + + public static final class Builder + { + private LedgerEntryMetadataPatch metadata = LedgerEntryMetadataPatch.none(); + private final LedgerPostingPatch.Builder sourcePostingBuilder = LedgerPostingPatch.builder(); + private final LedgerPostingPatch.Builder targetPostingBuilder = LedgerPostingPatch.builder(); + private LedgerUnitPostingPatch units = LedgerUnitPostingPatch.none(); + + private Builder() + { + } + + public Builder metadata(LedgerEntryMetadataPatch metadata) + { + this.metadata = java.util.Objects.requireNonNull(metadata); + return this; + } + + public Builder sourceAmount(long amount) + { + sourcePostingBuilder.amount(amount); + return this; + } + + public Builder sourceCurrency(String currency) + { + sourcePostingBuilder.currency(currency); + return this; + } + + public Builder sourceForexAmount(Long forexAmount) + { + sourcePostingBuilder.forexAmount(forexAmount); + return this; + } + + public Builder sourceForexCurrency(String forexCurrency) + { + sourcePostingBuilder.forexCurrency(forexCurrency); + return this; + } + + public Builder sourceExchangeRate(BigDecimal exchangeRate) + { + sourcePostingBuilder.exchangeRate(exchangeRate); + return this; + } + + public Builder targetAmount(long amount) + { + targetPostingBuilder.amount(amount); + return this; + } + + public Builder targetCurrency(String currency) + { + targetPostingBuilder.currency(currency); + return this; + } + + public Builder targetForexAmount(Long forexAmount) + { + targetPostingBuilder.forexAmount(forexAmount); + return this; + } + + public Builder targetForexCurrency(String forexCurrency) + { + targetPostingBuilder.forexCurrency(forexCurrency); + return this; + } + + public Builder targetExchangeRate(BigDecimal exchangeRate) + { + targetPostingBuilder.exchangeRate(exchangeRate); + return this; + } + + public Builder units(LedgerUnitPostingPatch units) + { + this.units = java.util.Objects.requireNonNull(units); + return this; + } + + public LedgerAccountTransferEdit build() + { + return new LedgerAccountTransferEdit(this); + } + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferEditor.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferEditor.java new file mode 100644 index 0000000000..5516a1fbbc --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferEditor.java @@ -0,0 +1,105 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.util.Objects; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerEntryEditSupport; +import name.abuchen.portfolio.model.ledger.LedgerEntryMetadataPatchHelper; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedAccountTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionSupport; + +/** + * Updates same-shape ledger-backed account transfer transactions. + * This class is part of the Ledger compatibility layer. Contributor code should use it when + * an existing UI or import path needs to edit Ledger truth safely. + */ +public final class LedgerAccountTransferEditor +{ + private final LedgerUnitPostingUpdater unitPostingUpdater = new LedgerUnitPostingUpdater(); + + public void apply(LedgerBackedAccountTransaction transaction, LedgerAccountTransferEdit edit) + { + apply(transaction.getLedgerEntry(), edit); + } + + public void apply(LedgerEntry entry, LedgerAccountTransferEdit edit) + { + Objects.requireNonNull(entry); + Objects.requireNonNull(edit); + + if (entry.getType() != LedgerEntryType.CASH_TRANSFER) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_009 + .message("Unsupported account transfer edit for " + entry.getType())); //$NON-NLS-1$ + + var sourceProjection = projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT); + var targetProjection = projection(entry, LedgerProjectionRole.TARGET_ACCOUNT); + var sourcePostingUUID = LedgerProjectionSupport.primaryPosting(entry, sourceProjection).getUUID(); + var targetPostingUUID = LedgerProjectionSupport.primaryPosting(entry, targetProjection).getUUID(); + var sourceAccount = sourceProjection.getAccount(); + var targetAccount = targetProjection.getAccount(); + + LedgerEntryEditSupport.applyValidated(entry, editedEntry -> applyEdit(editedEntry, edit, + sourceProjection.getUUID(), sourceAccount, targetProjection.getUUID(), targetAccount, + sourcePostingUUID, targetPostingUUID)); + } + + public void validate(LedgerEntry entry, LedgerAccountTransferEdit edit) + { + Objects.requireNonNull(entry); + Objects.requireNonNull(edit); + + if (entry.getType() != LedgerEntryType.CASH_TRANSFER) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_010 + .message("Unsupported account transfer edit for " + entry.getType())); //$NON-NLS-1$ + + var sourceProjection = projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT); + var targetProjection = projection(entry, LedgerProjectionRole.TARGET_ACCOUNT); + var sourcePostingUUID = LedgerProjectionSupport.primaryPosting(entry, sourceProjection).getUUID(); + var targetPostingUUID = LedgerProjectionSupport.primaryPosting(entry, targetProjection).getUUID(); + var sourceAccount = sourceProjection.getAccount(); + var targetAccount = targetProjection.getAccount(); + + LedgerEntryEditSupport.validatePatch(entry, editedEntry -> applyEdit(editedEntry, edit, + sourceProjection.getUUID(), sourceAccount, targetProjection.getUUID(), targetAccount, + sourcePostingUUID, targetPostingUUID)); + } + + private void applyEdit(LedgerEntry editedEntry, LedgerAccountTransferEdit edit, String sourceProjectionUUID, + name.abuchen.portfolio.model.Account sourceAccount, String targetProjectionUUID, + name.abuchen.portfolio.model.Account targetAccount, String sourcePostingUUID, + String targetPostingUUID) + { + LedgerEntryMetadataPatchHelper.apply(editedEntry, edit.getMetadata()); + edit.getSourcePosting().applyTo(LedgerEntryEditSupport.postingByUUID(editedEntry, sourcePostingUUID)); + edit.getTargetPosting().applyTo(LedgerEntryEditSupport.postingByUUID(editedEntry, targetPostingUUID)); + unitPostingUpdater.apply(editedEntry, edit.getUnits()); + ensureOwners(editedEntry, sourceProjectionUUID, sourceAccount, targetProjectionUUID, targetAccount); + } + + private LedgerProjectionRef projection(LedgerEntry entry, LedgerProjectionRole role) + { + return entry.getProjectionRefs().stream() // + .filter(projection -> projection.getRole() == role) // + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Projection not found: " + role)); //$NON-NLS-1$ + } + + private void ensureOwners(LedgerEntry entry, String sourceProjectionUUID, name.abuchen.portfolio.model.Account source, + String targetProjectionUUID, name.abuchen.portfolio.model.Account target) + { + var sourceProjection = entry.getProjectionRefs().stream() + .filter(projection -> projection.getUUID().equals(sourceProjectionUUID)).findFirst() + .orElseThrow(); + var targetProjection = entry.getProjectionRefs().stream() + .filter(projection -> projection.getUUID().equals(targetProjectionUUID)).findFirst() + .orElseThrow(); + + if (sourceProjection.getAccount() != source || targetProjection.getAccount() != target) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_005 + .message("Account transfer owner changes are not supported")); //$NON-NLS-1$ + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferTransactionCreator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferTransactionCreator.java new file mode 100644 index 0000000000..ea4c7cebac --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferTransactionCreator.java @@ -0,0 +1,245 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Objects; +import java.util.Optional; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.AccountTransferEntry; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.ledger.LedgerEntryMetadataPatch; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.LedgerTransactionMetadata; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedAccountTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionService; +import name.abuchen.portfolio.money.ExchangeRate; +import name.abuchen.portfolio.money.Money; + +/** + * Creates and updates ledger-backed account transfer transactions. + * This class is part of the Ledger compatibility layer for existing UI, import, and action + * code. Contributor code should use it instead of mutating projected legacy transactions. + */ +public final class LedgerAccountTransferTransactionCreator +{ + private final Client client; + + public LedgerAccountTransferTransactionCreator(Client client) + { + this.client = Objects.requireNonNull(client); + } + + public AccountTransferEntry create(Account sourceAccount, Account targetAccount, LocalDateTime dateTime, + long sourceAmount, String sourceCurrencyCode, long targetAmount, String targetCurrencyCode, + Money sourceForexAmount, BigDecimal sourceExchangeRate, String note, String source) + { + return create(sourceAccount, targetAccount, dateTime, sourceAmount, sourceCurrencyCode, targetAmount, + targetCurrencyCode, forex(sourceForexAmount, sourceExchangeRate), LedgerForexAmount.none(), + LedgerUnitPostingPatch.none(), note, source); + } + + public AccountTransferEntry create(Account sourceAccount, Account targetAccount, LocalDateTime dateTime, + long sourceAmount, String sourceCurrencyCode, long targetAmount, String targetCurrencyCode, + LedgerForexAmount sourceForex, LedgerForexAmount targetForex, LedgerUnitPostingPatch units, + String note, String source) + { + Objects.requireNonNull(sourceAccount); + Objects.requireNonNull(targetAccount); + Objects.requireNonNull(dateTime); + Objects.requireNonNull(sourceCurrencyCode); + Objects.requireNonNull(targetCurrencyCode); + Objects.requireNonNull(sourceForex); + Objects.requireNonNull(targetForex); + Objects.requireNonNull(units); + + var metadata = LedgerTransactionMetadata.of(dateTime).withNote(note).withSource(source); + var creator = new LedgerTransactionCreator(client); + var entry = creator.createAccountTransferEntry(metadata, + LedgerCashTransferLeg.of(sourceAccount, Money.of(sourceCurrencyCode, sourceAmount), sourceForex), + LedgerCashTransferLeg.of(targetAccount, Money.of(targetCurrencyCode, targetAmount), targetForex)); + + new LedgerUnitPostingUpdater().apply(entry, units); + var created = creator.add(entry); + + return materializeAndWrap(sourceAccount, targetAccount, created); + } + + public boolean isLedgerBacked(AccountTransferEntry entry) + { + return entry.getSourceTransaction() instanceof LedgerBackedTransaction + || entry.getTargetTransaction() instanceof LedgerBackedTransaction; + } + + public boolean canUpdate(AccountTransferEntry entry) + { + return entry.getSourceTransaction() instanceof LedgerBackedAccountTransaction + && entry.getTargetTransaction() instanceof LedgerBackedAccountTransaction + && entry.getSourceTransaction().getType() == AccountTransaction.Type.TRANSFER_OUT + && entry.getTargetTransaction().getType() == AccountTransaction.Type.TRANSFER_IN; + } + + public Optional getSourceExchangeRate(AccountTransferEntry entry) + { + Objects.requireNonNull(entry); + + if (!(entry.getSourceTransaction() instanceof LedgerBackedAccountTransaction ledgerTransaction)) + return Optional.empty(); + + var sourceAccount = entry.getSourceAccount(); + var targetAccount = entry.getTargetAccount(); + var projectionRef = ledgerTransaction.getLedgerProjectionRef(); + var postings = ledgerTransaction.getLedgerEntry().getPostings().stream() + .filter(posting -> posting.getAccount() == projectionRef.getAccount()).toList(); + + if (postings.isEmpty()) + return Optional.empty(); + + var posting = projectionRef.getRole() == LedgerProjectionRole.TARGET_ACCOUNT ? postings.get(postings.size() - 1) + : postings.get(0); + + if (posting.getForexAmount() == null || posting.getForexCurrency() == null || posting.getExchangeRate() == null + || !posting.getCurrency().equals(sourceAccount.getCurrencyCode()) + || !posting.getForexCurrency().equals(targetAccount.getCurrencyCode())) + return Optional.empty(); + + return Optional.of(ExchangeRate.inverse(posting.getExchangeRate())); + } + + public AccountTransferEntry update(AccountTransferEntry entry, Account sourceAccount, Account targetAccount, + LocalDateTime dateTime, long sourceAmount, String sourceCurrencyCode, long targetAmount, + String targetCurrencyCode, Money sourceForexAmount, BigDecimal sourceExchangeRate, String note, + String source) + { + return update(entry, sourceAccount, targetAccount, dateTime, sourceAmount, sourceCurrencyCode, targetAmount, + targetCurrencyCode, sourceForexAmount != null ? forex(sourceForexAmount, sourceExchangeRate) + : null, + null, LedgerUnitPostingPatch.none(), note, source); + } + + public AccountTransferEntry update(AccountTransferEntry entry, Account sourceAccount, Account targetAccount, + LocalDateTime dateTime, long sourceAmount, String sourceCurrencyCode, long targetAmount, + String targetCurrencyCode, LedgerForexAmount sourceForex, LedgerForexAmount targetForex, + LedgerUnitPostingPatch units, String note, String source) + { + Objects.requireNonNull(entry); + Objects.requireNonNull(sourceAccount); + Objects.requireNonNull(targetAccount); + Objects.requireNonNull(dateTime); + Objects.requireNonNull(sourceCurrencyCode); + Objects.requireNonNull(targetCurrencyCode); + Objects.requireNonNull(units); + + if (!canUpdate(entry)) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_CONVERT_018.message("Only ledger-backed account transfers can be updated")); //$NON-NLS-1$ + + var sourceTransaction = (LedgerBackedAccountTransaction) entry.getSourceTransaction(); + var targetTransaction = (LedgerBackedAccountTransaction) entry.getTargetTransaction(); + + var ledgerEntry = sourceTransaction.getLedgerEntry(); + var sourceProjectionUUID = sourceTransaction.getLedgerProjectionRef().getUUID(); + var targetProjectionUUID = targetTransaction.getLedgerProjectionRef().getUUID(); + var ownerPatchHelper = new LedgerOwnerPatchHelper(client); + + var editBuilder = LedgerAccountTransferEdit.builder() + .metadata(LedgerEntryMetadataPatch.builder().dateTime(dateTime).note(note).source(source) + .build()) + .sourceAmount(sourceAmount) + .sourceCurrency(sourceCurrencyCode) + .targetAmount(targetAmount) + .targetCurrency(targetCurrencyCode) + .units(units); + + applySourceForex(editBuilder, sourceForex); + applyTargetForex(editBuilder, targetForex); + + var edit = editBuilder.build(); + var editor = new LedgerAccountTransferEditor(); + + if (sourceTransaction.getLedgerProjectionRef().getAccount() != sourceAccount + || targetTransaction.getLedgerProjectionRef().getAccount() != targetAccount) + editor.validate(ledgerEntry, edit); + + if (sourceTransaction.getLedgerProjectionRef().getAccount() != sourceAccount) + ownerPatchHelper.moveAccountTransferSource(ledgerEntry, sourceAccount); + + if (targetTransaction.getLedgerProjectionRef().getAccount() != targetAccount) + ownerPatchHelper.moveAccountTransferTarget(ledgerEntry, targetAccount); + + sourceTransaction = (LedgerBackedAccountTransaction) find(sourceAccount, sourceProjectionUUID); + targetTransaction = (LedgerBackedAccountTransaction) find(targetAccount, targetProjectionUUID); + entry = (AccountTransferEntry) sourceTransaction.getCrossEntry(); + + editor.apply(sourceTransaction, edit); + + return entry; + } + + private void applySourceForex(LedgerAccountTransferEdit.Builder builder, LedgerForexAmount forex) + { + if (forex == null) + return; + + if (forex.isPresent()) + builder.sourceForexAmount(forex.getForexAmount().getAmount()) + .sourceForexCurrency(forex.getForexAmount().getCurrencyCode()) + .sourceExchangeRate(forex.getExchangeRate()); + else + builder.sourceForexAmount(null).sourceForexCurrency(null).sourceExchangeRate(null); + } + + private void applyTargetForex(LedgerAccountTransferEdit.Builder builder, LedgerForexAmount forex) + { + if (forex == null) + return; + + if (forex.isPresent()) + builder.targetForexAmount(forex.getForexAmount().getAmount()) + .targetForexCurrency(forex.getForexAmount().getCurrencyCode()) + .targetExchangeRate(forex.getExchangeRate()); + else + builder.targetForexAmount(null).targetForexCurrency(null).targetExchangeRate(null); + } + + private LedgerForexAmount forex(Money forexAmount, BigDecimal exchangeRate) + { + return forexAmount != null ? LedgerForexAmount.of(forexAmount, Objects.requireNonNull(exchangeRate)) + : LedgerForexAmount.none(); + } + + private AccountTransferEntry materializeAndWrap(Account sourceAccount, Account targetAccount, + LedgerTransactionCreator.CreatedTransaction created) + { + var sourceProjectionUUID = created.getProjectionRefs().stream() + .filter(projection -> projection.getRole() == LedgerProjectionRole.SOURCE_ACCOUNT) + .findFirst().orElseThrow().getUUID(); + var targetProjectionUUID = created.getProjectionRefs().stream() + .filter(projection -> projection.getRole() == LedgerProjectionRole.TARGET_ACCOUNT) + .findFirst().orElseThrow().getUUID(); + + LedgerProjectionService.materialize(client); + + return wrap(sourceAccount, find(sourceAccount, sourceProjectionUUID), targetAccount, + find(targetAccount, targetProjectionUUID)); + } + + private AccountTransaction find(Account account, String projectionUUID) + { + return account.getTransactions().stream() + .filter(LedgerBackedTransaction.class::isInstance) + .filter(transaction -> projectionUUID.equals(transaction.getUUID())) + .findFirst() + .orElseThrow(() -> new IllegalStateException( + "Ledger account transfer projection was not materialized: " //$NON-NLS-1$ + + projectionUUID)); + } + + private AccountTransferEntry wrap(Account sourceAccount, AccountTransaction sourceTransaction, Account targetAccount, + AccountTransaction targetTransaction) + { + return AccountTransferEntry.readOnly(sourceAccount, sourceTransaction, targetAccount, targetTransaction); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellEdit.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellEdit.java new file mode 100644 index 0000000000..be01fe1f15 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellEdit.java @@ -0,0 +1,152 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.math.BigDecimal; + +import name.abuchen.portfolio.model.ledger.LedgerEntryMetadataPatch; + +/** + * Carries buy/sell data for Ledger compatibility creators, editors, or patchers. + * This is a compatibility-layer value object. Contributor code may pass it to Ledger write + * paths, but it does not mutate Ledger truth by itself. + */ +public final class LedgerBuySellEdit +{ + private final LedgerEntryMetadataPatch metadata; + private final LedgerPostingPatch cashPosting; + private final LedgerPostingPatch securityPosting; + private final LedgerUnitPostingPatch units; + + private LedgerBuySellEdit(Builder builder) + { + this.metadata = builder.metadata; + this.cashPosting = builder.cashPostingBuilder.build(); + this.securityPosting = builder.securityPostingBuilder.build(); + this.units = builder.units; + } + + public static Builder builder() + { + return new Builder(); + } + + LedgerEntryMetadataPatch getMetadata() + { + return metadata; + } + + LedgerPostingPatch getCashPosting() + { + return cashPosting; + } + + LedgerPostingPatch getSecurityPosting() + { + return securityPosting; + } + + LedgerUnitPostingPatch getUnits() + { + return units; + } + + public static final class Builder + { + private LedgerEntryMetadataPatch metadata = LedgerEntryMetadataPatch.none(); + private final LedgerPostingPatch.Builder cashPostingBuilder = LedgerPostingPatch.builder(); + private final LedgerPostingPatch.Builder securityPostingBuilder = LedgerPostingPatch.builder(); + private LedgerUnitPostingPatch units = LedgerUnitPostingPatch.none(); + + private Builder() + { + } + + public Builder metadata(LedgerEntryMetadataPatch metadata) + { + this.metadata = java.util.Objects.requireNonNull(metadata); + return this; + } + + public Builder cashAmount(long amount) + { + cashPostingBuilder.amount(amount); + return this; + } + + public Builder cashCurrency(String currency) + { + cashPostingBuilder.currency(currency); + return this; + } + + public Builder cashForexAmount(Long forexAmount) + { + cashPostingBuilder.forexAmount(forexAmount); + return this; + } + + public Builder cashForexCurrency(String forexCurrency) + { + cashPostingBuilder.forexCurrency(forexCurrency); + return this; + } + + public Builder cashExchangeRate(BigDecimal exchangeRate) + { + cashPostingBuilder.exchangeRate(exchangeRate); + return this; + } + + public Builder securityAmount(long amount) + { + securityPostingBuilder.amount(amount); + return this; + } + + public Builder securityCurrency(String currency) + { + securityPostingBuilder.currency(currency); + return this; + } + + public Builder securityForexAmount(Long forexAmount) + { + securityPostingBuilder.forexAmount(forexAmount); + return this; + } + + public Builder securityForexCurrency(String forexCurrency) + { + securityPostingBuilder.forexCurrency(forexCurrency); + return this; + } + + public Builder securityExchangeRate(BigDecimal exchangeRate) + { + securityPostingBuilder.exchangeRate(exchangeRate); + return this; + } + + public Builder security(name.abuchen.portfolio.model.Security security) + { + securityPostingBuilder.security(security); + return this; + } + + public Builder shares(long shares) + { + securityPostingBuilder.shares(shares); + return this; + } + + public Builder units(LedgerUnitPostingPatch units) + { + this.units = java.util.Objects.requireNonNull(units); + return this; + } + + public LedgerBuySellEdit build() + { + return new LedgerBuySellEdit(this); + } + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellEditor.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellEditor.java new file mode 100644 index 0000000000..e08f3ffe95 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellEditor.java @@ -0,0 +1,98 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.util.Objects; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerEntryEditSupport; +import name.abuchen.portfolio.model.ledger.LedgerEntryMetadataPatchHelper; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedAccountTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedPortfolioTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionSupport; + +/** + * Updates same-shape ledger-backed buy/sell transactions. + * This class is part of the Ledger compatibility layer. Contributor code should use it when + * an existing UI or import path needs to edit Ledger truth safely. + */ +public final class LedgerBuySellEditor +{ + private final LedgerUnitPostingUpdater unitPostingUpdater = new LedgerUnitPostingUpdater(); + + public void apply(LedgerBackedAccountTransaction transaction, LedgerBuySellEdit edit) + { + apply(transaction.getLedgerEntry(), edit); + } + + public void apply(LedgerBackedPortfolioTransaction transaction, LedgerBuySellEdit edit) + { + apply(transaction.getLedgerEntry(), edit); + } + + public void apply(LedgerEntry entry, LedgerBuySellEdit edit) + { + Objects.requireNonNull(entry); + Objects.requireNonNull(edit); + + if (entry.getType() != LedgerEntryType.BUY && entry.getType() != LedgerEntryType.SELL) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_025 + .message("Unsupported buy/sell edit for " + entry.getType())); //$NON-NLS-1$ + + var accountProjection = projection(entry, LedgerProjectionRole.ACCOUNT); + var portfolioProjection = projection(entry, LedgerProjectionRole.PORTFOLIO); + var cashPostingUUID = LedgerProjectionSupport.primaryPosting(entry, accountProjection).getUUID(); + var securityPostingUUID = LedgerProjectionSupport.primaryPosting(entry, portfolioProjection).getUUID(); + + LedgerEntryEditSupport.applyValidated(entry, editedEntry -> applyEdit(editedEntry, edit, + accountProjection.getUUID(), portfolioProjection.getUUID(), cashPostingUUID, + securityPostingUUID)); + } + + public void validate(LedgerEntry entry, LedgerBuySellEdit edit) + { + Objects.requireNonNull(entry); + Objects.requireNonNull(edit); + + if (entry.getType() != LedgerEntryType.BUY && entry.getType() != LedgerEntryType.SELL) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_026 + .message("Unsupported buy/sell edit for " + entry.getType())); //$NON-NLS-1$ + + var accountProjection = projection(entry, LedgerProjectionRole.ACCOUNT); + var portfolioProjection = projection(entry, LedgerProjectionRole.PORTFOLIO); + var cashPostingUUID = LedgerProjectionSupport.primaryPosting(entry, accountProjection).getUUID(); + var securityPostingUUID = LedgerProjectionSupport.primaryPosting(entry, portfolioProjection).getUUID(); + + LedgerEntryEditSupport.validatePatch(entry, editedEntry -> applyEdit(editedEntry, edit, + accountProjection.getUUID(), portfolioProjection.getUUID(), cashPostingUUID, + securityPostingUUID)); + } + + private void applyEdit(LedgerEntry editedEntry, LedgerBuySellEdit edit, String accountProjectionUUID, + String portfolioProjectionUUID, String cashPostingUUID, String securityPostingUUID) + { + LedgerEntryMetadataPatchHelper.apply(editedEntry, edit.getMetadata()); + edit.getCashPosting().applyTo(LedgerEntryEditSupport.postingByUUID(editedEntry, cashPostingUUID)); + edit.getSecurityPosting().applyTo(LedgerEntryEditSupport.postingByUUID(editedEntry, securityPostingUUID)); + unitPostingUpdater.apply(editedEntry, edit.getUnits()); + ensureProjectionExists(editedEntry, accountProjectionUUID); + ensureProjectionExists(editedEntry, portfolioProjectionUUID); + } + + private LedgerProjectionRef projection(LedgerEntry entry, LedgerProjectionRole role) + { + return entry.getProjectionRefs().stream() // + .filter(projection -> projection.getRole() == role) // + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Projection not found: " + role)); //$NON-NLS-1$ + } + + private void ensureProjectionExists(LedgerEntry entry, String projectionUUID) + { + if (entry.getProjectionRefs().stream().noneMatch(projection -> projection.getUUID().equals(projectionUUID))) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_027 + .message("Projection was removed by edit: " + projectionUUID)); //$NON-NLS-1$ + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellTransactionCreator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellTransactionCreator.java new file mode 100644 index 0000000000..57a60df9b1 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellTransactionCreator.java @@ -0,0 +1,318 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Objects; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.BuySellEntry; +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.model.ledger.LedgerEntryMetadataPatch; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.LedgerTransactionMetadata; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedAccountTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedPortfolioTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionService; +import name.abuchen.portfolio.money.Money; + +/** + * Creates and updates ledger-backed buy/sell transactions. + * This class is part of the Ledger compatibility layer for existing UI, import, and action + * code. Contributor code should use it instead of mutating projected legacy transactions. + */ +public final class LedgerBuySellTransactionCreator +{ + private final Client client; + + public LedgerBuySellTransactionCreator(Client client) + { + this.client = Objects.requireNonNull(client); + } + + public BuySellEntry create(Portfolio portfolio, Account account, PortfolioTransaction.Type type, + LocalDateTime dateTime, long amount, String currencyCode, Security security, long shares, + List units, String note, String source) + { + return create(portfolio, account, type, dateTime, amount, currencyCode, security, shares, + LedgerForexAmount.none(), LedgerForexAmount.none(), units, note, source); + } + + public BuySellEntry create(Portfolio portfolio, Account account, PortfolioTransaction.Type type, + LocalDateTime dateTime, long amount, String currencyCode, Security security, long shares, + LedgerForexAmount cashForex, LedgerForexAmount securityForex, List units, + String note, String source) + { + Objects.requireNonNull(portfolio); + Objects.requireNonNull(account); + Objects.requireNonNull(type); + Objects.requireNonNull(dateTime); + Objects.requireNonNull(currencyCode); + Objects.requireNonNull(security); + Objects.requireNonNull(cashForex); + Objects.requireNonNull(securityForex); + Objects.requireNonNull(units); + + var metadata = LedgerTransactionMetadata.of(dateTime).withNote(note).withSource(source); + var value = Money.of(currencyCode, amount); + var cashLeg = LedgerAccountCashLeg.of(account, value, cashForex); + var securityLeg = LedgerPortfolioSecurityLeg.of(portfolio, LedgerSecurityQuantity.of(security, shares), value, + securityForex); + var creator = new LedgerTransactionCreator(client); + var created = switch (type) + { + case BUY -> creator.createBuy(metadata, cashLeg, securityLeg, creationUnits(units)); + case SELL -> creator.createSell(metadata, cashLeg, securityLeg, creationUnits(units)); + case DELIVERY_INBOUND, DELIVERY_OUTBOUND, TRANSFER_IN, TRANSFER_OUT -> throw new UnsupportedOperationException( + LedgerDiagnosticCode.LEDGER_CONVERT_030.message("Unsupported buy/sell ledger production type: " + type)); //$NON-NLS-1$ + }; + + return materializeAndWrap(portfolio, account, created); + } + + public boolean isLedgerBacked(BuySellEntry entry) + { + return entry.getAccountTransaction() instanceof LedgerBackedTransaction + || entry.getPortfolioTransaction() instanceof LedgerBackedTransaction; + } + + public boolean canUpdate(BuySellEntry entry) + { + return entry.getAccountTransaction() instanceof LedgerBackedAccountTransaction + && entry.getPortfolioTransaction() instanceof LedgerBackedPortfolioTransaction + && isBuySell(entry.getAccountTransaction().getType()) + && isBuySell(entry.getPortfolioTransaction().getType()) + && entry.getAccountTransaction().getType().name() + .equals(entry.getPortfolioTransaction().getType().name()); + } + + public BuySellEntry update(BuySellEntry entry, Portfolio portfolio, Account account, PortfolioTransaction.Type type, + LocalDateTime dateTime, long amount, String currencyCode, Security security, long shares, + List units, String note, String source) + { + return update(entry, portfolio, account, type, dateTime, amount, currencyCode, security, shares, null, null, + unitPostingPatch(entry, units), note, source); + } + + public BuySellEntry update(BuySellEntry entry, Portfolio portfolio, Account account, PortfolioTransaction.Type type, + LocalDateTime dateTime, long amount, String currencyCode, Security security, long shares, + LedgerForexAmount cashForex, LedgerForexAmount securityForex, LedgerUnitPostingPatch units, + String note, String source) + { + Objects.requireNonNull(entry); + Objects.requireNonNull(portfolio); + Objects.requireNonNull(account); + Objects.requireNonNull(type); + Objects.requireNonNull(dateTime); + Objects.requireNonNull(currencyCode); + Objects.requireNonNull(security); + Objects.requireNonNull(units); + + if (!canUpdate(entry)) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_CONVERT_031 + .message("Only ledger-backed buy/sell transactions can be updated")); //$NON-NLS-1$ + + var accountTransaction = (LedgerBackedAccountTransaction) entry.getAccountTransaction(); + var portfolioTransaction = (LedgerBackedPortfolioTransaction) entry.getPortfolioTransaction(); + + if (portfolioTransaction.getType() != type || !accountTransaction.getType().name().equals(type.name())) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_CONVERT_032.message("Changing buy/sell type is not supported")); //$NON-NLS-1$ + + var ledgerEntry = accountTransaction.getLedgerEntry(); + var accountProjectionUUID = accountTransaction.getLedgerProjectionRef().getUUID(); + var portfolioProjectionUUID = portfolioTransaction.getLedgerProjectionRef().getUUID(); + var ownerPatchHelper = new LedgerOwnerPatchHelper(client); + + var editBuilder = LedgerBuySellEdit.builder() + .metadata(LedgerEntryMetadataPatch.builder().dateTime(dateTime).note(note).source(source) + .build()) + .cashAmount(amount) + .cashCurrency(currencyCode) + .securityAmount(amount) + .securityCurrency(currencyCode) + .security(security) + .shares(shares) + .units(units); + + applyCashForex(editBuilder, cashForex); + applySecurityForex(editBuilder, securityForex); + + var edit = editBuilder.build(); + var editor = new LedgerBuySellEditor(); + + if (accountTransaction.getLedgerProjectionRef().getAccount() != account + || portfolioTransaction.getLedgerProjectionRef().getPortfolio() != portfolio) + editor.validate(ledgerEntry, edit); + + if (accountTransaction.getLedgerProjectionRef().getAccount() != account) + ownerPatchHelper.moveBuySellAccountSide(ledgerEntry, account); + + if (portfolioTransaction.getLedgerProjectionRef().getPortfolio() != portfolio) + ownerPatchHelper.moveBuySellPortfolioSide(ledgerEntry, portfolio); + + accountTransaction = (LedgerBackedAccountTransaction) find(account, accountProjectionUUID); + portfolioTransaction = (LedgerBackedPortfolioTransaction) find(portfolio, portfolioProjectionUUID); + entry = (BuySellEntry) accountTransaction.getCrossEntry(); + + editor.apply(portfolioTransaction, edit); + + return entry; + } + + private void applyCashForex(LedgerBuySellEdit.Builder builder, LedgerForexAmount forex) + { + if (forex == null) + return; + + if (forex.isPresent()) + builder.cashForexAmount(forex.getForexAmount().getAmount()) + .cashForexCurrency(forex.getForexAmount().getCurrencyCode()) + .cashExchangeRate(forex.getExchangeRate()); + else + builder.cashForexAmount(null).cashForexCurrency(null).cashExchangeRate(null); + } + + private void applySecurityForex(LedgerBuySellEdit.Builder builder, LedgerForexAmount forex) + { + if (forex == null) + return; + + if (forex.isPresent()) + builder.securityForexAmount(forex.getForexAmount().getAmount()) + .securityForexCurrency(forex.getForexAmount().getCurrencyCode()) + .securityExchangeRate(forex.getExchangeRate()); + else + builder.securityForexAmount(null).securityForexCurrency(null).securityExchangeRate(null); + } + + private boolean isBuySell(AccountTransaction.Type type) + { + return type == AccountTransaction.Type.BUY || type == AccountTransaction.Type.SELL; + } + + private boolean isBuySell(PortfolioTransaction.Type type) + { + return type == PortfolioTransaction.Type.BUY || type == PortfolioTransaction.Type.SELL; + } + + private LedgerCreationUnits creationUnits(List units) + { + if (units.isEmpty()) + return LedgerCreationUnits.none(); + + var ledgerUnits = units.stream().map(this::creationUnit).toList(); + return LedgerCreationUnits.of(ledgerUnits.get(0), + ledgerUnits.subList(1, ledgerUnits.size()).toArray(LedgerCreationUnit[]::new)); + } + + private LedgerCreationUnit creationUnit(Transaction.Unit unit) + { + var forex = unit.getForex() != null ? LedgerForexAmount.of(unit.getForex(), unit.getExchangeRate()) + : LedgerForexAmount.none(); + + return switch (unit.getType()) + { + case FEE -> forex.isPresent() ? LedgerCreationUnit.fee(unit.getAmount(), forex) + : LedgerCreationUnit.fee(unit.getAmount()); + case TAX -> forex.isPresent() ? LedgerCreationUnit.tax(unit.getAmount(), forex) + : LedgerCreationUnit.tax(unit.getAmount()); + case GROSS_VALUE -> LedgerCreationUnit.grossValue(unit.getAmount(), forex); + }; + } + + private LedgerUnitPostingPatch unitPostingPatch(LedgerBackedPortfolioTransaction transaction, + List units) + { + var edits = new java.util.ArrayList(); + + transaction.getLedgerEntry().getPostings().stream() + .filter(posting -> isUnitPosting(posting.getType())) + .map(LedgerPosting::getUUID) + .map(LedgerUnitPostingEdit::remove) + .forEach(edits::add); + + units.stream().map(this::unitPostingEdit).forEach(edits::add); + + if (edits.isEmpty()) + return LedgerUnitPostingPatch.none(); + + return LedgerUnitPostingPatch.of(edits.get(0), + edits.subList(1, edits.size()).toArray(LedgerUnitPostingEdit[]::new)); + } + + private LedgerUnitPostingPatch unitPostingPatch(BuySellEntry entry, List units) + { + Objects.requireNonNull(units); + + if (!(entry.getPortfolioTransaction() instanceof LedgerBackedPortfolioTransaction ledgerTransaction)) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_CONVERT_033 + .message("Only ledger-backed buy/sell transactions can be updated")); //$NON-NLS-1$ + + return unitPostingPatch(ledgerTransaction, units); + } + + private LedgerUnitPostingEdit unitPostingEdit(Transaction.Unit unit) + { + var ledgerUnit = creationUnit(unit); + var forex = ledgerUnit.getForex(); + + return forex.isPresent() + ? LedgerUnitPostingEdit.add(ledgerUnit.getPostingType(), ledgerUnit.getAmount(), forex) + : LedgerUnitPostingEdit.add(ledgerUnit.getPostingType(), ledgerUnit.getAmount()); + } + + private boolean isUnitPosting(LedgerPostingType type) + { + return type == LedgerPostingType.FEE || type == LedgerPostingType.TAX || type == LedgerPostingType.GROSS_VALUE; + } + + private BuySellEntry materializeAndWrap(Portfolio portfolio, Account account, + LedgerTransactionCreator.CreatedTransaction created) + { + var accountProjectionUUID = created.getProjectionRefs().stream() + .filter(projection -> projection.getRole() == LedgerProjectionRole.ACCOUNT) + .findFirst() + .orElseThrow() + .getUUID(); + var portfolioProjectionUUID = created.getProjectionRefs().stream() + .filter(projection -> projection.getRole() == LedgerProjectionRole.PORTFOLIO) + .findFirst() + .orElseThrow() + .getUUID(); + + LedgerProjectionService.materialize(client); + + return BuySellEntry.readOnly(portfolio, find(portfolio, portfolioProjectionUUID), account, + find(account, accountProjectionUUID)); + } + + private PortfolioTransaction find(Portfolio portfolio, String projectionUUID) + { + return portfolio.getTransactions().stream() + .filter(LedgerBackedTransaction.class::isInstance) + .filter(transaction -> projectionUUID.equals(transaction.getUUID())) + .findFirst() + .orElseThrow(() -> new IllegalStateException( + "Ledger buy/sell portfolio projection was not materialized: " //$NON-NLS-1$ + + projectionUUID)); + } + + private AccountTransaction find(Account account, String projectionUUID) + { + return account.getTransactions().stream() + .filter(LedgerBackedTransaction.class::isInstance) + .filter(transaction -> projectionUUID.equals(transaction.getUUID())) + .findFirst() + .orElseThrow(() -> new IllegalStateException( + "Ledger buy/sell account projection was not materialized: " //$NON-NLS-1$ + + projectionUUID)); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerCashTransferLeg.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerCashTransferLeg.java new file mode 100644 index 0000000000..1bb2f8d0eb --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerCashTransferLeg.java @@ -0,0 +1,50 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.util.Objects; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.money.Money; + +/** + * Carries cash transfer leg data for Ledger compatibility creators, editors, or patchers. + * This is a compatibility-layer value object. Contributor code may pass it to Ledger write + * paths, but it does not mutate Ledger truth by itself. + */ +public final class LedgerCashTransferLeg +{ + private final Account account; + private final Money amount; + private final LedgerForexAmount forex; + + private LedgerCashTransferLeg(Account account, Money amount, LedgerForexAmount forex) + { + this.account = Objects.requireNonNull(account); + this.amount = Objects.requireNonNull(amount); + this.forex = Objects.requireNonNull(forex); + } + + public static LedgerCashTransferLeg of(Account account, Money amount) + { + return new LedgerCashTransferLeg(account, amount, LedgerForexAmount.none()); + } + + public static LedgerCashTransferLeg of(Account account, Money amount, LedgerForexAmount forex) + { + return new LedgerCashTransferLeg(account, amount, forex); + } + + public Account getAccount() + { + return account; + } + + public Money getAmount() + { + return amount; + } + + public LedgerForexAmount getForex() + { + return forex; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerCreationUnit.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerCreationUnit.java new file mode 100644 index 0000000000..2e00ce79cf --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerCreationUnit.java @@ -0,0 +1,65 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.util.Objects; + +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.money.Money; + +/** + * Carries creation unit data for Ledger compatibility creators, editors, or patchers. + * This is a compatibility-layer value object. Contributor code may pass it to Ledger write + * paths, but it does not mutate Ledger truth by itself. + */ +public final class LedgerCreationUnit +{ + private final LedgerPostingType postingType; + private final Money amount; + private final LedgerForexAmount forex; + + private LedgerCreationUnit(LedgerPostingType postingType, Money amount, LedgerForexAmount forex) + { + this.postingType = Objects.requireNonNull(postingType); + this.amount = Objects.requireNonNull(amount); + this.forex = Objects.requireNonNull(forex); + } + + public static LedgerCreationUnit fee(Money amount) + { + return new LedgerCreationUnit(LedgerPostingType.FEE, amount, LedgerForexAmount.none()); + } + + public static LedgerCreationUnit fee(Money amount, LedgerForexAmount forex) + { + return new LedgerCreationUnit(LedgerPostingType.FEE, amount, forex); + } + + public static LedgerCreationUnit tax(Money amount) + { + return new LedgerCreationUnit(LedgerPostingType.TAX, amount, LedgerForexAmount.none()); + } + + public static LedgerCreationUnit tax(Money amount, LedgerForexAmount forex) + { + return new LedgerCreationUnit(LedgerPostingType.TAX, amount, forex); + } + + public static LedgerCreationUnit grossValue(Money amount, LedgerForexAmount forex) + { + return new LedgerCreationUnit(LedgerPostingType.GROSS_VALUE, amount, forex); + } + + public LedgerPostingType getPostingType() + { + return postingType; + } + + public Money getAmount() + { + return amount; + } + + public LedgerForexAmount getForex() + { + return forex; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerCreationUnits.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerCreationUnits.java new file mode 100644 index 0000000000..0e8147028d --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerCreationUnits.java @@ -0,0 +1,45 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * Carries creation units data for Ledger compatibility creators, editors, or patchers. + * This is a compatibility-layer value object. Contributor code may pass it to Ledger write + * paths, but it does not mutate Ledger truth by itself. + */ +public final class LedgerCreationUnits +{ + private static final LedgerCreationUnits NONE = new LedgerCreationUnits(List.of()); + + private final List units; + + private LedgerCreationUnits(List units) + { + this.units = List.copyOf(units); + } + + public static LedgerCreationUnits none() + { + return NONE; + } + + public static LedgerCreationUnits of(LedgerCreationUnit first, LedgerCreationUnit... rest) + { + var units = new ArrayList(); + + units.add(Objects.requireNonNull(first)); + + for (var unit : rest) + units.add(Objects.requireNonNull(unit)); + + return new LedgerCreationUnits(units); + } + + public List getUnits() + { + return Collections.unmodifiableList(units); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryLeg.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryLeg.java new file mode 100644 index 0000000000..e01c1d50cf --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryLeg.java @@ -0,0 +1,72 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.util.Objects; + +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.money.Money; + +/** + * Carries delivery leg data for Ledger compatibility creators, editors, or patchers. + * This is a compatibility-layer value object. Contributor code may pass it to Ledger write + * paths, but it does not mutate Ledger truth by itself. + */ +public final class LedgerDeliveryLeg +{ + private final Portfolio portfolio; + private final LedgerSecurityQuantity securityQuantity; + private final Money value; + private final LedgerForexAmount forex; + private final LedgerCreationUnits units; + + private LedgerDeliveryLeg(Portfolio portfolio, LedgerSecurityQuantity securityQuantity, Money value, + LedgerForexAmount forex, LedgerCreationUnits units) + { + this.portfolio = Objects.requireNonNull(portfolio); + this.securityQuantity = Objects.requireNonNull(securityQuantity); + this.value = Objects.requireNonNull(value); + this.forex = Objects.requireNonNull(forex); + this.units = Objects.requireNonNull(units); + } + + public static LedgerDeliveryLeg of(Portfolio portfolio, LedgerSecurityQuantity securityQuantity, Money value) + { + return of(portfolio, securityQuantity, value, LedgerForexAmount.none(), LedgerCreationUnits.none()); + } + + public static LedgerDeliveryLeg of(Portfolio portfolio, LedgerSecurityQuantity securityQuantity, Money value, + LedgerCreationUnits units) + { + return of(portfolio, securityQuantity, value, LedgerForexAmount.none(), units); + } + + public static LedgerDeliveryLeg of(Portfolio portfolio, LedgerSecurityQuantity securityQuantity, Money value, + LedgerForexAmount forex, LedgerCreationUnits units) + { + return new LedgerDeliveryLeg(portfolio, securityQuantity, value, forex, units); + } + + public Portfolio getPortfolio() + { + return portfolio; + } + + public LedgerSecurityQuantity getSecurityQuantity() + { + return securityQuantity; + } + + public Money getValue() + { + return value; + } + + public LedgerForexAmount getForex() + { + return forex; + } + + public LedgerCreationUnits getUnits() + { + return units; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryTransactionCreator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryTransactionCreator.java new file mode 100644 index 0000000000..9779a9146e --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryTransactionCreator.java @@ -0,0 +1,244 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Objects; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +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.model.ledger.LedgerEntryMetadataPatch; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerTransactionMetadata; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedPortfolioTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionService; +import name.abuchen.portfolio.money.Money; + +/** + * Creates and updates ledger-backed delivery transactions. + * This class is part of the Ledger compatibility layer for existing UI, import, and action + * code. Contributor code should use it instead of mutating projected legacy transactions. + */ +public final class LedgerDeliveryTransactionCreator +{ + private final Client client; + + public LedgerDeliveryTransactionCreator(Client client) + { + this.client = Objects.requireNonNull(client); + } + + public PortfolioTransaction create(Portfolio portfolio, PortfolioTransaction.Type type, LocalDateTime dateTime, + long amount, String currencyCode, Security security, long shares, Money forexAmount, + BigDecimal exchangeRate, List units, String note, String source) + { + Objects.requireNonNull(portfolio); + Objects.requireNonNull(type); + Objects.requireNonNull(dateTime); + Objects.requireNonNull(currencyCode); + Objects.requireNonNull(security); + Objects.requireNonNull(units); + + var metadata = LedgerTransactionMetadata.of(dateTime).withNote(note).withSource(source); + var deliveryLeg = LedgerDeliveryLeg.of(portfolio, LedgerSecurityQuantity.of(security, shares), + Money.of(currencyCode, amount), forex(forexAmount, exchangeRate), creationUnits(units)); + var creator = new LedgerTransactionCreator(client); + + var created = switch (type) + { + case DELIVERY_INBOUND -> creator.createInboundDelivery(metadata, deliveryLeg); + case DELIVERY_OUTBOUND -> creator.createOutboundDelivery(metadata, deliveryLeg); + case BUY, SELL, TRANSFER_IN, TRANSFER_OUT -> throw new UnsupportedOperationException( + LedgerDiagnosticCode.LEDGER_CONVERT_036.message("Unsupported delivery ledger production type: " + type)); //$NON-NLS-1$ + }; + + return materializeAndFind(portfolio, created); + } + + public boolean canUpdate(PortfolioTransaction transaction) + { + return transaction instanceof LedgerBackedPortfolioTransaction && isSupportedDeliveryType(transaction.getType()); + } + + public PortfolioTransaction update(PortfolioTransaction transaction, Portfolio portfolio, + PortfolioTransaction.Type type, LocalDateTime dateTime, long amount, String currencyCode, + Security security, long shares, Money forexAmount, BigDecimal exchangeRate, + List units, String note, String source) + { + return update(transaction, portfolio, type, dateTime, amount, currencyCode, security, shares, + forexAmount != null ? LedgerForexAmount.of(forexAmount, Objects.requireNonNull(exchangeRate)) + : null, + unitPostingPatch(transaction, units), note, source); + } + + public PortfolioTransaction update(PortfolioTransaction transaction, Portfolio portfolio, + PortfolioTransaction.Type type, LocalDateTime dateTime, long amount, String currencyCode, + Security security, long shares, LedgerForexAmount forex, LedgerUnitPostingPatch units, String note, + String source) + { + Objects.requireNonNull(transaction); + Objects.requireNonNull(portfolio); + Objects.requireNonNull(type); + Objects.requireNonNull(dateTime); + Objects.requireNonNull(currencyCode); + Objects.requireNonNull(security); + Objects.requireNonNull(units); + + if (!(transaction instanceof LedgerBackedPortfolioTransaction ledgerTransaction)) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_CONVERT_037 + .message("Only ledger-backed delivery transactions can be updated")); //$NON-NLS-1$ + if (!isSupportedDeliveryType(type) || transaction.getType() != type) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_CONVERT_038.message("Unsupported delivery ledger production edit type: " + type)); //$NON-NLS-1$ + + var editBuilder = LedgerDeliveryTransactionEdit.builder() + .metadata(LedgerEntryMetadataPatch.builder().dateTime(dateTime).note(note).source(source) + .build()) + .amount(amount) + .currency(currencyCode) + .security(security) + .shares(shares) + .units(units); + + applyForex(editBuilder, forex); + + var edit = editBuilder.build(); + var editor = new LedgerDeliveryTransactionEditor(); + + if (ledgerTransaction.getLedgerProjectionRef().getPortfolio() != portfolio) + { + var projectionUUID = ledgerTransaction.getLedgerProjectionRef().getUUID(); + + editor.validate(ledgerTransaction, edit); + new LedgerOwnerPatchHelper(client).moveDelivery(ledgerTransaction, portfolio); + ledgerTransaction = (LedgerBackedPortfolioTransaction) find(portfolio, projectionUUID); + } + + editor.apply(ledgerTransaction, edit); + + return ledgerTransaction; + } + + private void applyForex(LedgerDeliveryTransactionEdit.Builder builder, LedgerForexAmount forex) + { + if (forex == null) + return; + + if (forex.isPresent()) + builder.forexAmount(forex.getForexAmount().getAmount()) + .forexCurrency(forex.getForexAmount().getCurrencyCode()) + .exchangeRate(forex.getExchangeRate()); + else + builder.forexAmount(null).forexCurrency(null).exchangeRate(null); + } + + private PortfolioTransaction find(Portfolio portfolio, String projectionUUID) + { + return portfolio.getTransactions().stream() + .filter(LedgerBackedTransaction.class::isInstance) + .filter(item -> projectionUUID.equals(item.getUUID())) + .findFirst() + .orElseThrow(() -> new IllegalStateException( + "Ledger delivery projection was not materialized: " + projectionUUID)); //$NON-NLS-1$ + } + + private boolean isSupportedDeliveryType(PortfolioTransaction.Type type) + { + return switch (type) + { + case DELIVERY_INBOUND, DELIVERY_OUTBOUND -> true; + case BUY, SELL, TRANSFER_IN, TRANSFER_OUT -> false; + }; + } + + private LedgerForexAmount forex(Money forexAmount, BigDecimal exchangeRate) + { + return forexAmount != null ? LedgerForexAmount.of(forexAmount, Objects.requireNonNull(exchangeRate)) + : LedgerForexAmount.none(); + } + + private LedgerCreationUnits creationUnits(List units) + { + if (units.isEmpty()) + return LedgerCreationUnits.none(); + + var ledgerUnits = units.stream().map(this::creationUnit).toList(); + return LedgerCreationUnits.of(ledgerUnits.get(0), + ledgerUnits.subList(1, ledgerUnits.size()).toArray(LedgerCreationUnit[]::new)); + } + + private LedgerCreationUnit creationUnit(Transaction.Unit unit) + { + var forex = unit.getForex() != null ? LedgerForexAmount.of(unit.getForex(), unit.getExchangeRate()) + : LedgerForexAmount.none(); + + return switch (unit.getType()) + { + case FEE -> forex.isPresent() ? LedgerCreationUnit.fee(unit.getAmount(), forex) + : LedgerCreationUnit.fee(unit.getAmount()); + case TAX -> forex.isPresent() ? LedgerCreationUnit.tax(unit.getAmount(), forex) + : LedgerCreationUnit.tax(unit.getAmount()); + case GROSS_VALUE -> LedgerCreationUnit.grossValue(unit.getAmount(), forex); + }; + } + + private LedgerUnitPostingPatch unitPostingPatch(PortfolioTransaction transaction, List units) + { + Objects.requireNonNull(units); + + if (!(transaction instanceof LedgerBackedPortfolioTransaction ledgerTransaction)) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_CONVERT_039 + .message("Only ledger-backed delivery transactions can be updated")); //$NON-NLS-1$ + + var edits = new java.util.ArrayList(); + + ledgerTransaction.getLedgerEntry().getPostings().stream() + .filter(posting -> isUnitPosting(posting.getType())) + .map(LedgerPosting::getUUID) + .map(LedgerUnitPostingEdit::remove) + .forEach(edits::add); + + units.stream().map(this::unitPostingEdit).forEach(edits::add); + + if (edits.isEmpty()) + return LedgerUnitPostingPatch.none(); + + return LedgerUnitPostingPatch.of(edits.get(0), + edits.subList(1, edits.size()).toArray(LedgerUnitPostingEdit[]::new)); + } + + private LedgerUnitPostingEdit unitPostingEdit(Transaction.Unit unit) + { + var ledgerUnit = creationUnit(unit); + var forex = ledgerUnit.getForex(); + + return forex.isPresent() + ? LedgerUnitPostingEdit.add(ledgerUnit.getPostingType(), ledgerUnit.getAmount(), forex) + : LedgerUnitPostingEdit.add(ledgerUnit.getPostingType(), ledgerUnit.getAmount()); + } + + private boolean isUnitPosting(LedgerPostingType type) + { + return type == LedgerPostingType.FEE || type == LedgerPostingType.TAX || type == LedgerPostingType.GROSS_VALUE; + } + + private PortfolioTransaction materializeAndFind(Portfolio portfolio, + LedgerTransactionCreator.CreatedTransaction created) + { + var projectionUUID = created.getProjectionRefs().get(0).getUUID(); + + LedgerProjectionService.materialize(client); + + return portfolio.getTransactions().stream() + .filter(LedgerBackedTransaction.class::isInstance) + .filter(transaction -> projectionUUID.equals(transaction.getUUID())) + .findFirst() + .orElseThrow(() -> new IllegalStateException( + "Ledger delivery projection was not materialized: " + projectionUUID)); //$NON-NLS-1$ + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryTransactionEdit.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryTransactionEdit.java new file mode 100644 index 0000000000..f064101cf0 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryTransactionEdit.java @@ -0,0 +1,115 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.math.BigDecimal; + +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.ledger.LedgerEntryMetadataPatch; + +/** + * Carries delivery transaction data for Ledger compatibility creators, editors, or patchers. + * This is a compatibility-layer value object. Contributor code may pass it to Ledger write + * paths, but it does not mutate Ledger truth by itself. + */ +public final class LedgerDeliveryTransactionEdit +{ + private final LedgerEntryMetadataPatch metadata; + private final LedgerPostingPatch posting; + private final LedgerUnitPostingPatch units; + + private LedgerDeliveryTransactionEdit(Builder builder) + { + this.metadata = builder.metadata; + this.posting = builder.postingBuilder.build(); + this.units = builder.units; + } + + public static Builder builder() + { + return new Builder(); + } + + LedgerEntryMetadataPatch getMetadata() + { + return metadata; + } + + LedgerPostingPatch getPosting() + { + return posting; + } + + LedgerUnitPostingPatch getUnits() + { + return units; + } + + public static final class Builder + { + private LedgerEntryMetadataPatch metadata = LedgerEntryMetadataPatch.none(); + private final LedgerPostingPatch.Builder postingBuilder = LedgerPostingPatch.builder(); + private LedgerUnitPostingPatch units = LedgerUnitPostingPatch.none(); + + private Builder() + { + } + + public Builder metadata(LedgerEntryMetadataPatch metadata) + { + this.metadata = java.util.Objects.requireNonNull(metadata); + return this; + } + + public Builder amount(long amount) + { + postingBuilder.amount(amount); + return this; + } + + public Builder currency(String currency) + { + postingBuilder.currency(currency); + return this; + } + + public Builder forexAmount(Long forexAmount) + { + postingBuilder.forexAmount(forexAmount); + return this; + } + + public Builder forexCurrency(String forexCurrency) + { + postingBuilder.forexCurrency(forexCurrency); + return this; + } + + public Builder exchangeRate(BigDecimal exchangeRate) + { + postingBuilder.exchangeRate(exchangeRate); + return this; + } + + public Builder security(Security security) + { + postingBuilder.security(security); + return this; + } + + public Builder shares(long shares) + { + postingBuilder.shares(shares); + return this; + } + + public Builder units(LedgerUnitPostingPatch units) + { + this.units = java.util.Objects.requireNonNull(units); + return this; + } + + public LedgerDeliveryTransactionEdit build() + { + return new LedgerDeliveryTransactionEdit(this); + } + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryTransactionEditor.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryTransactionEditor.java new file mode 100644 index 0000000000..2ab199eeaf --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryTransactionEditor.java @@ -0,0 +1,73 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.util.Objects; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerEntryEditSupport; +import name.abuchen.portfolio.model.ledger.LedgerEntryMetadataPatchHelper; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedPortfolioTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionSupport; + +/** + * Updates same-shape ledger-backed delivery transaction transactions. + * This class is part of the Ledger compatibility layer. Contributor code should use it when + * an existing UI or import path needs to edit Ledger truth safely. + */ +public final class LedgerDeliveryTransactionEditor +{ + private final LedgerUnitPostingUpdater unitPostingUpdater = new LedgerUnitPostingUpdater(); + + public void apply(LedgerBackedPortfolioTransaction transaction, LedgerDeliveryTransactionEdit edit) + { + Objects.requireNonNull(transaction); + Objects.requireNonNull(edit); + + var entry = transaction.getLedgerEntry(); + + if (entry.getType() != LedgerEntryType.DELIVERY_INBOUND && entry.getType() != LedgerEntryType.DELIVERY_OUTBOUND) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_040 + .message("Unsupported delivery edit for " + entry.getType())); //$NON-NLS-1$ + + var projectionUUID = transaction.getLedgerProjectionRef().getUUID(); + var postingUUID = LedgerProjectionSupport.primaryPosting(entry, transaction.getLedgerProjectionRef()).getUUID(); + + LedgerEntryEditSupport.applyValidated(entry, editedEntry -> applyEdit(editedEntry, edit, projectionUUID, + postingUUID)); + } + + public void validate(LedgerBackedPortfolioTransaction transaction, LedgerDeliveryTransactionEdit edit) + { + Objects.requireNonNull(transaction); + Objects.requireNonNull(edit); + + var entry = transaction.getLedgerEntry(); + + if (entry.getType() != LedgerEntryType.DELIVERY_INBOUND && entry.getType() != LedgerEntryType.DELIVERY_OUTBOUND) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_041 + .message("Unsupported delivery edit for " + entry.getType())); //$NON-NLS-1$ + + var projectionUUID = transaction.getLedgerProjectionRef().getUUID(); + var postingUUID = LedgerProjectionSupport.primaryPosting(entry, transaction.getLedgerProjectionRef()).getUUID(); + + LedgerEntryEditSupport.validatePatch(entry, editedEntry -> applyEdit(editedEntry, edit, projectionUUID, + postingUUID)); + } + + private void applyEdit(LedgerEntry editedEntry, LedgerDeliveryTransactionEdit edit, String projectionUUID, + String postingUUID) + { + LedgerEntryMetadataPatchHelper.apply(editedEntry, edit.getMetadata()); + edit.getPosting().applyTo(LedgerEntryEditSupport.postingByUUID(editedEntry, postingUUID)); + unitPostingUpdater.apply(editedEntry, edit.getUnits()); + ensureProjectionExists(editedEntry, projectionUUID); + } + + private void ensureProjectionExists(LedgerEntry entry, String projectionUUID) + { + if (entry.getProjectionRefs().stream().noneMatch(projection -> projection.getUUID().equals(projectionUUID))) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_040 + .message("Projection was removed by edit: " + projectionUUID)); //$NON-NLS-1$ + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDividend.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDividend.java new file mode 100644 index 0000000000..68b2d55370 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDividend.java @@ -0,0 +1,82 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.time.LocalDateTime; +import java.util.Objects; + +/** + * Carries dividend data for Ledger compatibility creators, editors, or patchers. + * This is a compatibility-layer value object. Contributor code may pass it to Ledger write + * paths, but it does not mutate Ledger truth by itself. + */ +public final class LedgerDividend +{ + private final LedgerAccountCashLeg cashLeg; + private final LedgerOptionalSecurity security; + private final LedgerCreationUnits units; + private final long shares; + private final LocalDateTime exDate; + + private LedgerDividend(LedgerAccountCashLeg cashLeg, LedgerOptionalSecurity security, LedgerCreationUnits units, + long shares, LocalDateTime exDate) + { + this.cashLeg = Objects.requireNonNull(cashLeg); + this.security = Objects.requireNonNull(security); + this.units = Objects.requireNonNull(units); + this.shares = shares; + this.exDate = exDate; + } + + public static LedgerDividend withoutExDate(LedgerAccountCashLeg cashLeg, LedgerOptionalSecurity security, + LedgerCreationUnits units) + { + return withoutExDate(cashLeg, security, units, 0); + } + + public static LedgerDividend withoutExDate(LedgerAccountCashLeg cashLeg, LedgerOptionalSecurity security, + LedgerCreationUnits units, long shares) + { + return new LedgerDividend(cashLeg, security, units, shares, null); + } + + public static LedgerDividend withExDate(LedgerAccountCashLeg cashLeg, LedgerOptionalSecurity security, + LedgerCreationUnits units, LocalDateTime exDate) + { + return withExDate(cashLeg, security, units, 0, exDate); + } + + public static LedgerDividend withExDate(LedgerAccountCashLeg cashLeg, LedgerOptionalSecurity security, + LedgerCreationUnits units, long shares, LocalDateTime exDate) + { + return new LedgerDividend(cashLeg, security, units, shares, Objects.requireNonNull(exDate)); + } + + public LedgerAccountCashLeg getCashLeg() + { + return cashLeg; + } + + public LedgerOptionalSecurity getSecurity() + { + return security; + } + + public LedgerCreationUnits getUnits() + { + return units; + } + + public long getShares() + { + return shares; + } + + public boolean hasExDate() + { + return exDate != null; + } + + public LocalDateTime getExDate() + { + return exDate; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDividendTransactionCreator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDividendTransactionCreator.java new file mode 100644 index 0000000000..e2239f9688 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDividendTransactionCreator.java @@ -0,0 +1,233 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Objects; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.Transaction; +import name.abuchen.portfolio.model.ledger.LedgerEntryMetadataPatch; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerTransactionMetadata; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedAccountTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionService; +import name.abuchen.portfolio.money.Money; + +/** + * Creates and updates ledger-backed dividend transactions. + * This class is part of the Ledger compatibility layer for existing UI, import, and action + * code. Contributor code should use it instead of mutating projected legacy transactions. + */ +public final class LedgerDividendTransactionCreator +{ + private final Client client; + + public LedgerDividendTransactionCreator(Client client) + { + this.client = Objects.requireNonNull(client); + } + + public AccountTransaction create(Account account, LocalDateTime dateTime, long amount, String currencyCode, + Security security, long shares, LocalDateTime exDate, Money forexAmount, BigDecimal exchangeRate, + List units, String note, String source) + { + Objects.requireNonNull(account); + Objects.requireNonNull(dateTime); + Objects.requireNonNull(currencyCode); + Objects.requireNonNull(security); + Objects.requireNonNull(units); + + var metadata = LedgerTransactionMetadata.of(dateTime).withNote(note).withSource(source); + var cashLeg = LedgerAccountCashLeg.of(account, Money.of(currencyCode, amount), + forexAmount != null ? LedgerForexAmount.of(forexAmount, Objects.requireNonNull(exchangeRate)) + : LedgerForexAmount.none()); + var creationUnits = creationUnits(units); + var dividend = exDate != null + ? LedgerDividend.withExDate(cashLeg, LedgerOptionalSecurity.of(security), creationUnits, + shares, exDate) + : LedgerDividend.withoutExDate(cashLeg, LedgerOptionalSecurity.of(security), creationUnits, + shares); + + var created = new LedgerTransactionCreator(client).createDividend(metadata, dividend); + + return materializeAndFind(account, created); + } + + public boolean canUpdate(AccountTransaction transaction) + { + return transaction instanceof LedgerBackedAccountTransaction + && transaction.getType() == AccountTransaction.Type.DIVIDENDS; + } + + public AccountTransaction update(AccountTransaction transaction, Account account, AccountTransaction.Type type, + LocalDateTime dateTime, long amount, String currencyCode, Security security, long shares, + LocalDateTime exDate, Money forexAmount, BigDecimal exchangeRate, List units, + String note, String source) + { + return update(transaction, account, type, dateTime, amount, currencyCode, security, shares, exDate, + forexAmount != null ? LedgerForexAmount.of(forexAmount, Objects.requireNonNull(exchangeRate)) + : LedgerForexAmount.none(), + unitPostingPatch(transaction, units), note, source); + } + + public AccountTransaction update(AccountTransaction transaction, Account account, AccountTransaction.Type type, + LocalDateTime dateTime, long amount, String currencyCode, Security security, long shares, + LocalDateTime exDate, LedgerForexAmount forex, LedgerUnitPostingPatch units, String note, + String source) + { + Objects.requireNonNull(transaction); + Objects.requireNonNull(account); + Objects.requireNonNull(type); + Objects.requireNonNull(dateTime); + Objects.requireNonNull(currencyCode); + Objects.requireNonNull(security); + Objects.requireNonNull(units); + + if (!(transaction instanceof LedgerBackedAccountTransaction ledgerTransaction)) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_CONVERT_042 + .message("Only ledger-backed dividend transactions can be updated")); //$NON-NLS-1$ + if (type != AccountTransaction.Type.DIVIDENDS || transaction.getType() != AccountTransaction.Type.DIVIDENDS) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_CONVERT_043.message("Unsupported dividend ledger production edit type: " + type)); //$NON-NLS-1$ + + var editBuilder = LedgerAccountTransactionEdit.builder() + .metadata(LedgerEntryMetadataPatch.builder().dateTime(dateTime).note(note).source(source) + .build()) + .amount(amount) + .currency(currencyCode) + .security(security) + .shares(shares) + .units(units); + + applyForex(editBuilder, forex); + + if (exDate != null) + editBuilder.exDate(exDate); + else + editBuilder.clearExDate(); + + var edit = editBuilder.build(); + var editor = new LedgerAccountTransactionEditor(); + + if (ledgerTransaction.getLedgerProjectionRef().getAccount() != account) + { + var projectionUUID = ledgerTransaction.getLedgerProjectionRef().getUUID(); + + editor.validate(ledgerTransaction, edit); + new LedgerOwnerPatchHelper(client).moveAccountOnly(ledgerTransaction, account); + ledgerTransaction = (LedgerBackedAccountTransaction) find(account, projectionUUID); + } + + editor.apply(ledgerTransaction, edit); + + return ledgerTransaction; + } + + private void applyForex(LedgerAccountTransactionEdit.Builder builder, LedgerForexAmount forex) + { + if (forex == null) + return; + + if (forex.isPresent()) + builder.forexAmount(forex.getForexAmount().getAmount()) + .forexCurrency(forex.getForexAmount().getCurrencyCode()) + .exchangeRate(forex.getExchangeRate()); + else + builder.forexAmount(null).forexCurrency(null).exchangeRate(null); + } + + private AccountTransaction find(Account account, String projectionUUID) + { + return account.getTransactions().stream() + .filter(LedgerBackedTransaction.class::isInstance) + .filter(item -> projectionUUID.equals(item.getUUID())) + .findFirst() + .orElseThrow(() -> new IllegalStateException( + "Ledger dividend projection was not materialized: " + projectionUUID)); //$NON-NLS-1$ + } + + private LedgerCreationUnits creationUnits(List units) + { + if (units.isEmpty()) + return LedgerCreationUnits.none(); + + var ledgerUnits = units.stream().map(this::creationUnit).toList(); + return LedgerCreationUnits.of(ledgerUnits.get(0), + ledgerUnits.subList(1, ledgerUnits.size()).toArray(LedgerCreationUnit[]::new)); + } + + private LedgerCreationUnit creationUnit(Transaction.Unit unit) + { + var forex = unit.getForex() != null ? LedgerForexAmount.of(unit.getForex(), unit.getExchangeRate()) + : LedgerForexAmount.none(); + + return switch (unit.getType()) + { + case FEE -> forex.isPresent() ? LedgerCreationUnit.fee(unit.getAmount(), forex) + : LedgerCreationUnit.fee(unit.getAmount()); + case TAX -> forex.isPresent() ? LedgerCreationUnit.tax(unit.getAmount(), forex) + : LedgerCreationUnit.tax(unit.getAmount()); + case GROSS_VALUE -> LedgerCreationUnit.grossValue(unit.getAmount(), forex); + }; + } + + private LedgerUnitPostingPatch unitPostingPatch(AccountTransaction transaction, List units) + { + Objects.requireNonNull(units); + + if (!(transaction instanceof LedgerBackedAccountTransaction ledgerTransaction)) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_CONVERT_044 + .message("Only ledger-backed dividend transactions can be updated")); //$NON-NLS-1$ + + var edits = new java.util.ArrayList(); + + ledgerTransaction.getLedgerEntry().getPostings().stream() + .filter(posting -> isUnitPosting(posting.getType())) + .map(LedgerPosting::getUUID) + .map(LedgerUnitPostingEdit::remove) + .forEach(edits::add); + + units.stream().map(this::unitPostingEdit).forEach(edits::add); + + if (edits.isEmpty()) + return LedgerUnitPostingPatch.none(); + + return LedgerUnitPostingPatch.of(edits.get(0), + edits.subList(1, edits.size()).toArray(LedgerUnitPostingEdit[]::new)); + } + + private LedgerUnitPostingEdit unitPostingEdit(Transaction.Unit unit) + { + var ledgerUnit = creationUnit(unit); + var forex = ledgerUnit.getForex(); + + return forex.isPresent() + ? LedgerUnitPostingEdit.add(ledgerUnit.getPostingType(), ledgerUnit.getAmount(), forex) + : LedgerUnitPostingEdit.add(ledgerUnit.getPostingType(), ledgerUnit.getAmount()); + } + + private boolean isUnitPosting(LedgerPostingType type) + { + return type == LedgerPostingType.FEE || type == LedgerPostingType.TAX || type == LedgerPostingType.GROSS_VALUE; + } + + private AccountTransaction materializeAndFind(Account account, LedgerTransactionCreator.CreatedTransaction created) + { + var projectionUUID = created.getProjectionRefs().get(0).getUUID(); + + LedgerProjectionService.materialize(client); + + return account.getTransactions().stream() + .filter(LedgerBackedTransaction.class::isInstance) + .filter(transaction -> projectionUUID.equals(transaction.getUUID())) + .findFirst() + .orElseThrow(() -> new IllegalStateException( + "Ledger dividend projection was not materialized: " + projectionUUID)); //$NON-NLS-1$ + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerForexAmount.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerForexAmount.java new file mode 100644 index 0000000000..a8eb71d89b --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerForexAmount.java @@ -0,0 +1,53 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.math.BigDecimal; +import java.util.Objects; + +import name.abuchen.portfolio.money.Money; + +/** + * Carries forex amount data for Ledger compatibility creators, editors, or patchers. + * This is a compatibility-layer value object. Contributor code may pass it to Ledger write + * paths, but it does not mutate Ledger truth by itself. + */ +public final class LedgerForexAmount +{ + private static final LedgerForexAmount NONE = new LedgerForexAmount(null, null); + + private final Money forexAmount; + private final BigDecimal exchangeRate; + + private LedgerForexAmount(Money forexAmount, BigDecimal exchangeRate) + { + this.forexAmount = forexAmount; + this.exchangeRate = exchangeRate; + } + + public static LedgerForexAmount none() + { + return NONE; + } + + public static LedgerForexAmount of(Money forexAmount, BigDecimal exchangeRate) + { + Objects.requireNonNull(forexAmount); + Objects.requireNonNull(exchangeRate); + + return new LedgerForexAmount(forexAmount, exchangeRate); + } + + public boolean isPresent() + { + return forexAmount != null; + } + + public Money getForexAmount() + { + return forexAmount; + } + + public BigDecimal getExchangeRate() + { + return exchangeRate; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerInvestmentPlanRefSupport.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerInvestmentPlanRefSupport.java new file mode 100644 index 0000000000..c928c04fe6 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerInvestmentPlanRefSupport.java @@ -0,0 +1,215 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.InvestmentPlan; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; + +/** + * Keeps generated transaction references valid during Ledger conversions. + * This is internal support for investment-plan references. It prevents conversions from + * guessing a target when the old reference is ambiguous. + */ +final class LedgerInvestmentPlanRefSupport +{ + private LedgerInvestmentPlanRefSupport() + { + } + + static RoleChange roleChange(String projectionUUID, LedgerProjectionRole sourceRole, + LedgerProjectionRole targetRole) + { + return new RoleChange(Objects.requireNonNull(projectionUUID), Objects.requireNonNull(sourceRole), + Objects.requireNonNull(targetRole)); + } + + static void requireCurrentRefsResolveUniquely(Client client, LedgerEntry entry) + { + forEachRef(client, entry, ref -> { + var matches = matchingProjections(entry, ref); + if (matches != 1) + throw new UnsupportedOperationException(referenceResolutionFailure(matches)); + }); + } + + static SplitExecutionRefUpdates prepareAccountTransferSplitExecutionRefUpdates(Client client, LedgerEntry entry, + LedgerProjectionRef sourceProjection, LedgerProjectionRef targetProjection, + LedgerEntry removalEntry, LedgerEntry depositEntry) + { + var removalProjection = uniqueAccountProjection(removalEntry); + var depositProjection = uniqueAccountProjection(depositEntry); + var updates = new ArrayList(); + + for (var plan : client.getPlans()) + { + var refs = plan.getLedgerExecutionRefs(); + + for (int ii = 0; ii < refs.size(); ii++) + { + var ref = refs.get(ii); + + if (!entry.getUUID().equals(ref.getLedgerEntryUUID())) + continue; + + if (splitSide(ref, sourceProjection, targetProjection) == SplitSide.SOURCE) + updates.add(new ExecutionRefUpdate(plan, ii, new InvestmentPlan.LedgerExecutionRef( + removalEntry.getUUID(), removalProjection.getUUID(), removalProjection.getRole()))); + else + updates.add(new ExecutionRefUpdate(plan, ii, new InvestmentPlan.LedgerExecutionRef( + depositEntry.getUUID(), depositProjection.getUUID(), depositProjection.getRole()))); + } + } + + return new SplitExecutionRefUpdates(List.copyOf(updates)); + } + + static void requireRefsFollowRoleChanges(Client client, LedgerEntry entry, RoleChange... changes) + { + requireCurrentRefsResolveUniquely(client, entry); + + forEachRef(client, entry, ref -> { + if (!canFollowAny(ref, changes)) + throw new UnsupportedOperationException(cannotFollowFailure(ref)); + }); + } + + static void updateProjectionRoles(Client client, LedgerEntry entry, RoleChange... changes) + { + for (var plan : client.getPlans()) + { + var refs = plan.getLedgerExecutionRefs(); + + for (int ii = 0; ii < refs.size(); ii++) + { + var ref = refs.get(ii); + + if (!entry.getUUID().equals(ref.getLedgerEntryUUID())) + continue; + + for (var change : changes) + { + if (canFollow(ref, change) && ref.getProjectionRole() != null) + { + refs.set(ii, new InvestmentPlan.LedgerExecutionRef(ref.getLedgerEntryUUID(), + ref.getProjectionUUID(), change.targetRole())); + break; + } + } + } + } + } + + private static void forEachRef(Client client, LedgerEntry entry, RefConsumer consumer) + { + for (var plan : client.getPlans()) + for (var ref : plan.getLedgerExecutionRefs()) + if (entry.getUUID().equals(ref.getLedgerEntryUUID())) + consumer.accept(ref); + } + + private static long matchingProjections(LedgerEntry entry, InvestmentPlan.LedgerExecutionRef ref) + { + return entry.getProjectionRefs().stream().filter(projection -> matches(ref, projection)).count(); + } + + private static boolean matches(InvestmentPlan.LedgerExecutionRef ref, LedgerProjectionRef projection) + { + return (ref.getProjectionUUID() == null || ref.getProjectionUUID().equals(projection.getUUID())) + && (ref.getProjectionRole() == null || ref.getProjectionRole() == projection.getRole()); + } + + private static boolean canFollowAny(InvestmentPlan.LedgerExecutionRef ref, RoleChange... changes) + { + for (var change : changes) + if (canFollow(ref, change)) + return true; + + return false; + } + + private static boolean canFollow(InvestmentPlan.LedgerExecutionRef ref, RoleChange change) + { + if (ref.getProjectionUUID() != null && !ref.getProjectionUUID().equals(change.projectionUUID())) + return false; + + if (ref.getProjectionUUID() == null && ref.getProjectionRole() == null) + return false; + + return ref.getProjectionRole() == null || ref.getProjectionRole() == change.sourceRole(); + } + + private static SplitSide splitSide(InvestmentPlan.LedgerExecutionRef ref, LedgerProjectionRef sourceProjection, + LedgerProjectionRef targetProjection) + { + var matchesSource = matches(ref, sourceProjection); + var matchesTarget = matches(ref, targetProjection); + + if (matchesSource == matchesTarget) + throw new UnsupportedOperationException( + LedgerDiagnosticCode.LEDGER_CONVERT_045.message("Ledger plan reference cannot be mapped to a split transfer side")); //$NON-NLS-1$ + + return matchesSource ? SplitSide.SOURCE : SplitSide.TARGET; + } + + private static LedgerProjectionRef uniqueAccountProjection(LedgerEntry entry) + { + var projections = entry.getProjectionRefs().stream() + .filter(projection -> projection.getRole() == LedgerProjectionRole.ACCOUNT).toList(); + + if (projections.size() != 1) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PROJ_041 + .message("Ledger plan reference cannot be mapped to a split transfer side")); //$NON-NLS-1$ + + return projections.get(0); + } + + private static String referenceResolutionFailure(long matches) + { + if (matches == 0) + return "Ledger plan reference cannot resolve selected projection"; //$NON-NLS-1$ + + return "Ledger plan reference would become ambiguous"; //$NON-NLS-1$ + } + + private static String cannotFollowFailure(InvestmentPlan.LedgerExecutionRef ref) + { + if (ref.getProjectionUUID() != null) + return "Ledger plan reference projection would be removed by conversion"; //$NON-NLS-1$ + + return "Ledger plan reference would become ambiguous after conversion"; //$NON-NLS-1$ + } + + record RoleChange(String projectionUUID, LedgerProjectionRole sourceRole, LedgerProjectionRole targetRole) + { + } + + record SplitExecutionRefUpdates(List updates) + { + void apply() + { + for (var update : updates) + update.plan().getLedgerExecutionRefs().set(update.index(), update.ref()); + } + } + + private record ExecutionRefUpdate(InvestmentPlan plan, int index, InvestmentPlan.LedgerExecutionRef ref) + { + } + + private enum SplitSide + { + SOURCE, TARGET + } + + @FunctionalInterface + private interface RefConsumer + { + void accept(InvestmentPlan.LedgerExecutionRef ref); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerOptionalSecurity.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerOptionalSecurity.java new file mode 100644 index 0000000000..69381d7dff --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerOptionalSecurity.java @@ -0,0 +1,42 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.util.Objects; + +import name.abuchen.portfolio.model.Security; + +/** + * Carries optional security data for Ledger compatibility creators, editors, or patchers. + * This is a compatibility-layer value object. Contributor code may pass it to Ledger write + * paths, but it does not mutate Ledger truth by itself. + */ +public final class LedgerOptionalSecurity +{ + private static final LedgerOptionalSecurity NONE = new LedgerOptionalSecurity(null); + + private final Security security; + + private LedgerOptionalSecurity(Security security) + { + this.security = security; + } + + public static LedgerOptionalSecurity none() + { + return NONE; + } + + public static LedgerOptionalSecurity of(Security security) + { + return new LedgerOptionalSecurity(Objects.requireNonNull(security)); + } + + public boolean isPresent() + { + return security != null; + } + + public Security getSecurity() + { + return security; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioSecurityLeg.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioSecurityLeg.java new file mode 100644 index 0000000000..414424425b --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioSecurityLeg.java @@ -0,0 +1,60 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.util.Objects; + +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.money.Money; + +/** + * Carries portfolio security leg data for Ledger compatibility creators, editors, or patchers. + * This is a compatibility-layer value object. Contributor code may pass it to Ledger write + * paths, but it does not mutate Ledger truth by itself. + */ +public final class LedgerPortfolioSecurityLeg +{ + private final Portfolio portfolio; + private final LedgerSecurityQuantity securityQuantity; + private final Money value; + private final LedgerForexAmount forex; + + private LedgerPortfolioSecurityLeg(Portfolio portfolio, LedgerSecurityQuantity securityQuantity, Money value, + LedgerForexAmount forex) + { + this.portfolio = Objects.requireNonNull(portfolio); + this.securityQuantity = Objects.requireNonNull(securityQuantity); + this.value = Objects.requireNonNull(value); + this.forex = Objects.requireNonNull(forex); + } + + public static LedgerPortfolioSecurityLeg of(Portfolio portfolio, LedgerSecurityQuantity securityQuantity, + Money value) + { + return new LedgerPortfolioSecurityLeg(portfolio, securityQuantity, value, LedgerForexAmount.none()); + } + + public static LedgerPortfolioSecurityLeg of(Portfolio portfolio, LedgerSecurityQuantity securityQuantity, + Money value, LedgerForexAmount forex) + { + return new LedgerPortfolioSecurityLeg(portfolio, securityQuantity, value, forex); + } + + public Portfolio getPortfolio() + { + return portfolio; + } + + public LedgerSecurityQuantity getSecurityQuantity() + { + return securityQuantity; + } + + public Money getValue() + { + return value; + } + + public LedgerForexAmount getForex() + { + return forex; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferEdit.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferEdit.java new file mode 100644 index 0000000000..78707eb880 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferEdit.java @@ -0,0 +1,165 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.math.BigDecimal; + +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.ledger.LedgerEntryMetadataPatch; + +/** + * Carries portfolio transfer data for Ledger compatibility creators, editors, or patchers. + * This is a compatibility-layer value object. Contributor code may pass it to Ledger write + * paths, but it does not mutate Ledger truth by itself. + */ +public final class LedgerPortfolioTransferEdit +{ + private final LedgerEntryMetadataPatch metadata; + private final LedgerPostingPatch sourcePosting; + private final LedgerPostingPatch targetPosting; + private final LedgerUnitPostingPatch units; + + private LedgerPortfolioTransferEdit(Builder builder) + { + this.metadata = builder.metadata; + this.sourcePosting = builder.sourcePostingBuilder.build(); + this.targetPosting = builder.targetPostingBuilder.build(); + this.units = builder.units; + } + + public static Builder builder() + { + return new Builder(); + } + + LedgerEntryMetadataPatch getMetadata() + { + return metadata; + } + + LedgerPostingPatch getSourcePosting() + { + return sourcePosting; + } + + LedgerPostingPatch getTargetPosting() + { + return targetPosting; + } + + LedgerUnitPostingPatch getUnits() + { + return units; + } + + public static final class Builder + { + private LedgerEntryMetadataPatch metadata = LedgerEntryMetadataPatch.none(); + private final LedgerPostingPatch.Builder sourcePostingBuilder = LedgerPostingPatch.builder(); + private final LedgerPostingPatch.Builder targetPostingBuilder = LedgerPostingPatch.builder(); + private LedgerUnitPostingPatch units = LedgerUnitPostingPatch.none(); + + private Builder() + { + } + + public Builder metadata(LedgerEntryMetadataPatch metadata) + { + this.metadata = java.util.Objects.requireNonNull(metadata); + return this; + } + + public Builder sourceAmount(long amount) + { + sourcePostingBuilder.amount(amount); + return this; + } + + public Builder sourceCurrency(String currency) + { + sourcePostingBuilder.currency(currency); + return this; + } + + public Builder sourceForexAmount(Long forexAmount) + { + sourcePostingBuilder.forexAmount(forexAmount); + return this; + } + + public Builder sourceForexCurrency(String forexCurrency) + { + sourcePostingBuilder.forexCurrency(forexCurrency); + return this; + } + + public Builder sourceExchangeRate(BigDecimal exchangeRate) + { + sourcePostingBuilder.exchangeRate(exchangeRate); + return this; + } + + public Builder sourceSecurity(Security security) + { + sourcePostingBuilder.security(security); + return this; + } + + public Builder sourceShares(long shares) + { + sourcePostingBuilder.shares(shares); + return this; + } + + public Builder targetAmount(long amount) + { + targetPostingBuilder.amount(amount); + return this; + } + + public Builder targetCurrency(String currency) + { + targetPostingBuilder.currency(currency); + return this; + } + + public Builder targetForexAmount(Long forexAmount) + { + targetPostingBuilder.forexAmount(forexAmount); + return this; + } + + public Builder targetForexCurrency(String forexCurrency) + { + targetPostingBuilder.forexCurrency(forexCurrency); + return this; + } + + public Builder targetExchangeRate(BigDecimal exchangeRate) + { + targetPostingBuilder.exchangeRate(exchangeRate); + return this; + } + + public Builder targetSecurity(Security security) + { + targetPostingBuilder.security(security); + return this; + } + + public Builder targetShares(long shares) + { + targetPostingBuilder.shares(shares); + return this; + } + + public Builder units(LedgerUnitPostingPatch units) + { + this.units = java.util.Objects.requireNonNull(units); + return this; + } + + public LedgerPortfolioTransferEdit build() + { + return new LedgerPortfolioTransferEdit(this); + } + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferEditor.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferEditor.java new file mode 100644 index 0000000000..61dff64d37 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferEditor.java @@ -0,0 +1,106 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.util.Objects; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerEntryEditSupport; +import name.abuchen.portfolio.model.ledger.LedgerEntryMetadataPatchHelper; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedPortfolioTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionSupport; + +/** + * Updates same-shape ledger-backed portfolio transfer transactions. + * This class is part of the Ledger compatibility layer. Contributor code should use it when + * an existing UI or import path needs to edit Ledger truth safely. + */ +public final class LedgerPortfolioTransferEditor +{ + private final LedgerUnitPostingUpdater unitPostingUpdater = new LedgerUnitPostingUpdater(); + + public void apply(LedgerBackedPortfolioTransaction transaction, LedgerPortfolioTransferEdit edit) + { + apply(transaction.getLedgerEntry(), edit); + } + + public void apply(LedgerEntry entry, LedgerPortfolioTransferEdit edit) + { + Objects.requireNonNull(entry); + Objects.requireNonNull(edit); + + if (entry.getType() != LedgerEntryType.SECURITY_TRANSFER) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_057 + .message("Unsupported portfolio transfer edit for " + entry.getType())); //$NON-NLS-1$ + + var sourceProjection = projection(entry, LedgerProjectionRole.SOURCE_PORTFOLIO); + var targetProjection = projection(entry, LedgerProjectionRole.TARGET_PORTFOLIO); + var sourcePostingUUID = LedgerProjectionSupport.primaryPosting(entry, sourceProjection).getUUID(); + var targetPostingUUID = LedgerProjectionSupport.primaryPosting(entry, targetProjection).getUUID(); + var sourcePortfolio = sourceProjection.getPortfolio(); + var targetPortfolio = targetProjection.getPortfolio(); + + LedgerEntryEditSupport.applyValidated(entry, editedEntry -> applyEdit(editedEntry, edit, + sourceProjection.getUUID(), sourcePortfolio, targetProjection.getUUID(), targetPortfolio, + sourcePostingUUID, targetPostingUUID)); + } + + public void validate(LedgerEntry entry, LedgerPortfolioTransferEdit edit) + { + Objects.requireNonNull(entry); + Objects.requireNonNull(edit); + + if (entry.getType() != LedgerEntryType.SECURITY_TRANSFER) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_058 + .message("Unsupported portfolio transfer edit for " + entry.getType())); //$NON-NLS-1$ + + var sourceProjection = projection(entry, LedgerProjectionRole.SOURCE_PORTFOLIO); + var targetProjection = projection(entry, LedgerProjectionRole.TARGET_PORTFOLIO); + var sourcePostingUUID = LedgerProjectionSupport.primaryPosting(entry, sourceProjection).getUUID(); + var targetPostingUUID = LedgerProjectionSupport.primaryPosting(entry, targetProjection).getUUID(); + var sourcePortfolio = sourceProjection.getPortfolio(); + var targetPortfolio = targetProjection.getPortfolio(); + + LedgerEntryEditSupport.validatePatch(entry, editedEntry -> applyEdit(editedEntry, edit, + sourceProjection.getUUID(), sourcePortfolio, targetProjection.getUUID(), targetPortfolio, + sourcePostingUUID, targetPostingUUID)); + } + + private void applyEdit(LedgerEntry editedEntry, LedgerPortfolioTransferEdit edit, String sourceProjectionUUID, + name.abuchen.portfolio.model.Portfolio sourcePortfolio, String targetProjectionUUID, + name.abuchen.portfolio.model.Portfolio targetPortfolio, String sourcePostingUUID, + String targetPostingUUID) + { + LedgerEntryMetadataPatchHelper.apply(editedEntry, edit.getMetadata()); + edit.getSourcePosting().applyTo(LedgerEntryEditSupport.postingByUUID(editedEntry, sourcePostingUUID)); + edit.getTargetPosting().applyTo(LedgerEntryEditSupport.postingByUUID(editedEntry, targetPostingUUID)); + unitPostingUpdater.apply(editedEntry, edit.getUnits()); + ensureOwners(editedEntry, sourceProjectionUUID, sourcePortfolio, targetProjectionUUID, targetPortfolio); + } + + private LedgerProjectionRef projection(LedgerEntry entry, LedgerProjectionRole role) + { + return entry.getProjectionRefs().stream() // + .filter(projection -> projection.getRole() == role) // + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Projection not found: " + role)); //$NON-NLS-1$ + } + + private void ensureOwners(LedgerEntry entry, String sourceProjectionUUID, + name.abuchen.portfolio.model.Portfolio source, String targetProjectionUUID, + name.abuchen.portfolio.model.Portfolio target) + { + var sourceProjection = entry.getProjectionRefs().stream() + .filter(projection -> projection.getUUID().equals(sourceProjectionUUID)).findFirst() + .orElseThrow(); + var targetProjection = entry.getProjectionRefs().stream() + .filter(projection -> projection.getUUID().equals(targetProjectionUUID)).findFirst() + .orElseThrow(); + + if (sourceProjection.getPortfolio() != source || targetProjection.getPortfolio() != target) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_057 + .message("Portfolio transfer owner changes are not supported")); //$NON-NLS-1$ + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferLeg.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferLeg.java new file mode 100644 index 0000000000..9a9553ee87 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferLeg.java @@ -0,0 +1,50 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.util.Objects; + +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.money.Money; + +/** + * Carries portfolio transfer leg data for Ledger compatibility creators, editors, or patchers. + * This is a compatibility-layer value object. Contributor code may pass it to Ledger write + * paths, but it does not mutate Ledger truth by itself. + */ +public final class LedgerPortfolioTransferLeg +{ + private final Portfolio portfolio; + private final Money value; + private final LedgerForexAmount forex; + + private LedgerPortfolioTransferLeg(Portfolio portfolio, Money value, LedgerForexAmount forex) + { + this.portfolio = Objects.requireNonNull(portfolio); + this.value = Objects.requireNonNull(value); + this.forex = Objects.requireNonNull(forex); + } + + public static LedgerPortfolioTransferLeg of(Portfolio portfolio, Money value) + { + return of(portfolio, value, LedgerForexAmount.none()); + } + + public static LedgerPortfolioTransferLeg of(Portfolio portfolio, Money value, LedgerForexAmount forex) + { + return new LedgerPortfolioTransferLeg(portfolio, value, forex); + } + + public Portfolio getPortfolio() + { + return portfolio; + } + + public Money getValue() + { + return value; + } + + public LedgerForexAmount getForex() + { + return forex; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferSecurity.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferSecurity.java new file mode 100644 index 0000000000..00ba243b20 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferSecurity.java @@ -0,0 +1,37 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.util.Objects; + +import name.abuchen.portfolio.model.Security; + +/** + * Carries portfolio transfer security data for Ledger compatibility creators, editors, or patchers. + * This is a compatibility-layer value object. Contributor code may pass it to Ledger write + * paths, but it does not mutate Ledger truth by itself. + */ +public final class LedgerPortfolioTransferSecurity +{ + private final Security security; + private final long shares; + + private LedgerPortfolioTransferSecurity(Security security, long shares) + { + this.security = Objects.requireNonNull(security); + this.shares = shares; + } + + public static LedgerPortfolioTransferSecurity of(Security security, long shares) + { + return new LedgerPortfolioTransferSecurity(security, shares); + } + + public Security getSecurity() + { + return security; + } + + public long getShares() + { + return shares; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferTransactionCreator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferTransactionCreator.java new file mode 100644 index 0000000000..ec161546c0 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferTransactionCreator.java @@ -0,0 +1,211 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.time.LocalDateTime; +import java.util.Objects; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.PortfolioTransaction; +import name.abuchen.portfolio.model.PortfolioTransferEntry; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.ledger.LedgerEntryMetadataPatch; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.LedgerTransactionMetadata; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedPortfolioTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionService; +import name.abuchen.portfolio.money.Money; + +/** + * Creates and updates ledger-backed portfolio transfer transactions. + * This class is part of the Ledger compatibility layer for existing UI, import, and action + * code. Contributor code should use it instead of mutating projected legacy transactions. + */ +public final class LedgerPortfolioTransferTransactionCreator +{ + private final Client client; + + public LedgerPortfolioTransferTransactionCreator(Client client) + { + this.client = Objects.requireNonNull(client); + } + + public PortfolioTransferEntry create(Portfolio sourcePortfolio, Portfolio targetPortfolio, Security security, + LocalDateTime dateTime, long shares, long amount, String currencyCode, String note, String source) + { + return create(sourcePortfolio, targetPortfolio, security, dateTime, shares, amount, currencyCode, + LedgerForexAmount.none(), LedgerForexAmount.none(), LedgerUnitPostingPatch.none(), note, + source); + } + + public PortfolioTransferEntry create(Portfolio sourcePortfolio, Portfolio targetPortfolio, Security security, + LocalDateTime dateTime, long shares, long amount, String currencyCode, + LedgerForexAmount sourceForex, LedgerForexAmount targetForex, LedgerUnitPostingPatch units, + String note, String source) + { + Objects.requireNonNull(sourcePortfolio); + Objects.requireNonNull(targetPortfolio); + Objects.requireNonNull(security); + Objects.requireNonNull(dateTime); + Objects.requireNonNull(currencyCode); + Objects.requireNonNull(sourceForex); + Objects.requireNonNull(targetForex); + Objects.requireNonNull(units); + + var metadata = LedgerTransactionMetadata.of(dateTime).withNote(note).withSource(source); + var value = Money.of(currencyCode, amount); + var creator = new LedgerTransactionCreator(client); + var entry = creator.createPortfolioTransferEntry(metadata, LedgerPortfolioTransferSecurity.of(security, shares), + LedgerPortfolioTransferLeg.of(sourcePortfolio, value, sourceForex), + LedgerPortfolioTransferLeg.of(targetPortfolio, value, targetForex)); + + new LedgerUnitPostingUpdater().apply(entry, units); + var created = creator.add(entry); + + return materializeAndWrap(sourcePortfolio, targetPortfolio, created); + } + + public boolean isLedgerBacked(PortfolioTransferEntry entry) + { + return entry.getSourceTransaction() instanceof LedgerBackedTransaction + || entry.getTargetTransaction() instanceof LedgerBackedTransaction; + } + + public boolean canUpdate(PortfolioTransferEntry entry) + { + return entry.getSourceTransaction() instanceof LedgerBackedPortfolioTransaction + && entry.getTargetTransaction() instanceof LedgerBackedPortfolioTransaction + && entry.getSourceTransaction().getType() == PortfolioTransaction.Type.TRANSFER_OUT + && entry.getTargetTransaction().getType() == PortfolioTransaction.Type.TRANSFER_IN; + } + + public PortfolioTransferEntry update(PortfolioTransferEntry entry, Portfolio sourcePortfolio, + Portfolio targetPortfolio, Security security, LocalDateTime dateTime, long shares, long amount, + String currencyCode, String note, String source) + { + return update(entry, sourcePortfolio, targetPortfolio, security, dateTime, shares, amount, currencyCode, null, + null, LedgerUnitPostingPatch.none(), note, source); + } + + public PortfolioTransferEntry update(PortfolioTransferEntry entry, Portfolio sourcePortfolio, + Portfolio targetPortfolio, Security security, LocalDateTime dateTime, long shares, long amount, + String currencyCode, LedgerForexAmount sourceForex, LedgerForexAmount targetForex, + LedgerUnitPostingPatch units, String note, String source) + { + Objects.requireNonNull(entry); + Objects.requireNonNull(sourcePortfolio); + Objects.requireNonNull(targetPortfolio); + Objects.requireNonNull(security); + Objects.requireNonNull(dateTime); + Objects.requireNonNull(currencyCode); + Objects.requireNonNull(units); + + if (!canUpdate(entry)) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_CONVERT_059.message("Only ledger-backed portfolio transfers can be updated")); //$NON-NLS-1$ + + var sourceTransaction = (LedgerBackedPortfolioTransaction) entry.getSourceTransaction(); + var targetTransaction = (LedgerBackedPortfolioTransaction) entry.getTargetTransaction(); + + var ledgerEntry = sourceTransaction.getLedgerEntry(); + var sourceProjectionUUID = sourceTransaction.getLedgerProjectionRef().getUUID(); + var targetProjectionUUID = targetTransaction.getLedgerProjectionRef().getUUID(); + var ownerPatchHelper = new LedgerOwnerPatchHelper(client); + + var editBuilder = LedgerPortfolioTransferEdit.builder() + .metadata(LedgerEntryMetadataPatch.builder().dateTime(dateTime).note(note).source(source) + .build()) + .sourceSecurity(security) + .sourceShares(shares) + .sourceAmount(amount) + .sourceCurrency(currencyCode) + .targetSecurity(security) + .targetShares(shares) + .targetAmount(amount) + .targetCurrency(currencyCode) + .units(units); + + applySourceForex(editBuilder, sourceForex); + applyTargetForex(editBuilder, targetForex); + + var edit = editBuilder.build(); + var editor = new LedgerPortfolioTransferEditor(); + + if (sourceTransaction.getLedgerProjectionRef().getPortfolio() != sourcePortfolio + || targetTransaction.getLedgerProjectionRef().getPortfolio() != targetPortfolio) + editor.validate(ledgerEntry, edit); + + if (sourceTransaction.getLedgerProjectionRef().getPortfolio() != sourcePortfolio) + ownerPatchHelper.movePortfolioTransferSource(ledgerEntry, sourcePortfolio); + + if (targetTransaction.getLedgerProjectionRef().getPortfolio() != targetPortfolio) + ownerPatchHelper.movePortfolioTransferTarget(ledgerEntry, targetPortfolio); + + sourceTransaction = (LedgerBackedPortfolioTransaction) find(sourcePortfolio, sourceProjectionUUID); + targetTransaction = (LedgerBackedPortfolioTransaction) find(targetPortfolio, targetProjectionUUID); + entry = (PortfolioTransferEntry) sourceTransaction.getCrossEntry(); + + editor.apply(sourceTransaction, edit); + + return entry; + } + + private void applySourceForex(LedgerPortfolioTransferEdit.Builder builder, LedgerForexAmount forex) + { + if (forex == null) + return; + + if (forex.isPresent()) + builder.sourceForexAmount(forex.getForexAmount().getAmount()) + .sourceForexCurrency(forex.getForexAmount().getCurrencyCode()) + .sourceExchangeRate(forex.getExchangeRate()); + else + builder.sourceForexAmount(null).sourceForexCurrency(null).sourceExchangeRate(null); + } + + private void applyTargetForex(LedgerPortfolioTransferEdit.Builder builder, LedgerForexAmount forex) + { + if (forex == null) + return; + + if (forex.isPresent()) + builder.targetForexAmount(forex.getForexAmount().getAmount()) + .targetForexCurrency(forex.getForexAmount().getCurrencyCode()) + .targetExchangeRate(forex.getExchangeRate()); + else + builder.targetForexAmount(null).targetForexCurrency(null).targetExchangeRate(null); + } + + private PortfolioTransferEntry materializeAndWrap(Portfolio sourcePortfolio, Portfolio targetPortfolio, + LedgerTransactionCreator.CreatedTransaction created) + { + var sourceProjectionUUID = created.getProjectionRefs().stream() + .filter(projection -> projection.getRole() == LedgerProjectionRole.SOURCE_PORTFOLIO) + .findFirst() + .orElseThrow() + .getUUID(); + var targetProjectionUUID = created.getProjectionRefs().stream() + .filter(projection -> projection.getRole() == LedgerProjectionRole.TARGET_PORTFOLIO) + .findFirst() + .orElseThrow() + .getUUID(); + + LedgerProjectionService.materialize(client); + + var sourceTransaction = find(sourcePortfolio, sourceProjectionUUID); + var targetTransaction = find(targetPortfolio, targetProjectionUUID); + + return PortfolioTransferEntry.readOnly(sourcePortfolio, sourceTransaction, targetPortfolio, targetTransaction); + } + + private PortfolioTransaction find(Portfolio portfolio, String projectionUUID) + { + return portfolio.getTransactions().stream() + .filter(LedgerBackedTransaction.class::isInstance) + .filter(transaction -> projectionUUID.equals(transaction.getUUID())) + .findFirst() + .orElseThrow(() -> new IllegalStateException( + "Ledger portfolio transfer projection was not materialized: " //$NON-NLS-1$ + + projectionUUID)); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerSecurityQuantity.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerSecurityQuantity.java new file mode 100644 index 0000000000..5348693f2f --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerSecurityQuantity.java @@ -0,0 +1,37 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.util.Objects; + +import name.abuchen.portfolio.model.Security; + +/** + * Carries security quantity data for Ledger compatibility creators, editors, or patchers. + * This is a compatibility-layer value object. Contributor code may pass it to Ledger write + * paths, but it does not mutate Ledger truth by itself. + */ +public final class LedgerSecurityQuantity +{ + private final Security security; + private final long shares; + + private LedgerSecurityQuantity(Security security, long shares) + { + this.security = Objects.requireNonNull(security); + this.shares = shares; + } + + public static LedgerSecurityQuantity of(Security security, long shares) + { + return new LedgerSecurityQuantity(security, shares); + } + + public Security getSecurity() + { + return security; + } + + public long getShares() + { + return shares; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerTransactionCreator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerTransactionCreator.java new file mode 100644 index 0000000000..3d54065abc --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerTransactionCreator.java @@ -0,0 +1,448 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerMutationContext; +import name.abuchen.portfolio.model.ledger.LedgerParameter; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.LedgerTransactionMetadata; +import name.abuchen.portfolio.model.ledger.ProjectionMembershipRole; +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.Money; + +/** + * Creates and updates ledger-backed transactions. + * This class is part of the Ledger compatibility layer for existing UI, import, and action + * code. Contributor code should use it instead of mutating projected legacy transactions. + */ +public final class LedgerTransactionCreator +{ + private final Client client; + + public LedgerTransactionCreator(Client client) + { + this.client = Objects.requireNonNull(client); + } + + public CreatedTransaction createDeposit(LedgerTransactionMetadata metadata, LedgerAccountCashLeg cashLeg) + { + return createDeposit(metadata, cashLeg, LedgerCreationUnits.none()); + } + + public CreatedTransaction createDeposit(LedgerTransactionMetadata metadata, LedgerAccountCashLeg cashLeg, + LedgerCreationUnits units) + { + return add(createAccountEntry(metadata, LedgerEntryType.DEPOSIT, LedgerPostingType.CASH, cashLeg, + LedgerOptionalSecurity.none(), units)); + } + + public CreatedTransaction createRemoval(LedgerTransactionMetadata metadata, LedgerAccountCashLeg cashLeg) + { + return createRemoval(metadata, cashLeg, LedgerCreationUnits.none()); + } + + public CreatedTransaction createRemoval(LedgerTransactionMetadata metadata, LedgerAccountCashLeg cashLeg, + LedgerCreationUnits units) + { + return add(createAccountEntry(metadata, LedgerEntryType.REMOVAL, LedgerPostingType.CASH, cashLeg, + LedgerOptionalSecurity.none(), units)); + } + + public CreatedTransaction createInterest(LedgerTransactionMetadata metadata, LedgerAccountCashLeg cashLeg, + LedgerOptionalSecurity security, LedgerCreationUnits units) + { + return add(createAccountEntry(metadata, LedgerEntryType.INTEREST, LedgerPostingType.CASH, cashLeg, security, + units)); + } + + public CreatedTransaction createInterestCharge(LedgerTransactionMetadata metadata, LedgerAccountCashLeg cashLeg, + LedgerOptionalSecurity security, LedgerCreationUnits units) + { + return add(createAccountEntry(metadata, LedgerEntryType.INTEREST_CHARGE, LedgerPostingType.CASH, cashLeg, + security, units)); + } + + public CreatedTransaction createFee(LedgerTransactionMetadata metadata, LedgerAccountCashLeg cashLeg, + LedgerOptionalSecurity security, LedgerCreationUnits units) + { + return add(createAccountEntry(metadata, LedgerEntryType.FEES, LedgerPostingType.FEE, cashLeg, security, units)); + } + + public CreatedTransaction createFeeRefund(LedgerTransactionMetadata metadata, LedgerAccountCashLeg cashLeg, + LedgerOptionalSecurity security, LedgerCreationUnits units) + { + return add(createAccountEntry(metadata, LedgerEntryType.FEES_REFUND, LedgerPostingType.FEE, cashLeg, security, + units)); + } + + public CreatedTransaction createTax(LedgerTransactionMetadata metadata, LedgerAccountCashLeg cashLeg, + LedgerOptionalSecurity security, LedgerCreationUnits units) + { + return add(createAccountEntry(metadata, LedgerEntryType.TAXES, LedgerPostingType.TAX, cashLeg, security, units)); + } + + public CreatedTransaction createTaxRefund(LedgerTransactionMetadata metadata, LedgerAccountCashLeg cashLeg, + LedgerOptionalSecurity security, LedgerCreationUnits units) + { + return add(createAccountEntry(metadata, LedgerEntryType.TAX_REFUND, LedgerPostingType.TAX, cashLeg, security, + units)); + } + + public CreatedTransaction createDividend(LedgerTransactionMetadata metadata, LedgerDividend dividend) + { + Objects.requireNonNull(dividend); + + var entry = createBaseEntry(metadata, LedgerEntryType.DIVIDENDS); + var cashPosting = createCashPosting(LedgerPostingType.CASH, dividend.getCashLeg()); + + applySecurity(cashPosting, dividend.getSecurity()); + cashPosting.setShares(dividend.getShares()); + + if (dividend.hasExDate()) + cashPosting.addParameter( + LedgerParameter.ofLocalDateTime(LedgerParameterType.EX_DATE, + dividend.getExDate())); + + entry.addPosting(cashPosting); + var unitPostings = addUnitPostings(entry, dividend.getUnits()); + var projection = accountProjection(dividend.getCashLeg().getAccount(), cashPosting); + addUnitMemberships(projection, unitPostings); + entry.addProjectionRef(projection); + + return add(entry); + } + + public CreatedTransaction createInboundDelivery(LedgerTransactionMetadata metadata, LedgerDeliveryLeg deliveryLeg) + { + return add(createDeliveryEntry(metadata, LedgerEntryType.DELIVERY_INBOUND, LedgerProjectionRole.DELIVERY_INBOUND, + deliveryLeg)); + } + + public CreatedTransaction createOutboundDelivery(LedgerTransactionMetadata metadata, LedgerDeliveryLeg deliveryLeg) + { + return add(createDeliveryEntry(metadata, LedgerEntryType.DELIVERY_OUTBOUND, + LedgerProjectionRole.DELIVERY_OUTBOUND, deliveryLeg)); + } + + public CreatedTransaction createBuy(LedgerTransactionMetadata metadata, LedgerAccountCashLeg cashLeg, + LedgerPortfolioSecurityLeg securityLeg, LedgerCreationUnits units) + { + return add(createBuySellEntry(metadata, LedgerEntryType.BUY, cashLeg, securityLeg, units)); + } + + public CreatedTransaction createSell(LedgerTransactionMetadata metadata, LedgerAccountCashLeg cashLeg, + LedgerPortfolioSecurityLeg securityLeg, LedgerCreationUnits units) + { + return add(createBuySellEntry(metadata, LedgerEntryType.SELL, cashLeg, securityLeg, units)); + } + + public CreatedTransaction createAccountTransfer(LedgerTransactionMetadata metadata, LedgerCashTransferLeg source, + LedgerCashTransferLeg target) + { + return add(createAccountTransferEntry(metadata, source, target)); + } + + LedgerEntry createAccountTransferEntry(LedgerTransactionMetadata metadata, LedgerCashTransferLeg source, + LedgerCashTransferLeg target) + { + Objects.requireNonNull(source); + Objects.requireNonNull(target); + + var entry = createBaseEntry(metadata, LedgerEntryType.CASH_TRANSFER); + + var sourcePosting = createCashPosting(LedgerPostingType.CASH, source); + var targetPosting = createCashPosting(LedgerPostingType.CASH, target); + + entry.addPosting(sourcePosting); + entry.addPosting(targetPosting); + entry.addProjectionRef(accountProjection(LedgerProjectionRole.SOURCE_ACCOUNT, source.getAccount(), + sourcePosting)); + entry.addProjectionRef(accountProjection(LedgerProjectionRole.TARGET_ACCOUNT, target.getAccount(), + targetPosting)); + + return entry; + } + + public CreatedTransaction createPortfolioTransfer(LedgerTransactionMetadata metadata, + LedgerPortfolioTransferSecurity security, LedgerPortfolioTransferLeg source, + LedgerPortfolioTransferLeg target) + { + return add(createPortfolioTransferEntry(metadata, security, source, target)); + } + + LedgerEntry createPortfolioTransferEntry(LedgerTransactionMetadata metadata, + LedgerPortfolioTransferSecurity security, LedgerPortfolioTransferLeg source, + LedgerPortfolioTransferLeg target) + { + Objects.requireNonNull(security); + Objects.requireNonNull(source); + Objects.requireNonNull(target); + + var entry = createBaseEntry(metadata, LedgerEntryType.SECURITY_TRANSFER); + + var sourcePosting = createSecurityPosting(source, security); + var targetPosting = createSecurityPosting(target, security); + + entry.addPosting(sourcePosting); + entry.addPosting(targetPosting); + entry.addProjectionRef(portfolioProjection(LedgerProjectionRole.SOURCE_PORTFOLIO, source.getPortfolio(), + sourcePosting)); + entry.addProjectionRef(portfolioProjection(LedgerProjectionRole.TARGET_PORTFOLIO, target.getPortfolio(), + targetPosting)); + + return entry; + } + + private LedgerEntry createAccountEntry(LedgerTransactionMetadata metadata, LedgerEntryType entryType, + LedgerPostingType postingType, LedgerAccountCashLeg cashLeg, LedgerOptionalSecurity security, + LedgerCreationUnits units) + { + Objects.requireNonNull(cashLeg); + Objects.requireNonNull(security); + Objects.requireNonNull(units); + + var entry = createBaseEntry(metadata, entryType); + var posting = createCashPosting(postingType, cashLeg); + + applySecurity(posting, security); + + entry.addPosting(posting); + var unitPostings = addUnitPostings(entry, units); + var projection = accountProjection(cashLeg.getAccount(), posting); + addUnitMemberships(projection, unitPostings); + entry.addProjectionRef(projection); + + return entry; + } + + private LedgerEntry createDeliveryEntry(LedgerTransactionMetadata metadata, LedgerEntryType entryType, + LedgerProjectionRole role, LedgerDeliveryLeg deliveryLeg) + { + Objects.requireNonNull(deliveryLeg); + + var entry = createBaseEntry(metadata, entryType); + var posting = new LedgerPosting(); + var securityQuantity = deliveryLeg.getSecurityQuantity(); + var value = deliveryLeg.getValue(); + + posting.setType(LedgerPostingType.SECURITY); + posting.setPortfolio(deliveryLeg.getPortfolio()); + posting.setSecurity(securityQuantity.getSecurity()); + posting.setShares(securityQuantity.getShares()); + applyMoney(posting, value, deliveryLeg.getForex()); + + entry.addPosting(posting); + var unitPostings = addUnitPostings(entry, deliveryLeg.getUnits()); + var projection = portfolioProjection(role, deliveryLeg.getPortfolio(), posting); + addUnitMemberships(projection, unitPostings); + entry.addProjectionRef(projection); + + return entry; + } + + private LedgerEntry createBuySellEntry(LedgerTransactionMetadata metadata, LedgerEntryType entryType, + LedgerAccountCashLeg cashLeg, LedgerPortfolioSecurityLeg securityLeg, LedgerCreationUnits units) + { + Objects.requireNonNull(cashLeg); + Objects.requireNonNull(securityLeg); + Objects.requireNonNull(units); + + var entry = createBaseEntry(metadata, entryType); + + var cashPosting = createCashPosting(LedgerPostingType.CASH, cashLeg); + var securityPosting = createSecurityPosting(securityLeg); + + entry.addPosting(cashPosting); + entry.addPosting(securityPosting); + var unitPostings = addUnitPostings(entry, units); + var accountProjection = accountProjection(cashLeg.getAccount(), cashPosting); + var portfolioProjection = portfolioProjection(LedgerProjectionRole.PORTFOLIO, securityLeg.getPortfolio(), + securityPosting); + addUnitMemberships(accountProjection, unitPostings); + addUnitMemberships(portfolioProjection, unitPostings); + entry.addProjectionRef(accountProjection); + entry.addProjectionRef(portfolioProjection); + + return entry; + } + + private LedgerEntry createBaseEntry(LedgerTransactionMetadata metadata, LedgerEntryType type) + { + Objects.requireNonNull(metadata); + + var entry = new LedgerEntry(); + + entry.setType(type); + entry.setDateTime(metadata.getDateTime()); + entry.setNote(metadata.getNote()); + entry.setSource(metadata.getSource()); + + return entry; + } + + private LedgerPosting createCashPosting(LedgerPostingType postingType, LedgerAccountCashLeg cashLeg) + { + var posting = new LedgerPosting(); + + posting.setType(postingType); + posting.setAccount(cashLeg.getAccount()); + applyMoney(posting, cashLeg.getAmount(), cashLeg.getForex()); + + return posting; + } + + private LedgerPosting createCashPosting(LedgerPostingType postingType, LedgerCashTransferLeg cashLeg) + { + var posting = new LedgerPosting(); + + posting.setType(postingType); + posting.setAccount(cashLeg.getAccount()); + applyMoney(posting, cashLeg.getAmount(), cashLeg.getForex()); + + return posting; + } + + private LedgerPosting createSecurityPosting(LedgerPortfolioSecurityLeg securityLeg) + { + var posting = new LedgerPosting(); + var securityQuantity = securityLeg.getSecurityQuantity(); + + posting.setType(LedgerPostingType.SECURITY); + posting.setPortfolio(securityLeg.getPortfolio()); + posting.setSecurity(securityQuantity.getSecurity()); + posting.setShares(securityQuantity.getShares()); + applyMoney(posting, securityLeg.getValue(), securityLeg.getForex()); + + return posting; + } + + private LedgerPosting createSecurityPosting(LedgerPortfolioTransferLeg leg, + LedgerPortfolioTransferSecurity security) + { + var posting = new LedgerPosting(); + + posting.setType(LedgerPostingType.SECURITY); + posting.setPortfolio(leg.getPortfolio()); + posting.setSecurity(security.getSecurity()); + posting.setShares(security.getShares()); + applyMoney(posting, leg.getValue(), leg.getForex()); + + return posting; + } + + private List addUnitPostings(LedgerEntry entry, LedgerCreationUnits units) + { + var postings = new ArrayList(); + + for (var unit : units.getUnits()) + { + var posting = new LedgerPosting(); + + posting.setType(unit.getPostingType()); + applyMoney(posting, unit.getAmount(), unit.getForex()); + entry.addPosting(posting); + postings.add(posting); + } + + return List.copyOf(postings); + } + + private void addUnitMemberships(LedgerProjectionRef projection, List unitPostings) + { + for (var posting : unitPostings) + { + switch (posting.getType()) + { + case FEE -> projection.addMembership(posting.getUUID(), ProjectionMembershipRole.FEE_UNIT); + case TAX -> projection.addMembership(posting.getUUID(), ProjectionMembershipRole.TAX_UNIT); + case GROSS_VALUE -> projection.addMembership(posting.getUUID(), ProjectionMembershipRole.GROSS_VALUE_UNIT); + default -> throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_062.message("Unsupported unit posting type: " + posting.getType())); //$NON-NLS-1$ + } + } + } + + private void applyMoney(LedgerPosting posting, Money amount, LedgerForexAmount forex) + { + posting.setAmount(amount.getAmount()); + posting.setCurrency(amount.getCurrencyCode()); + + if (forex.isPresent()) + { + posting.setForexAmount(forex.getForexAmount().getAmount()); + posting.setForexCurrency(forex.getForexAmount().getCurrencyCode()); + posting.setExchangeRate(forex.getExchangeRate()); + } + } + + private void applySecurity(LedgerPosting posting, LedgerOptionalSecurity security) + { + if (security.isPresent()) + posting.setSecurity(security.getSecurity()); + } + + private LedgerProjectionRef accountProjection(Account account, LedgerPosting posting) + { + return accountProjection(LedgerProjectionRole.ACCOUNT, account, posting); + } + + private LedgerProjectionRef accountProjection(LedgerProjectionRole role, Account account, LedgerPosting posting) + { + var projection = new LedgerProjectionRef(); + + projection.setRole(role); + projection.setAccount(account); + projection.setPrimaryPosting(posting); + + return projection; + } + + private LedgerProjectionRef portfolioProjection(LedgerProjectionRole role, Portfolio portfolio, + LedgerPosting posting) + { + var projection = new LedgerProjectionRef(); + + projection.setRole(role); + projection.setPortfolio(portfolio); + projection.setPrimaryPosting(posting); + + return projection; + } + + CreatedTransaction add(LedgerEntry entry) + { + var liveEntry = new LedgerMutationContext(client).attachEntry(entry); + + return new CreatedTransaction(liveEntry); + } + + public static final class CreatedTransaction + { + private final LedgerEntry entry; + + private CreatedTransaction(LedgerEntry entry) + { + this.entry = entry; + } + + public LedgerEntry getEntry() + { + return entry; + } + + public List getProjectionRefs() + { + return entry.getProjectionRefs(); + } + } +} From cb05774390db5d5e632804354558a814b464c0ab Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Fri, 26 Jun 2026 14:29:59 +0200 Subject: [PATCH 06/68] Wire Ledger into client and legacy transaction access Wire Ledger into client ownership, legacy transaction access, and migration compatibility paths. This connects Ledger truth to existing model APIs without mixing in UI review concerns. Persistence schema, UI routing, diagnostics/NLS, and documentation are intentionally left unchanged here. --- .../checks/impl/CrossEntryCheckTest.java | 568 +++++- .../portfolio/model/ClientLedgerTest.java | 107 ++ .../portfolio/model/CrossEntryTest.java | 87 + ...LegacyTransactionToLedgerMigratorTest.java | 1689 +++++++++++++++++ .../portfolio/model/AccountTransferEntry.java | 67 +- .../abuchen/portfolio/model/BuySellEntry.java | 61 +- .../name/abuchen/portfolio/model/Client.java | 11 + .../model/PortfolioTransferEntry.java | 71 +- .../abuchen/portfolio/model/Transaction.java | 2 +- .../portfolio/model/TransactionOwner.java | 9 + .../portfolio/model/TransactionPair.java | 16 + .../LegacyTransactionToLedgerMigrator.java | 1129 +++++++++++ 12 files changed, 3778 insertions(+), 39 deletions(-) create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ClientLedgerTest.java create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigratorTest.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigrator.java diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/checks/impl/CrossEntryCheckTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/checks/impl/CrossEntryCheckTest.java index 637e3fe413..7b9d5da121 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/checks/impl/CrossEntryCheckTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/checks/impl/CrossEntryCheckTest.java @@ -3,9 +3,15 @@ import static org.hamcrest.CoreMatchers.hasItem; import static org.hamcrest.CoreMatchers.instanceOf; import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.CoreMatchers.notNullValue; import static org.hamcrest.MatcherAssert.assertThat; - +import static org.junit.Assert.assertFalse; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.ArrayList; @@ -21,12 +27,35 @@ import name.abuchen.portfolio.model.AccountTransaction; import name.abuchen.portfolio.model.BuySellEntry; import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.ClientFactory; +import name.abuchen.portfolio.model.InvestmentPlan; import name.abuchen.portfolio.model.Portfolio; import name.abuchen.portfolio.model.PortfolioTransaction; import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.SecurityPrice; +import name.abuchen.portfolio.model.Transaction; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountOnlyTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountTransferTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerBuySellTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerDeliveryTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferTransactionCreator; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionService; import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.Values; import name.abuchen.portfolio.snapshot.ClientSnapshot; +/** + * Tests check and repair choices for broken cross-entry and transaction facts. + * These tests make sure damaged business facts are diagnosed without reconstructing currency, security, or transfer structure by guessing. + */ public class CrossEntryCheckTest { private Client client; @@ -46,12 +75,20 @@ public void setupClient() client.addSecurity(security); } + /** + * Verifies that an empty client has no cross-entry or repair issues. + * The check must not report synthetic ledger or legacy problems. + */ @Test public void testEmptyClient() { assertThat(new CrossEntryCheck().execute(client).size(), is(0)); } + /** + * Verifies that a sell missing its account-side booking is diagnosed. + * The issue represents a broken business booking and must not be silently ignored. + */ @Test public void testMissingSellInAccountIssue() { @@ -63,10 +100,15 @@ public void testMissingSellInAccountIssue() assertThat(issues.size(), is(1)); assertThat(issues.get(0), is(instanceOf(MissingBuySellAccountIssue.class))); assertThat(issues.get(0).getEntity(), is((Object) portfolio)); + assertOnlyDeleteFix(issues.get(0)); applyFixes(client, issues); } + /** + * Verifies that a buy missing its account-side booking is diagnosed. + * The check must catch broken cross-entry structure for legacy buy/sell rows. + */ @Test public void testMissingBuyInAccountIssue() { @@ -78,10 +120,15 @@ public void testMissingBuyInAccountIssue() assertThat(issues.size(), is(1)); assertThat(issues.get(0), is(instanceOf(MissingBuySellAccountIssue.class))); assertThat(issues.get(0).getEntity(), is((Object) portfolio)); + assertOnlyDeleteFix(issues.get(0)); applyFixes(client, issues); } + /** + * Verifies that complete buy/sell entries are not reported as damaged. + * A valid cross entry must not receive any repair or delete suggestion. + */ @Test public void testThatCorrectBuySellEntriesAreNotReported() { @@ -97,8 +144,421 @@ public void testThatCorrectBuySellEntriesAreNotReported() assertThat(new CrossEntryCheck().execute(client).size(), is(0)); } + /** + * Verifies that valid ledger-backed cross-entry families are not reported as broken. + * Runtime projections derived from the ledger must be accepted when the ledger entry is structurally valid. + */ + @Test + public void testThatLedgerBackedCrossEntryFamiliesAreNotReported() + { + Account secondAccount = new Account(); + client.addAccount(secondAccount); + Portfolio secondPortfolio = new Portfolio(); + client.addPortfolio(secondPortfolio); + + LocalDateTime date = LocalDateTime.of(2026, 6, 15, 10, 0); + new LedgerBuySellTransactionCreator(client).create(portfolio, account, PortfolioTransaction.Type.BUY, date, + Values.Amount.factorize(100), CurrencyUnit.EUR, security, Values.Share.factorize(5), + List.of(), "buy note", "buy source"); + new LedgerBuySellTransactionCreator(client).create(portfolio, account, PortfolioTransaction.Type.SELL, + date.plusDays(1), Values.Amount.factorize(110), CurrencyUnit.EUR, security, + Values.Share.factorize(5), List.of(), "sell note", "sell source"); + new LedgerAccountTransferTransactionCreator(client).create(account, secondAccount, date.plusDays(2), + Values.Amount.factorize(50), CurrencyUnit.EUR, Values.Amount.factorize(50), CurrencyUnit.EUR, + null, null, "transfer note", "transfer source"); + new LedgerPortfolioTransferTransactionCreator(client).create(portfolio, secondPortfolio, security, + date.plusDays(3), Values.Share.factorize(2), Values.Amount.factorize(40), CurrencyUnit.EUR, + "portfolio transfer note", "portfolio transfer source"); + + assertThat(new CrossEntryCheck().execute(client).size(), is(0)); + assertThat(client.getAllTransactions().size(), is(4)); + } + + /** + * Verifies that damaged ledger-backed cross-entry rows offer only a ledger-aware delete fix. + * The check must not rebuild cross entries from runtime projections. + */ + @Test + public void testLedgerBackedMissingCrossEntryIssuesOfferOnlyLedgerAwareDeleteFix() + { + Account secondAccount = new Account(); + client.addAccount(secondAccount); + Portfolio secondPortfolio = new Portfolio(); + client.addPortfolio(secondPortfolio); + + LocalDateTime date = LocalDateTime.of(2026, 6, 15, 10, 0); + BuySellEntry buy = new LedgerBuySellTransactionCreator(client).create(portfolio, account, + PortfolioTransaction.Type.BUY, date, Values.Amount.factorize(100), CurrencyUnit.EUR, security, + Values.Share.factorize(5), List.of(), "note", "source"); + var accountTransfer = new LedgerAccountTransferTransactionCreator(client).create(account, secondAccount, + date.plusDays(1), Values.Amount.factorize(50), CurrencyUnit.EUR, + Values.Amount.factorize(50), CurrencyUnit.EUR, null, null, "note", "source"); + var portfolioTransfer = new LedgerPortfolioTransferTransactionCreator(client).create(portfolio, + secondPortfolio, security, date.plusDays(2), Values.Share.factorize(2), + Values.Amount.factorize(40), CurrencyUnit.EUR, "note", "source"); + + assertOnlyDeleteFix(new MissingBuySellAccountIssue(client, portfolio, buy.getPortfolioTransaction())); + assertOnlyDeleteFix(new MissingBuySellPortfolioIssue(client, account, buy.getAccountTransaction())); + assertOnlyDeleteFix(new MissingAccountTransferIssue(client, account, accountTransfer.getSourceTransaction())); + assertOnlyDeleteFix(new MissingPortfolioTransferIssue(client, portfolio, + portfolioTransfer.getSourceTransaction())); + } + + /** + * Verifies that deleting a damaged ledger-backed cross entry removes the whole ledger entry. + * All derived account and portfolio projections must disappear together. + */ + @Test + public void testLedgerBackedDeleteFixDeletesWholeLedgerEntryAndAllProjections() + { + LocalDateTime date = LocalDateTime.of(2026, 6, 15, 10, 0); + BuySellEntry buy = new LedgerBuySellTransactionCreator(client).create(portfolio, account, + PortfolioTransaction.Type.BUY, date, Values.Amount.factorize(100), CurrencyUnit.EUR, security, + Values.Share.factorize(5), List.of(), "note", "source"); + + new DeleteTransactionFix(client, account, buy.getAccountTransaction()).execute(); + + assertThat(client.getLedger().getEntries().size(), is(0)); + assertThat(account.getTransactions().size(), is(0)); + assertThat(portfolio.getTransactions().size(), is(0)); + assertThat(client.getAllTransactions().size(), is(0)); + } + + /** + * Verifies that a delete fix also cleans plan execution refs for the deleted ledger entry. + * Save/load must not bring back a generated booking through a stale plan reference. + */ + @Test + public void testLedgerBackedDeleteFixRemovesInvestmentPlanRefs() throws IOException + { + security.addPrice(new SecurityPrice(LocalDate.now().minusMonths(2), Values.Quote.factorize(10))); + InvestmentPlan plan = createBuyPlan("deleted plan"); + InvestmentPlan unrelatedPlan = createBuyPlan("unrelated plan"); + client.addPlan(plan); + client.addPlan(unrelatedPlan); + + var generated = plan.generateTransactions(client, new TestCurrencyConverter()); + var unrelatedGenerated = unrelatedPlan.generateTransactions(client, new TestCurrencyConverter()); + var deletedPortfolioProjection = (PortfolioTransaction) generated.get(0).getTransaction(); + var deletedEntryUUID = ((LedgerBackedTransaction) deletedPortfolioProjection).getLedgerEntry().getUUID(); + var accountProjection = (AccountTransaction) deletedPortfolioProjection.getCrossEntry() + .getCrossTransaction(deletedPortfolioProjection); + var unrelatedEntryUUID = ((LedgerBackedTransaction) unrelatedGenerated.get(0).getTransaction()).getLedgerEntry() + .getUUID(); + + AccountTransaction legacyTransaction = new AccountTransaction(LocalDateTime.of(2026, 6, 15, 11, 0), + CurrencyUnit.EUR, Values.Amount.factorize(1), null, AccountTransaction.Type.DEPOSIT); + account.addTransaction(legacyTransaction); + + new DeleteTransactionFix(client, account, accountProjection).execute(); + + assertFalse(client.getLedger().getEntries().stream().anyMatch(entry -> entry.getUUID().equals(deletedEntryUUID))); + assertThat(client.getLedger().getEntries().stream().filter(entry -> entry.getUUID().equals(unrelatedEntryUUID)) + .count(), is(1L)); + assertThat(plan.getLedgerExecutionRefs(), is(List.of())); + assertThat(plan.getTransactions(client), is(List.of())); + assertThat(unrelatedPlan.getLedgerExecutionRefs().size(), is(1)); + assertThat(unrelatedPlan.getTransactions(client).size(), is(1)); + assertThat(account.getTransactions(), hasItem(legacyTransaction)); + assertFalse(account.getTransactions().contains(accountProjection)); + assertFalse(portfolio.getTransactions().contains(deletedPortfolioProjection)); + } + + /** + * Verifies that missing currency facts on ledger-backed bookings are delete-only. + * The fix must delete the ledger entry instead of guessing or setting a transaction currency. + */ + @Test + public void testLedgerBackedMissingCurrencyIssueOffersDeleteOnlyAndDeleteSurvivesXmlReload() throws Exception + { + LocalDateTime date = LocalDateTime.of(2026, 6, 15, 10, 0); + AccountTransaction transaction = new LedgerAccountOnlyTransactionCreator(client).create(account, + AccountTransaction.Type.FEES, date, Values.Amount.factorize(10), CurrencyUnit.EUR, security, + List.of(), "fee note", "fee source"); + String entryUUID = ledgerEntry(transaction).getUUID(); + + primaryPosting(transaction).setCurrency(null); + + List issues = new TransactionCurrencyCheck().execute(client); + + assertThat(issues.size(), is(1)); + assertOnlyDeleteFix(issues.get(0)).execute(); + + assertEntryDeleted(entryUUID); + assertThat(account.getTransactions().size(), is(0)); + + Client loaded = reloadXml(client); + assertThat(loaded.getLedger().getEntries().size(), is(0)); + assertThat(loaded.getAccounts().get(0).getTransactions().size(), is(0)); + } + + /** + * Verifies that missing security facts on ledger-backed portfolio rows are delete-only. + * The fix must not infer a security from the projected transaction or owner list. + */ + @Test + public void testLedgerBackedMissingSecurityIssueOffersDeleteOnlyAndDeletesLedgerEntry() + { + LocalDateTime date = LocalDateTime.of(2026, 6, 15, 10, 0); + PortfolioTransaction transaction = new LedgerDeliveryTransactionCreator(client).create(portfolio, + PortfolioTransaction.Type.DELIVERY_INBOUND, date, Values.Amount.factorize(20), + CurrencyUnit.EUR, security, Values.Share.factorize(2), null, null, List.of(), "delivery note", + "delivery source"); + String entryUUID = ledgerEntry(transaction).getUUID(); + + primaryPosting(transaction).setSecurity(null); + + List issues = new PortfolioTransactionWithoutSecurityCheck().execute(client); + + assertThat(issues.size(), is(1)); + assertOnlyDeleteFix(issues.get(0)).execute(); + + assertEntryDeleted(entryUUID); + assertThat(portfolio.getTransactions().size(), is(0)); + } + + /** + * Verifies that security-backed account bookings with missing security facts are delete-only. + * Dividend-like facts must not be reconstructed automatically. + */ + @Test + public void testLedgerBackedDividendSecurityIssueOffersDeleteOnly() + { + LocalDateTime date = LocalDateTime.of(2026, 6, 15, 10, 0); + AccountTransaction transaction = new LedgerAccountOnlyTransactionCreator(client).create(account, + AccountTransaction.Type.INTEREST, date, Values.Amount.factorize(30), CurrencyUnit.EUR, + security, List.of(), "interest note", "interest source"); + String entryUUID = ledgerEntry(transaction).getUUID(); + + ((LedgerBackedTransaction) transaction).getLedgerEntry().setType(LedgerEntryType.DIVIDENDS); + primaryPosting(transaction).setSecurity(null); + + List issues = new DividendsAndInterestCheck().execute(client); + + assertThat(issues.size(), is(1)); + assertOnlyDeleteFix(issues.get(0)).execute(); + + assertEntryDeleted(entryUUID); + assertThat(account.getTransactions().size(), is(0)); + } + + /** + * Verifies that missing buy/sell projections in a ledger entry are delete-only. + * The repair path must not recreate the missing projection as a second source of truth. + */ + @Test + public void testLedgerBackedMissingBuySellProjectionIssueOffersDeleteOnlyAndDeletesLedgerEntry() throws Exception + { + LocalDateTime date = LocalDateTime.of(2026, 6, 15, 10, 0); + BuySellEntry buy = new LedgerBuySellTransactionCreator(client).create(portfolio, account, + PortfolioTransaction.Type.BUY, date, Values.Amount.factorize(100), CurrencyUnit.EUR, security, + Values.Share.factorize(5), List.of(), "note", "source"); + LedgerEntry entry = ledgerEntry(buy.getAccountTransaction()); + String entryUUID = entry.getUUID(); + + entry.removeProjectionRef(projection(entry, LedgerProjectionRole.PORTFOLIO)); + rematerializeLedgerProjections(); + + List issues = new CrossEntryCheck().execute(client); + + assertThat(issues.size(), is(1)); + assertThat(issues.get(0), is(instanceOf(MissingBuySellPortfolioIssue.class))); + assertOnlyDeleteFix(issues.get(0)).execute(); + + assertEntryDeleted(entryUUID); + assertThat(account.getTransactions().size(), is(0)); + assertThat(portfolio.getTransactions().size(), is(0)); + assertThat(reloadXml(client).getLedger().getEntries().size(), is(0)); + } + + /** + * Verifies that missing transfer-side projections in ledger-backed transfers are delete-only. + * Transfer source and target structure must not be inferred from the remaining runtime side. + */ @Test - public void testThatMatchingBuySellEntriesAreFixed() + public void testLedgerBackedMissingTransferProjectionIssuesOfferDeleteOnlyAndDeleteLedgerEntry() + { + LocalDateTime date = LocalDateTime.of(2026, 6, 15, 10, 0); + Account targetAccount = new Account(); + client.addAccount(targetAccount); + + var accountTransfer = new LedgerAccountTransferTransactionCreator(client).create(account, targetAccount, date, + Values.Amount.factorize(50), CurrencyUnit.EUR, Values.Amount.factorize(50), CurrencyUnit.EUR, + null, null, "account transfer note", "account transfer source"); + LedgerEntry accountEntry = ledgerEntry(accountTransfer.getSourceTransaction()); + + accountEntry.removeProjectionRef(projection(accountEntry, LedgerProjectionRole.TARGET_ACCOUNT)); + rematerializeLedgerProjections(); + + List issues = new CrossEntryCheck().execute(client); + + assertThat(issues.size(), is(1)); + assertThat(issues.get(0), is(instanceOf(MissingAccountTransferIssue.class))); + assertOnlyDeleteFix(issues.get(0)).execute(); + + assertEntryDeleted(accountEntry.getUUID()); + assertThat(account.getTransactions().size(), is(0)); + assertThat(targetAccount.getTransactions().size(), is(0)); + + Portfolio targetPortfolio = new Portfolio(); + client.addPortfolio(targetPortfolio); + var portfolioTransfer = new LedgerPortfolioTransferTransactionCreator(client).create(portfolio, + targetPortfolio, security, date.plusDays(1), Values.Share.factorize(2), + Values.Amount.factorize(40), CurrencyUnit.EUR, "portfolio transfer note", + "portfolio transfer source"); + LedgerEntry portfolioEntry = ledgerEntry(portfolioTransfer.getSourceTransaction()); + + portfolioEntry.removeProjectionRef(projection(portfolioEntry, LedgerProjectionRole.TARGET_PORTFOLIO)); + rematerializeLedgerProjections(); + + issues = new CrossEntryCheck().execute(client); + + assertThat(issues.size(), is(1)); + assertThat(issues.get(0), is(instanceOf(MissingPortfolioTransferIssue.class))); + assertOnlyDeleteFix(issues.get(0)).execute(); + + assertEntryDeleted(portfolioEntry.getUUID()); + assertThat(portfolio.getTransactions().size(), is(0)); + assertThat(targetPortfolio.getTransactions().size(), is(0)); + } + + /** + * Verifies that legacy damaged business facts now follow the same delete-only policy. + * Currency, security, and transfer facts are not repaired by guessing replacement values. + */ + @Test + public void testLegacyFactRepairIssuesOfferOnlyDeleteFixes() + { + LocalDateTime date = LocalDateTime.of(2026, 6, 15, 10, 0); + AccountTransaction missingCurrency = new AccountTransaction(date, CurrencyUnit.EUR, + Values.Amount.factorize(10), null, AccountTransaction.Type.FEES); + account.addTransaction(missingCurrency); + missingCurrency.setCurrencyCode(null); + + PortfolioTransaction missingSecurity = new PortfolioTransaction(date.plusDays(1), CurrencyUnit.EUR, + Values.Amount.factorize(20), security, Values.Share.factorize(2), + PortfolioTransaction.Type.DELIVERY_INBOUND, 0, 0); + portfolio.addTransaction(missingSecurity); + missingSecurity.setSecurity(null); + + AccountTransaction dividendWithoutSecurity = new AccountTransaction(date.plusDays(2), CurrencyUnit.EUR, + Values.Amount.factorize(30), null, AccountTransaction.Type.DIVIDENDS); + account.addTransaction(dividendWithoutSecurity); + + List currencyIssues = new TransactionCurrencyCheck().execute(client); + List securityIssues = new PortfolioTransactionWithoutSecurityCheck().execute(client); + List dividendIssues = new DividendsAndInterestCheck().execute(client); + + assertThat(currencyIssues.size(), is(1)); + assertOnlyDeleteFix(currencyIssues.get(0)); + assertThat(securityIssues.size(), is(1)); + assertOnlyDeleteFix(securityIssues.get(0)); + assertThat(dividendIssues.size(), is(1)); + assertOnlyDeleteFix(dividendIssues.get(0)); + } + + /** + * Verifies that malformed ledger-backed exchange-rate facts offer only deletion. + * Because amount, currency, and rate belong together, no automatic forex repair is attempted. + */ + @Test + public void testLedgerBackedExchangeRateMalformedFactsOfferDeleteOnlyAndDeleteLedgerEntry() throws Exception + { + LocalDateTime date = LocalDateTime.of(2026, 6, 15, 10, 0); + AccountTransaction negativeExchangeRate = new LedgerAccountOnlyTransactionCreator(client).create(account, + AccountTransaction.Type.FEES, date, Values.Amount.factorize(40), CurrencyUnit.EUR, + security, + List.of(new Transaction.Unit(Transaction.Unit.Type.FEE, + Money.of(CurrencyUnit.EUR, Values.Amount.factorize(1)), + Money.of(CurrencyUnit.USD, Values.Amount.factorize(1)), BigDecimal.ONE)), + "negative rate note", "negative rate source"); + + ((LedgerBackedTransaction) negativeExchangeRate).getLedgerEntry().getPostings().stream() + .filter(posting -> posting.getType() == LedgerPostingType.FEE).findFirst().orElseThrow() + .setExchangeRate(BigDecimal.valueOf(-1)); + String entryUUID = ledgerEntry(negativeExchangeRate).getUUID(); + + assertThat(new TransactionCurrencyCheck().execute(client).size(), is(0)); + assertThat(new PortfolioTransactionWithoutSecurityCheck().execute(client).size(), is(0)); + assertThat(new DividendsAndInterestCheck().execute(client).size(), is(0)); + + List issues = new NegativeExchangeRateCheck().execute(client); + + assertThat(issues.size(), is(1)); + assertOnlyDeleteFix(issues.get(0)).execute(); + + assertEntryDeleted(entryUUID); + assertThat(account.getTransactions().size(), is(0)); + assertThat(reloadXml(client).getLedger().getEntries().size(), is(0)); + } + + private LedgerPosting primaryPosting(AccountTransaction transaction) + { + var ledgerBacked = (LedgerBackedTransaction) transaction; + var primaryPostingUUID = ledgerBacked.getLedgerProjectionRef().getPrimaryPostingUUID(); + + return ledgerBacked.getLedgerEntry().getPostings().stream() + .filter(posting -> posting.getUUID().equals(primaryPostingUUID)).findFirst().orElseThrow(); + } + + private LedgerPosting primaryPosting(PortfolioTransaction transaction) + { + var ledgerBacked = (LedgerBackedTransaction) transaction; + var primaryPostingUUID = ledgerBacked.getLedgerProjectionRef().getPrimaryPostingUUID(); + + return ledgerBacked.getLedgerEntry().getPostings().stream() + .filter(posting -> posting.getUUID().equals(primaryPostingUUID)).findFirst().orElseThrow(); + } + + private LedgerEntry ledgerEntry(Transaction transaction) + { + return ((LedgerBackedTransaction) transaction).getLedgerEntry(); + } + + private LedgerProjectionRef projection(LedgerEntry entry, LedgerProjectionRole role) + { + return entry.getProjectionRefs().stream().filter(ref -> ref.getRole() == role).findFirst().orElseThrow(); + } + + private void rematerializeLedgerProjections() + { + client.getAccounts().forEach(owner -> owner.getTransactions().removeIf(LedgerCheckSupport::isLedgerBacked)); + client.getPortfolios().forEach(owner -> owner.getTransactions().removeIf(LedgerCheckSupport::isLedgerBacked)); + + LedgerProjectionService.materialize(client); + } + + private void assertEntryDeleted(String entryUUID) + { + assertFalse(client.getLedger().getEntries().stream().anyMatch(entry -> entry.getUUID().equals(entryUUID))); + } + + private Client reloadXml(Client client) throws Exception + { + return ClientFactory.load(new ByteArrayInputStream(saveXml(client).getBytes(StandardCharsets.UTF_8))); + } + + private String saveXml(Client client) throws Exception + { + var file = File.createTempFile("ledger-delete-only-repair-policy", ".xml"); //$NON-NLS-1$ //$NON-NLS-2$ + + try + { + ClientFactory.save(client, file); + return Files.readString(file.toPath(), StandardCharsets.UTF_8); + } + finally + { + Files.deleteIfExists(file.toPath()); + } + } + + /** + * Verifies that matching buy/sell rows are reported as one damaged legacy booking. + * The available fix is deletion, not reconstruction of the missing side. + */ + @Test + public void testThatMatchingBuySellEntriesAreReportedDeleteOnly() { LocalDateTime date = LocalDateTime.now(); portfolio.addTransaction(new PortfolioTransaction(date, CurrencyUnit.EUR, 1, security, 1, @@ -107,11 +567,21 @@ public void testThatMatchingBuySellEntriesAreFixed() account.addTransaction(new AccountTransaction(date, CurrencyUnit.EUR, 1, security, // AccountTransaction.Type.SELL)); - assertThat(new CrossEntryCheck().execute(client).size(), is(0)); - assertThat(portfolio.getTransactions().get(0).getCrossEntry(), notNullValue()); - assertThat(account.getTransactions().get(0).getCrossEntry(), notNullValue()); + List issues = new CrossEntryCheck().execute(client); + + assertThat(issues.size(), is(2)); + List objects = new ArrayList(issues); + assertThat(objects, hasItem(instanceOf(MissingBuySellAccountIssue.class))); + assertThat(objects, hasItem(instanceOf(MissingBuySellPortfolioIssue.class))); + issues.forEach(this::assertOnlyDeleteFix); + + applyFixes(client, issues); } + /** + * Verifies that almost matching buy/sell rows are not paired by the check. + * The repair code must not guess cross-entry relationships from similar values. + */ @Test public void testThatAlmostMatchingBuySellEntriesAreNotMatched() { @@ -127,12 +597,17 @@ public void testThatAlmostMatchingBuySellEntriesAreNotMatched() List objects = new ArrayList(issues); assertThat(objects, hasItem(instanceOf(MissingBuySellAccountIssue.class))); assertThat(objects, hasItem(instanceOf(MissingBuySellPortfolioIssue.class))); + issues.forEach(this::assertOnlyDeleteFix); applyFixes(client, issues); } + /** + * Verifies that a missing transfer-out side offers only deletion. + * The check must not create a replacement transfer side from inferred owner structure. + */ @Test - public void testMissingAccountTransferOutIssue() + public void testMissingAccountTransferOutIssueOffersOnlyDeleteFix() { account.addTransaction(new AccountTransaction(LocalDateTime.now(), CurrencyUnit.EUR, 1, security, AccountTransaction.Type.TRANSFER_IN)); @@ -142,12 +617,17 @@ public void testMissingAccountTransferOutIssue() assertThat(issues.size(), is(1)); assertThat(issues.get(0), is(instanceOf(MissingAccountTransferIssue.class))); assertThat(issues.get(0).getEntity(), is((Object) account)); + assertOnlyDeleteFix(issues.get(0)); applyFixes(client, issues); } + /** + * Verifies that a missing transfer-in side offers only deletion. + * The check must not create a replacement transfer side from inferred owner structure. + */ @Test - public void testMissingAccountTransferInIssue() + public void testMissingAccountTransferInIssueOffersOnlyDeleteFix() { account.addTransaction(new AccountTransaction(LocalDateTime.now(), CurrencyUnit.EUR, 1, security, AccountTransaction.Type.TRANSFER_OUT)); @@ -157,12 +637,17 @@ public void testMissingAccountTransferInIssue() assertThat(issues.size(), is(1)); assertThat(issues.get(0), is(instanceOf(MissingAccountTransferIssue.class))); assertThat(issues.get(0).getEntity(), is((Object) account)); + assertOnlyDeleteFix(issues.get(0)); applyFixes(client, issues); } + /** + * Verifies that account transfers are no longer reconstructed by repair fixes. + * Broken source or target sides must be diagnosed and deleted instead of rebuilt. + */ @Test - public void testThatNotTheSameAccountIsMatched() + public void testThatAccountTransfersAreNotReconstructedAndOfferOnlyDeleteFixes() { Account second = new Account(); client.addAccount(second); @@ -180,18 +665,20 @@ public void testThatNotTheSameAccountIsMatched() List issues = new CrossEntryCheck().execute(client); - assertThat(issues.size(), is(1)); - assertThat(issues.get(0), is(instanceOf(MissingAccountTransferIssue.class))); - + assertThat(issues.size(), is(3)); + issues.forEach(issue -> assertThat(issue, is(instanceOf(MissingAccountTransferIssue.class)))); + issues.forEach(this::assertOnlyDeleteFix); assertThat(account.getTransactions(), hasItem(umatched)); - assertThat(second.getTransactions().get(0).getCrossEntry(), notNullValue()); - assertThat(second.getTransactions().get(0).getType(), is(AccountTransaction.Type.TRANSFER_OUT)); applyFixes(client, issues); } + /** + * Verifies that a missing portfolio transfer-out side offers only deletion. + * The check must not infer a replacement depot-side transfer structure. + */ @Test - public void testMissingPortfolioTransferOutIssue() + public void testMissingPortfolioTransferOutIssueOffersOnlyDeleteFix() { portfolio.addTransaction(new PortfolioTransaction(LocalDateTime.now(), CurrencyUnit.EUR, 1, security, 1, PortfolioTransaction.Type.TRANSFER_IN, 1, 0)); @@ -201,12 +688,17 @@ public void testMissingPortfolioTransferOutIssue() assertThat(issues.size(), is(1)); assertThat(issues.get(0), is(instanceOf(MissingPortfolioTransferIssue.class))); assertThat(issues.get(0).getEntity(), is((Object) portfolio)); + assertOnlyDeleteFix(issues.get(0)); applyFixes(client, issues); } + /** + * Verifies that a missing portfolio transfer-in side offers only deletion. + * The check must not infer a replacement depot-side transfer structure. + */ @Test - public void testMissingPortfolioTransferInIssue() + public void testMissingPortfolioTransferInIssueOffersOnlyDeleteFix() { portfolio.addTransaction(new PortfolioTransaction(LocalDateTime.now(), CurrencyUnit.EUR, 1, security, 1, PortfolioTransaction.Type.TRANSFER_OUT, 1, 0)); @@ -216,12 +708,17 @@ public void testMissingPortfolioTransferInIssue() assertThat(issues.size(), is(1)); assertThat(issues.get(0), is(instanceOf(MissingPortfolioTransferIssue.class))); assertThat(issues.get(0).getEntity(), is((Object) portfolio)); + assertOnlyDeleteFix(issues.get(0)); applyFixes(client, issues); } + /** + * Verifies that portfolio transfers are no longer reconstructed by repair fixes. + * Broken source or target sides must be diagnosed and deleted instead of rebuilt. + */ @Test - public void testThatNotTheSamePortfolioIsMatched() + public void testThatPortfolioTransfersAreNotReconstructedAndOfferOnlyDeleteFixes() { Portfolio second = new Portfolio(); client.addPortfolio(second); @@ -239,18 +736,20 @@ public void testThatNotTheSamePortfolioIsMatched() List issues = new CrossEntryCheck().execute(client); - assertThat(issues.size(), is(1)); - assertThat(issues.get(0), is(instanceOf(MissingPortfolioTransferIssue.class))); - + assertThat(issues.size(), is(3)); + issues.forEach(issue -> assertThat(issue, is(instanceOf(MissingPortfolioTransferIssue.class)))); + issues.forEach(this::assertOnlyDeleteFix); assertThat(portfolio.getTransactions(), hasItem(umatched)); - assertThat(second.getTransactions().get(0).getCrossEntry(), notNullValue()); - assertThat(second.getTransactions().get(0).getType(), is(PortfolioTransaction.Type.TRANSFER_OUT)); applyFixes(client, issues); } + /** + * Verifies that account transactions carrying impossible security facts offer only deletion. + * The repair path must not clean up or reinterpret the transaction facts automatically. + */ @Test - public void testThatAccountTransactionsWithoutSecurity() + public void testThatAccountTransactionsWithoutSecurityOfferOnlyDeleteFix() { Portfolio second = new Portfolio(); client.addPortfolio(second); @@ -278,4 +777,27 @@ private void applyFixes(Client client, List issues) } assertThat(new CrossEntryCheck().execute(client).size(), is(0)); } + + private QuickFix assertOnlyDeleteFix(Issue issue) + { + List fixes = issue.getAvailableFixes(); + assertThat(fixes.size(), is(1)); + assertThat(fixes.get(0), is(instanceOf(DeleteTransactionFix.class))); + return fixes.get(0); + } + + private InvestmentPlan createBuyPlan(String name) + { + InvestmentPlan plan = new InvestmentPlan(); + plan.setName(name); + plan.setType(InvestmentPlan.Type.PURCHASE_OR_DELIVERY); + plan.setAccount(account); + plan.setPortfolio(portfolio); + plan.setSecurity(security); + plan.setStart(LocalDate.now().minusMonths(1)); + plan.setInterval(12); + plan.setAmount(Values.Amount.factorize(100)); + return plan; + } + } diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ClientLedgerTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ClientLedgerTest.java new file mode 100644 index 0000000000..eb490842a3 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ClientLedgerTest.java @@ -0,0 +1,107 @@ +package name.abuchen.portfolio.model; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.Test; + +import name.abuchen.portfolio.money.CurrencyUnit; + +/** + * Tests the client-level ledger container and its legacy owner-list boundary. + * These tests make sure Ledger-V6 has one persisted transaction truth without changing direct legacy list behavior. + */ +@SuppressWarnings("nls") +public class ClientLedgerTest +{ + /** + * Verifies that every new client owns an empty ledger. + * New files must have a place for ledger truth before any transactions are created. + */ + @Test + public void testNewClientOwnsEmptyLedger() + { + var client = new Client(); + + assertNotNull(client.getLedger()); + assertSame(client.getLedger(), client.getLedger()); + assertTrue(client.getLedger().getEntries().isEmpty()); + } + + /** + * Verifies that post-load initialization restores a missing ledger field. + * Old or malformed serialized clients must not leave the application without a ledger container. + */ + @Test + public void testPostLoadInitializationRestoresNullLedger() throws ReflectiveOperationException + { + var client = new Client(); + var originalLedger = client.getLedger(); + var field = Client.class.getDeclaredField("ledger"); + + field.setAccessible(true); + field.set(client, null); + + client.doPostLoadInitialization(); + + assertNotNull(client.getLedger()); + assertNotSame(originalLedger, client.getLedger()); + assertTrue(client.getLedger().getEntries().isEmpty()); + } + + /** + * Verifies that the client ledger field is part of persistence. + * The ledger must be saved as the source of truth rather than treated as runtime-only state. + */ + @Test + public void testLedgerFieldIsPersisted() throws NoSuchFieldException + { + var field = Client.class.getDeclaredField("ledger"); + + assertFalse(java.lang.reflect.Modifier.isTransient(field.getModifiers())); + } + + /** + * Verifies that legacy owner lists remain unchanged when rows are added directly. + * Adding legacy transactions must not silently populate ledger truth. + */ + @Test + public void testLegacyTransactionListsRemainUnchangedAndDoNotPopulateLedger() + { + var client = new Client(); + var account = new Account(); + var portfolio = new Portfolio(); + var security = new Security("Some security", CurrencyUnit.EUR); + var accountTransaction = new AccountTransaction(); + var portfolioTransaction = new PortfolioTransaction(); + + account.setCurrencyCode(CurrencyUnit.EUR); + client.addAccount(account); + client.addPortfolio(portfolio); + client.addSecurity(security); + + accountTransaction.setType(AccountTransaction.Type.DEPOSIT); + accountTransaction.setDateTime(LocalDateTime.of(2026, 1, 2, 0, 0)); + accountTransaction.setCurrencyCode(CurrencyUnit.EUR); + accountTransaction.setAmount(100L); + account.addTransaction(accountTransaction); + + portfolioTransaction.setType(PortfolioTransaction.Type.DELIVERY_INBOUND); + portfolioTransaction.setDateTime(LocalDateTime.of(2026, 1, 2, 0, 0)); + portfolioTransaction.setSecurity(security); + portfolioTransaction.setShares(1000L); + portfolio.addTransaction(portfolioTransaction); + + assertThat(account.getTransactions(), is(List.of(accountTransaction))); + assertThat(portfolio.getTransactions(), is(List.of(portfolioTransaction))); + assertTrue(client.getLedger().getEntries().isEmpty()); + } +} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/CrossEntryTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/CrossEntryTest.java index 93527ad74f..650a5d2a0a 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/CrossEntryTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/CrossEntryTest.java @@ -5,6 +5,10 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.Assert.fail; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.time.LocalDateTime; import java.time.Month; @@ -12,6 +16,9 @@ import org.junit.Test; import name.abuchen.portfolio.model.Transaction.Unit; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountTransferTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerBuySellTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferTransactionCreator; import name.abuchen.portfolio.money.CurrencyUnit; import name.abuchen.portfolio.money.Money; import name.abuchen.portfolio.money.Values; @@ -253,4 +260,84 @@ public void testPortoflioTransferEntry() assertThat(portfolioA.getTransactions().size(), is(0)); assertThat(portfolioB.getTransactions().size(), is(0)); } + + @Test + public void testLedgerBackedBuySellTransactionPairDeleteRemovesWholeEntry() throws Exception + { + Portfolio portfolio = client.getPortfolios().get(0); + Account account = client.getAccounts().get(0); + Security security = client.getSecurities().get(0); + + account.setCurrencyCode(CurrencyUnit.EUR); + portfolio.setReferenceAccount(account); + + var entry = new LedgerBuySellTransactionCreator(client).create(portfolio, account, PortfolioTransaction.Type.BUY, + LocalDateTime.now(), Values.Amount.factorize(1000), CurrencyUnit.EUR, security, + Values.Share.factorize(1), java.util.List.of(), "note", "source"); //$NON-NLS-1$ //$NON-NLS-2$ + + new TransactionPair<>(portfolio, entry.getPortfolioTransaction()).deleteTransaction(client); + + assertThat(client.getLedger().getEntries().size(), is(0)); + assertThat(portfolio.getTransactions().size(), is(0)); + assertThat(account.getTransactions().size(), is(0)); + assertThat(reloadXml(client).getAllTransactions().size(), is(0)); + } + + @Test + public void testLedgerBackedAccountTransferTransactionPairDeleteRemovesWholeEntry() + { + Account accountA = client.getAccounts().get(0); + Account accountB = client.getAccounts().get(1); + + accountA.setCurrencyCode(CurrencyUnit.EUR); + accountB.setCurrencyCode(CurrencyUnit.EUR); + + var entry = new LedgerAccountTransferTransactionCreator(client).create(accountA, accountB, LocalDateTime.now(), + Values.Amount.factorize(1000), CurrencyUnit.EUR, Values.Amount.factorize(1000), + CurrencyUnit.EUR, null, null, "note", "source"); //$NON-NLS-1$ //$NON-NLS-2$ + + new TransactionPair<>(accountA, entry.getSourceTransaction()).deleteTransaction(client); + + assertThat(client.getLedger().getEntries().size(), is(0)); + assertThat(accountA.getTransactions().size(), is(0)); + assertThat(accountB.getTransactions().size(), is(0)); + } + + @Test + public void testLedgerBackedPortfolioTransferTransactionPairDeleteRemovesWholeEntry() + { + Portfolio portfolioA = client.getPortfolios().get(0); + Portfolio portfolioB = client.getPortfolios().get(1); + Security security = client.getSecurities().get(0); + + var entry = new LedgerPortfolioTransferTransactionCreator(client).create(portfolioA, portfolioB, security, + LocalDateTime.now(), Values.Share.factorize(1), Values.Amount.factorize(1000), + CurrencyUnit.EUR, "note", "source"); //$NON-NLS-1$ //$NON-NLS-2$ + + new TransactionPair<>(portfolioA, entry.getSourceTransaction()).deleteTransaction(client); + + assertThat(client.getLedger().getEntries().size(), is(0)); + assertThat(portfolioA.getTransactions().size(), is(0)); + assertThat(portfolioB.getTransactions().size(), is(0)); + } + + private Client reloadXml(Client client) throws Exception + { + return ClientFactory.load(new ByteArrayInputStream(saveXml(client).getBytes(StandardCharsets.UTF_8))); + } + + private String saveXml(Client client) throws Exception + { + var file = File.createTempFile("ledger-pair-delete", ".xml"); //$NON-NLS-1$ //$NON-NLS-2$ + + try + { + ClientFactory.save(client, file); + return Files.readString(file.toPath(), StandardCharsets.UTF_8); + } + finally + { + Files.deleteIfExists(file.toPath()); + } + } } diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigratorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigratorTest.java new file mode 100644 index 0000000000..9d51cf1022 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigratorTest.java @@ -0,0 +1,1689 @@ +package name.abuchen.portfolio.model.ledger.legacy; + +import static org.hamcrest.CoreMatchers.instanceOf; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.lang.reflect.InvocationTargetException; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import java.util.function.Consumer; + +import org.junit.Test; + +import name.abuchen.portfolio.Messages; +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.AccountTransferEntry; +import name.abuchen.portfolio.model.BuySellEntry; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.InvestmentPlan; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.PortfolioTransaction; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.Transaction.Unit; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerParameter; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; +import name.abuchen.portfolio.model.ledger.ProjectionMembershipRole; +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.model.ledger.projection.LedgerBackedAccountTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.Values; + +@SuppressWarnings("nls") +public class LegacyTransactionToLedgerMigratorTest +{ + private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 1, 2, 0, 0); + private static final LocalDateTime EX_DATE = LocalDateTime.of(2025, 12, 28, 0, 0); + + @Test + public void testAccountOnlyFamiliesMigrateWithProjectionUUIDAndApiParity() + { + assertAccountOnlyMigration(AccountTransaction.Type.DEPOSIT, LedgerEntryType.DEPOSIT); + assertAccountOnlyMigration(AccountTransaction.Type.REMOVAL, LedgerEntryType.REMOVAL); + assertAccountOnlyMigration(AccountTransaction.Type.INTEREST, LedgerEntryType.INTEREST); + assertAccountOnlyMigration(AccountTransaction.Type.INTEREST_CHARGE, LedgerEntryType.INTEREST_CHARGE); + assertAccountOnlyMigration(AccountTransaction.Type.FEES, LedgerEntryType.FEES); + assertAccountOnlyMigration(AccountTransaction.Type.FEES_REFUND, LedgerEntryType.FEES_REFUND); + assertAccountOnlyMigration(AccountTransaction.Type.TAXES, LedgerEntryType.TAXES); + assertAccountOnlyMigration(AccountTransaction.Type.TAX_REFUND, LedgerEntryType.TAX_REFUND); + } + + @Test + public void testDividendMigrationPreservesSecurityExDateUnitsAndForex() + { + var client = new Client(); + var account = register(client, account()); + var security = security(); + var dividend = accountTransaction(AccountTransaction.Type.DIVIDENDS, 120); + + dividend.setSecurity(security); + dividend.setShares(Values.Share.factorize(3)); + dividend.setExDate(EX_DATE); + dividend.addUnit(new Unit(Unit.Type.TAX, money(10))); + dividend.addUnit(new Unit(Unit.Type.FEE, money(2))); + dividend.addUnit(new Unit(Unit.Type.GROSS_VALUE, money(150), + Money.of(CurrencyUnit.USD, Values.Amount.factorize(300)), BigDecimal.valueOf(0.5))); + account.addTransaction(dividend); + + var result = migrate(client); + var migrated = onlyAccountTransaction(account); + var entry = onlyLedgerEntry(client); + var posting = entry.getPostings().get(0); + + assertFalse(result.hasDiagnostics()); + assertThat(result.getMigratedTransactionCount(), is(1)); + assertThat(entry.getType(), is(LedgerEntryType.DIVIDENDS)); + assertThat(migrated.getUUID(), is(dividend.getUUID())); + assertThat(migrated.getType(), is(AccountTransaction.Type.DIVIDENDS)); + assertSame(security, migrated.getSecurity()); + assertThat(migrated.getShares(), is(dividend.getShares())); + assertThat(migrated.getExDate(), is(EX_DATE)); + assertThat(migrated.getUnits().toList().size(), is(3)); + assertThat(posting.getParameters().get(0).getType(), is(LedgerParameterType.EX_DATE)); + assertThat(posting.getParameters().get(0).getValueKind(), is(LedgerParameter.ValueKind.LOCAL_DATE_TIME)); + assertTrue(entry.getPostings().stream().anyMatch(p -> p.getType() == LedgerPostingType.GROSS_VALUE + && Long.valueOf(Values.Amount.factorize(300)).equals(p.getForexAmount()) + && CurrencyUnit.USD.equals(p.getForexCurrency()) + && BigDecimal.valueOf(0.5).compareTo(p.getExchangeRate()) == 0)); + assertValid(client); + } + + @Test + public void testBuyMigrationCreatesOneEntryTwoProjectionUUIDsAndCrossEntry() + { + assertBuySellMigration(PortfolioTransaction.Type.BUY, LedgerEntryType.BUY); + } + + @Test + public void testSellMigrationCreatesOneEntryTwoProjectionUUIDsAndCrossEntry() + { + assertBuySellMigration(PortfolioTransaction.Type.SELL, LedgerEntryType.SELL); + } + + @Test + public void testAccountTransferMigrationPreservesDirectionAndProjectionUUIDs() + { + var client = new Client(); + var source = register(client, account()); + var target = register(client, account()); + var transfer = new AccountTransferEntry(source, target); + + transfer.setDate(DATE_TIME); + transfer.setAmount(Values.Amount.factorize(55)); + transfer.setCurrencyCode(CurrencyUnit.EUR); + transfer.setNote("transfer note"); + transfer.setSource("transfer source"); + transfer.insert(); + + var sourceUUID = transfer.getSourceTransaction().getUUID(); + var targetUUID = transfer.getTargetTransaction().getUUID(); + + migrate(client); + + var entry = onlyLedgerEntry(client); + var sourceTransaction = onlyAccountTransaction(source); + var targetTransaction = onlyAccountTransaction(target); + + assertThat(entry.getType(), is(LedgerEntryType.CASH_TRANSFER)); + assertThat(sourceTransaction.getUUID(), is(sourceUUID)); + assertThat(targetTransaction.getUUID(), is(targetUUID)); + assertThat(sourceTransaction.getType(), is(AccountTransaction.Type.TRANSFER_OUT)); + assertThat(targetTransaction.getType(), is(AccountTransaction.Type.TRANSFER_IN)); + assertThat(sourceTransaction.getDateTime(), is(transfer.getSourceTransaction().getDateTime())); + assertThat(targetTransaction.getDateTime(), is(transfer.getTargetTransaction().getDateTime())); + assertThat(sourceTransaction.getAmount(), is(transfer.getSourceTransaction().getAmount())); + assertThat(targetTransaction.getAmount(), is(transfer.getTargetTransaction().getAmount())); + assertThat(sourceTransaction.getCurrencyCode(), is(transfer.getSourceTransaction().getCurrencyCode())); + assertThat(targetTransaction.getCurrencyCode(), is(transfer.getTargetTransaction().getCurrencyCode())); + assertThat(sourceTransaction.getNote(), is(transfer.getSourceTransaction().getNote())); + assertThat(sourceTransaction.getSource(), is(transfer.getSourceTransaction().getSource())); + assertSame(target, sourceTransaction.getCrossEntry().getCrossOwner(sourceTransaction)); + assertSame(targetTransaction, sourceTransaction.getCrossEntry().getCrossTransaction(sourceTransaction)); + assertValid(client); + } + + @Test + public void testPortfolioTransferMigrationPreservesDirectionAndProjectionUUIDs() + { + var client = new Client(); + var source = register(client, portfolio()); + var target = register(client, portfolio()); + var security = security(); + var transfer = new name.abuchen.portfolio.model.PortfolioTransferEntry(source, target); + + transfer.setDate(DATE_TIME); + transfer.setSecurity(security); + transfer.setShares(Values.Share.factorize(7)); + transfer.setAmount(Values.Amount.factorize(400)); + transfer.setCurrencyCode(CurrencyUnit.EUR); + transfer.setNote("portfolio transfer note"); + transfer.setSource("portfolio transfer source"); + transfer.insert(); + + var sourceUUID = transfer.getSourceTransaction().getUUID(); + var targetUUID = transfer.getTargetTransaction().getUUID(); + + migrate(client); + + var entry = onlyLedgerEntry(client); + var sourceTransaction = onlyPortfolioTransaction(source); + var targetTransaction = onlyPortfolioTransaction(target); + + assertThat(entry.getType(), is(LedgerEntryType.SECURITY_TRANSFER)); + assertThat(sourceTransaction.getUUID(), is(sourceUUID)); + assertThat(targetTransaction.getUUID(), is(targetUUID)); + assertThat(sourceTransaction.getType(), is(PortfolioTransaction.Type.TRANSFER_OUT)); + assertThat(targetTransaction.getType(), is(PortfolioTransaction.Type.TRANSFER_IN)); + assertThat(sourceTransaction.getDateTime(), is(transfer.getSourceTransaction().getDateTime())); + assertThat(targetTransaction.getDateTime(), is(transfer.getTargetTransaction().getDateTime())); + assertThat(sourceTransaction.getAmount(), is(transfer.getSourceTransaction().getAmount())); + assertThat(targetTransaction.getAmount(), is(transfer.getTargetTransaction().getAmount())); + assertThat(sourceTransaction.getCurrencyCode(), is(transfer.getSourceTransaction().getCurrencyCode())); + assertThat(targetTransaction.getCurrencyCode(), is(transfer.getTargetTransaction().getCurrencyCode())); + assertSame(security, sourceTransaction.getSecurity()); + assertSame(security, targetTransaction.getSecurity()); + assertThat(sourceTransaction.getShares(), is(Values.Share.factorize(7))); + assertThat(targetTransaction.getShares(), is(Values.Share.factorize(7))); + assertThat(sourceTransaction.getNote(), is(transfer.getSourceTransaction().getNote())); + assertThat(sourceTransaction.getSource(), is(transfer.getSourceTransaction().getSource())); + assertSame(target, sourceTransaction.getCrossEntry().getCrossOwner(sourceTransaction)); + assertSame(targetTransaction, sourceTransaction.getCrossEntry().getCrossTransaction(sourceTransaction)); + assertValid(client); + } + + @Test + public void testDeliveriesMigrateWithProjectionUUIDAndUnits() + { + assertDeliveryMigration(PortfolioTransaction.Type.DELIVERY_INBOUND, LedgerEntryType.DELIVERY_INBOUND); + assertDeliveryMigration(PortfolioTransaction.Type.DELIVERY_OUTBOUND, LedgerEntryType.DELIVERY_OUTBOUND); + } + + @Test + public void testDuplicateMigrationPrevention() + { + var client = new Client(); + var account = register(client, account()); + var deposit = accountTransaction(AccountTransaction.Type.DEPOSIT, 100); + + account.addTransaction(deposit); + + var first = migrate(client); + var second = migrate(client); + + assertThat(first.getMigratedTransactionCount(), is(1)); + assertThat(second.getMigratedTransactionCount(), is(0)); + assertThat(client.getLedger().getEntries().size(), is(1)); + assertThat(account.getTransactions().size(), is(1)); + assertThat(account.getTransactions().get(0).getUUID(), is(deposit.getUUID())); + } + + @Test + public void testMixedClientMigratesSupportedAndLeavesUnsupportedLegacyUntouched() + { + var client = new Client(); + var account = register(client, account()); + var supported = accountTransaction(AccountTransaction.Type.DEPOSIT, 100); + var unsupported = accountTransaction(AccountTransaction.Type.BUY, 200); + + account.addTransaction(supported); + account.addTransaction(unsupported); + + var result = migrate(client); + + assertThat(result.getMigratedTransactionCount(), is(1)); + assertThat(client.getLedger().getEntries().size(), is(1)); + assertTrue(account.getTransactions().stream().anyMatch(transaction -> transaction == unsupported)); + assertTrue(account.getTransactions().stream().anyMatch(transaction -> transaction instanceof LedgerBackedTransaction + && transaction.getUUID().equals(supported.getUUID()))); + } + + @Test + public void testMalformedCrossEntryIsDiagnosedAndDoesNotCreatePartialLedgerEntry() + { + var client = new Client(); + var account = register(client, account()); + var entry = new BuySellEntry(); + + account.setName("Migration Account"); + entry.setType(PortfolioTransaction.Type.BUY); + entry.setDate(DATE_TIME); + entry.setSecurity(security()); + entry.setShares(Values.Share.factorize(1)); + entry.setAmount(Values.Amount.factorize(100)); + entry.setCurrencyCode(CurrencyUnit.EUR); + account.addTransaction(entry.getAccountTransaction()); + + var result = migrate(client); + + assertTrue(result.hasDiagnostics()); + assertDiagnostic(result, LedgerDiagnosticCode.LEDGER_IMPORT_001.prefix(), "family=BUY_SELL", + "reason=MALFORMED_CROSS_ENTRY", "incomplete", entry.getAccountTransaction().getUUID(), + entry.getPortfolioTransaction().getUUID(), + Messages.LedgerDiagnosticMessageFormatterTransactionContext + ":", + Messages.LedgerDiagnosticMessageFormatterAccount + ": Migration Account", + Messages.LedgerDiagnosticMessageFormatterDate + ": 2026-01-02T00:00", + Messages.LedgerDiagnosticMessageFormatterType + ":"); + assertTrue(client.getLedger().getEntries().isEmpty()); + assertSame(entry.getAccountTransaction(), account.getTransactions().get(0)); + } + + @Test + public void testAccountTransferMalformedCrossEntryIncompleteHasImportCode() + { + var client = new Client(); + var source = register(client, account()); + var target = register(client, account()); + var transfer = new AccountTransferEntry(source, target); + + source.setName("Source Account"); + transfer.setDate(DATE_TIME); + transfer.setAmount(Values.Amount.factorize(55)); + transfer.setCurrencyCode(CurrencyUnit.EUR); + transfer.setTargetAccount(null); + source.addTransaction(transfer.getSourceTransaction()); + + var result = migrate(client); + + assertNoMigration(result, client); + assertDiagnostic(result, LedgerDiagnosticCode.LEDGER_IMPORT_006.prefix(), "family=ACCOUNT_TRANSFER", + "reason=MALFORMED_CROSS_ENTRY", "incomplete", transfer.getSourceTransaction().getUUID(), + transfer.getTargetTransaction().getUUID(), + Messages.LedgerDiagnosticMessageFormatterTransactionContext + ":", + Messages.LedgerDiagnosticMessageFormatterAccount + ": Source Account", + Messages.LedgerDiagnosticMessageFormatterType + ":"); + assertSame(transfer.getSourceTransaction(), source.getTransactions().get(0)); + assertTrue(target.getTransactions().isEmpty()); + } + + @Test + public void testPortfolioTransferMalformedCrossEntryIncompleteHasImportCode() + { + var client = new Client(); + var source = register(client, portfolio()); + var target = register(client, portfolio()); + var transfer = new name.abuchen.portfolio.model.PortfolioTransferEntry(source, target); + + source.setName("Source Portfolio"); + transfer.setDate(DATE_TIME); + transfer.setSecurity(security()); + transfer.setShares(Values.Share.factorize(7)); + transfer.setAmount(Values.Amount.factorize(400)); + transfer.setCurrencyCode(CurrencyUnit.EUR); + transfer.setTargetPortfolio(null); + source.addTransaction(transfer.getSourceTransaction()); + + var result = migrate(client); + + assertNoMigration(result, client); + assertDiagnostic(result, LedgerDiagnosticCode.LEDGER_IMPORT_011.prefix(), "family=PORTFOLIO_TRANSFER", + "reason=MALFORMED_CROSS_ENTRY", "incomplete", transfer.getSourceTransaction().getUUID(), + transfer.getTargetTransaction().getUUID(), + Messages.LedgerDiagnosticMessageFormatterTransactionContext + ":", + Messages.LedgerDiagnosticMessageFormatterPortfolio + ": Source Portfolio", + Messages.LedgerDiagnosticMessageFormatterSecurity + ": Security", + Messages.LedgerDiagnosticMessageFormatterType + ":"); + assertSame(transfer.getSourceTransaction(), source.getTransactions().get(0)); + assertTrue(target.getTransactions().isEmpty()); + } + + @Test + public void testBuySellRejectsNonBuySellPortfolioType() + { + var client = new Client(); + var account = register(client, account()); + var portfolio = register(client, portfolio()); + var entry = buySellEntry(portfolio, account, PortfolioTransaction.Type.BUY); + + entry.getPortfolioTransaction().setType(PortfolioTransaction.Type.TRANSFER_IN); + entry.insert(); + + var result = migrate(client); + + assertNoMigration(result, client); + assertDiagnostic(result, LedgerDiagnosticCode.LEDGER_IMPORT_004.prefix(), "family=BUY_SELL", + "reason=TYPE_MISMATCH", entry.getAccountTransaction().getUUID(), + entry.getPortfolioTransaction().getUUID()); + assertSame(entry.getAccountTransaction(), account.getTransactions().get(0)); + assertSame(entry.getPortfolioTransaction(), portfolio.getTransactions().get(0)); + } + + @Test + public void testBuySellRejectsNonBuySellAccountType() + { + var client = new Client(); + var account = register(client, account()); + var portfolio = register(client, portfolio()); + var entry = buySellEntry(portfolio, account, PortfolioTransaction.Type.BUY); + + entry.getAccountTransaction().setType(AccountTransaction.Type.TRANSFER_IN); + entry.insert(); + + var result = migrate(client); + + assertNoMigration(result, client); + assertDiagnostic(result, LedgerDiagnosticCode.LEDGER_IMPORT_004.prefix(), "family=BUY_SELL", + "reason=TYPE_MISMATCH", entry.getAccountTransaction().getUUID(), + entry.getPortfolioTransaction().getUUID()); + assertSame(entry.getAccountTransaction(), account.getTransactions().get(0)); + assertSame(entry.getPortfolioTransaction(), portfolio.getTransactions().get(0)); + } + + @Test + public void testBuySellRejectsDirectionMismatch() + { + var client = new Client(); + var account = register(client, account()); + var portfolio = register(client, portfolio()); + var entry = buySellEntry(portfolio, account, PortfolioTransaction.Type.BUY); + + entry.getPortfolioTransaction().setType(PortfolioTransaction.Type.SELL); + entry.insert(); + + var result = migrate(client); + + assertNoMigration(result, client); + assertDiagnostic(result, LedgerDiagnosticCode.LEDGER_IMPORT_004.prefix(), "family=BUY_SELL", + "reason=TYPE_MISMATCH", entry.getAccountTransaction().getUUID(), + entry.getPortfolioTransaction().getUUID()); + assertSame(entry.getAccountTransaction(), account.getTransactions().get(0)); + assertSame(entry.getPortfolioTransaction(), portfolio.getTransactions().get(0)); + } + + @Test + public void testBuySellMetadataMismatchesAreRejected() + { + assertBuySellMetadataMismatchIsRejected("dateTime"); + assertBuySellMetadataMismatchIsRejected("note"); + assertBuySellMetadataMismatchIsRejected("source"); + } + + @Test + public void testAccountTransferMetadataMismatchIsRejected() + { + var client = new Client(); + var source = register(client, account()); + var target = register(client, account()); + var transfer = new AccountTransferEntry(source, target); + + transfer.setDate(DATE_TIME); + transfer.setAmount(Values.Amount.factorize(55)); + transfer.setCurrencyCode(CurrencyUnit.EUR); + transfer.setNote("source note"); + transfer.setSource("source"); + transfer.getTargetTransaction().setNote("target note"); + transfer.insert(); + + var result = migrate(client); + + assertNoMigration(result, client); + assertDiagnostic(result, LedgerDiagnosticCode.LEDGER_IMPORT_019.prefix(), "family=ACCOUNT_TRANSFER", + "reason=METADATA_MISMATCH", "field=note", transfer.getSourceTransaction().getUUID(), + transfer.getTargetTransaction().getUUID()); + assertSame(transfer.getSourceTransaction(), source.getTransactions().get(0)); + assertSame(transfer.getTargetTransaction(), target.getTransactions().get(0)); + } + + @Test + public void testPortfolioTransferMetadataMismatchIsRejected() + { + var client = new Client(); + var source = register(client, portfolio()); + var target = register(client, portfolio()); + var transfer = new name.abuchen.portfolio.model.PortfolioTransferEntry(source, target); + + transfer.setDate(DATE_TIME); + transfer.setSecurity(security()); + transfer.setShares(Values.Share.factorize(7)); + transfer.setAmount(Values.Amount.factorize(400)); + transfer.setCurrencyCode(CurrencyUnit.EUR); + transfer.setNote("source note"); + transfer.setSource("source"); + transfer.getTargetTransaction().setSource("target source"); + transfer.insert(); + + var result = migrate(client); + + assertNoMigration(result, client); + assertDiagnostic(result, LedgerDiagnosticCode.LEDGER_IMPORT_020.prefix(), "family=PORTFOLIO_TRANSFER", + "reason=METADATA_MISMATCH", "field=source", transfer.getSourceTransaction().getUUID(), + transfer.getTargetTransaction().getUUID()); + assertSame(transfer.getSourceTransaction(), source.getTransactions().get(0)); + assertSame(transfer.getTargetTransaction(), target.getTransactions().get(0)); + } + + @Test + public void testMigrationPlanIsAtomicWhenCandidateValidationFails() + { + var client = new Client(); + var account = register(client, account()); + var valid = accountTransaction(AccountTransaction.Type.DEPOSIT, 100); + var invalid = accountTransaction(AccountTransaction.Type.REMOVAL, -10); + + account.addTransaction(valid); + account.addTransaction(invalid); + + var result = migrate(client); + + assertThat(result.getMigratedTransactionCount(), is(0)); + assertDiagnostic(result, "family=MIGRATION", "reason=FAILED_VALIDATION", "[SIGNED_FACT_NOT_ALLOWED] "); + assertTrue(client.getLedger().getEntries().isEmpty()); + assertThat(account.getTransactions().size(), is(2)); + assertSame(valid, account.getTransactions().get(0)); + assertSame(invalid, account.getTransactions().get(1)); + assertFalse(account.getTransactions().stream().anyMatch(LedgerBackedTransaction.class::isInstance)); + } + + @Test + public void testPartialExistingBuySellAccountProjectionDoesNotRemoveLegacyTransactions() + { + var client = new Client(); + var account = register(client, account()); + var portfolio = register(client, portfolio()); + var entry = buySellEntry(portfolio, account, PortfolioTransaction.Type.BUY); + + entry.insert(); + client.getLedger().addEntry(existingAccountProjectionEntry(LedgerEntryType.BUY, account, + entry.getAccountTransaction().getUUID())); + + var result = migrate(client); + + assertPartialDuplicate(result, "BUY_SELL", entry.getAccountTransaction().getUUID(), + entry.getPortfolioTransaction().getUUID()); + assertThat(client.getLedger().getEntries().size(), is(1)); + assertSame(entry.getAccountTransaction(), account.getTransactions().get(0)); + assertSame(entry.getPortfolioTransaction(), portfolio.getTransactions().get(0)); + assertFalse(account.getTransactions().stream().anyMatch(LedgerBackedTransaction.class::isInstance)); + assertFalse(portfolio.getTransactions().stream().anyMatch(LedgerBackedTransaction.class::isInstance)); + } + + @Test + public void testPartialExistingBuySellPortfolioProjectionDoesNotRemoveLegacyTransactions() + { + var client = new Client(); + var account = register(client, account()); + var portfolio = register(client, portfolio()); + var entry = buySellEntry(portfolio, account, PortfolioTransaction.Type.BUY); + + entry.insert(); + client.getLedger().addEntry(existingPortfolioProjectionEntry(LedgerEntryType.BUY, portfolio, + entry.getPortfolioTransaction().getUUID(), LedgerProjectionRole.PORTFOLIO)); + + var result = migrate(client); + + assertPartialDuplicate(result, "BUY_SELL", entry.getAccountTransaction().getUUID(), + entry.getPortfolioTransaction().getUUID()); + assertThat(client.getLedger().getEntries().size(), is(1)); + assertSame(entry.getAccountTransaction(), account.getTransactions().get(0)); + assertSame(entry.getPortfolioTransaction(), portfolio.getTransactions().get(0)); + assertFalse(account.getTransactions().stream().anyMatch(LedgerBackedTransaction.class::isInstance)); + assertFalse(portfolio.getTransactions().stream().anyMatch(LedgerBackedTransaction.class::isInstance)); + } + + @Test + public void testPartialExistingAccountTransferProjectionDoesNotRemoveLegacyTransactions() + { + var client = new Client(); + var source = register(client, account()); + var target = register(client, account()); + var transfer = new AccountTransferEntry(source, target); + + transfer.setDate(DATE_TIME); + transfer.setAmount(Values.Amount.factorize(55)); + transfer.setCurrencyCode(CurrencyUnit.EUR); + transfer.insert(); + client.getLedger().addEntry(existingAccountProjectionEntry(LedgerEntryType.CASH_TRANSFER, source, + transfer.getSourceTransaction().getUUID())); + + var result = migrate(client); + + assertPartialDuplicate(result, "ACCOUNT_TRANSFER", transfer.getSourceTransaction().getUUID(), + transfer.getTargetTransaction().getUUID()); + assertThat(client.getLedger().getEntries().size(), is(1)); + assertSame(transfer.getSourceTransaction(), source.getTransactions().get(0)); + assertSame(transfer.getTargetTransaction(), target.getTransactions().get(0)); + } + + @Test + public void testPartialExistingPortfolioTransferProjectionDoesNotRemoveLegacyTransactions() + { + var client = new Client(); + var source = register(client, portfolio()); + var target = register(client, portfolio()); + var transfer = new name.abuchen.portfolio.model.PortfolioTransferEntry(source, target); + + transfer.setDate(DATE_TIME); + transfer.setSecurity(security()); + transfer.setShares(Values.Share.factorize(7)); + transfer.setAmount(Values.Amount.factorize(400)); + transfer.setCurrencyCode(CurrencyUnit.EUR); + transfer.insert(); + client.getLedger().addEntry(existingPortfolioProjectionEntry(LedgerEntryType.SECURITY_TRANSFER, target, + transfer.getTargetTransaction().getUUID(), LedgerProjectionRole.TARGET_PORTFOLIO)); + + var result = migrate(client); + + assertPartialDuplicate(result, "PORTFOLIO_TRANSFER", transfer.getSourceTransaction().getUUID(), + transfer.getTargetTransaction().getUUID()); + assertThat(client.getLedger().getEntries().size(), is(1)); + assertSame(transfer.getSourceTransaction(), source.getTransactions().get(0)); + assertSame(transfer.getTargetTransaction(), target.getTransactions().get(0)); + } + + @Test + public void testCompleteExistingBuySellGroupRemovesLegacyRowsWithoutCreatingDuplicateEntry() + { + var client = new Client(); + var account = register(client, account()); + var portfolio = register(client, portfolio()); + var entry = buySellEntry(portfolio, account, PortfolioTransaction.Type.BUY); + + entry.insert(); + client.getLedger().addEntry(existingBuySellEntry(account, portfolio, entry.getAccountTransaction().getUUID(), + entry.getPortfolioTransaction().getUUID())); + + var result = migrate(client); + + assertThat(result.getMigratedTransactionCount(), is(0)); + assertDiagnostic(result, "family=BUY_SELL", "reason=SKIPPED_ALREADY_MIGRATED", + entry.getAccountTransaction().getUUID(), entry.getPortfolioTransaction().getUUID()); + assertThat(client.getLedger().getEntries().size(), is(1)); + assertThat(account.getTransactions().size(), is(1)); + assertThat(portfolio.getTransactions().size(), is(1)); + assertTrue(account.getTransactions().get(0) instanceof LedgerBackedTransaction); + assertTrue(portfolio.getTransactions().get(0) instanceof LedgerBackedTransaction); + } + + @Test + public void testExistingAccountProjectionMustMatchFamilyShape() + { + assertAccountDuplicateConflict(AccountTransaction.Type.DEPOSIT, LedgerEntryType.REMOVAL, false, "ENTRY_TYPE"); + assertAccountDuplicateConflict(AccountTransaction.Type.DEPOSIT, LedgerEntryType.DEPOSIT, true, + "PROJECTION_OWNER"); + } + + @Test + public void testExistingDuplicateWithStructuralValidationFailureIsDiagnosed() + { + var client = new Client(); + var account = register(client, account()); + var transaction = accountTransaction(AccountTransaction.Type.DEPOSIT, 100); + var existing = existingAccountProjectionEntry(LedgerEntryType.DEPOSIT, account, transaction.getUUID()); + + existing.setDateTime(null); + account.addTransaction(transaction); + client.getLedger().addEntry(existing); + + var result = migrate(client); + + assertDuplicateConflictWithMismatch(result, "ACCOUNT", "STRUCTURAL_VALIDATION", transaction.getUUID()); + assertThat(client.getLedger().getEntries().size(), is(1)); + assertSame(existing, client.getLedger().getEntries().get(0)); + assertThat(account.getTransactions().size(), is(1)); + assertSame(transaction, account.getTransactions().get(0)); + assertFalse(account.getTransactions().stream().anyMatch(LedgerBackedTransaction.class::isInstance)); + } + + @Test + public void testExistingDividendProjectionMustMatchFamilyShape() + { + assertDividendDuplicateConflict(LedgerEntryType.DEPOSIT, false, false, "ENTRY_TYPE"); + assertDividendDuplicateConflict(LedgerEntryType.DIVIDENDS, true, false, "PROJECTION_OWNER"); + assertDividendDuplicateConflict(LedgerEntryType.DIVIDENDS, false, true, "DIVIDEND_SECURITY"); + } + + @Test + public void testExistingBuySellDuplicateMustMatchTypeRolesOwnersAndPostingOwners() + { + assertBuySellDuplicateConflict(existing -> existing.setType(LedgerEntryType.SELL), "ENTRY_TYPE"); + assertBuySellDuplicateConflict(this::swapBuySellProjectionSides, "PROJECTION_ROLE"); + assertBuySellDuplicateConflict((existing, account, portfolio, otherAccount, otherPortfolio) -> { + var accountProjection = existing.getProjectionRefs().get(0); + var cashPosting = postingByUUID(existing, accountProjection.getPrimaryPostingUUID()); + + accountProjection.setAccount(otherAccount); + cashPosting.setAccount(otherAccount); + }, "PROJECTION_OWNER"); + assertBuySellDuplicateConflict((existing, account, portfolio, otherAccount, otherPortfolio) -> { + var portfolioProjection = existing.getProjectionRefs().get(1); + var securityPosting = postingByUUID(existing, portfolioProjection.getPrimaryPostingUUID()); + + portfolioProjection.setPortfolio(otherPortfolio); + securityPosting.setPortfolio(otherPortfolio); + }, "PROJECTION_OWNER"); + assertBuySellDuplicateConflict((existing, account, portfolio, otherAccount, otherPortfolio) -> { + var accountProjection = existing.getProjectionRefs().get(0); + var cashPosting = postingByUUID(existing, accountProjection.getPrimaryPostingUUID()); + + cashPosting.setAccount(otherAccount); + }, "POSTING_OWNER"); + } + + @Test + public void testExistingAccountTransferDuplicateMustPreserveSourceTargetShape() + { + assertAccountTransferDuplicateConflict(existing -> { + existing.getProjectionRefs().get(0).setRole(LedgerProjectionRole.TARGET_ACCOUNT); + existing.getProjectionRefs().get(1).setRole(LedgerProjectionRole.SOURCE_ACCOUNT); + }, "PROJECTION_ROLE"); + assertAccountTransferDuplicateConflict((existing, source, target, other) -> { + var sourceProjection = existing.getProjectionRefs().get(0); + postingByUUID(existing, sourceProjection.getPrimaryPostingUUID()).setAccount(other); + }, "POSTING_OWNER"); + assertAccountTransferDuplicateConflict((existing, source, target, other) -> { + var targetProjection = existing.getProjectionRefs().get(1); + postingByUUID(existing, targetProjection.getPrimaryPostingUUID()).setAccount(other); + }, "POSTING_OWNER"); + } + + @Test + public void testExistingPortfolioTransferDuplicateMustPreserveSourceTargetShape() + { + assertPortfolioTransferDuplicateConflict(existing -> { + existing.getProjectionRefs().get(0).setRole(LedgerProjectionRole.TARGET_PORTFOLIO); + existing.getProjectionRefs().get(1).setRole(LedgerProjectionRole.SOURCE_PORTFOLIO); + }, "PROJECTION_ROLE"); + assertPortfolioTransferDuplicateConflict((existing, source, target, other) -> { + var sourceProjection = existing.getProjectionRefs().get(0); + postingByUUID(existing, sourceProjection.getPrimaryPostingUUID()).setPortfolio(other); + }, "POSTING_OWNER"); + assertPortfolioTransferDuplicateConflict((existing, source, target, other) -> { + var targetProjection = existing.getProjectionRefs().get(1); + postingByUUID(existing, targetProjection.getPrimaryPostingUUID()).setPortfolio(other); + }, "POSTING_OWNER"); + } + + @Test + public void testExistingDeliveryDuplicateMustMatchDirectionAndOwner() + { + assertDeliveryDuplicateConflict(LedgerEntryType.DELIVERY_OUTBOUND, false, "ENTRY_TYPE"); + assertDeliveryDuplicateConflict(LedgerEntryType.DELIVERY_INBOUND, true, "PROJECTION_OWNER"); + } + + @Test + public void testExistingDuplicateUnitPostingsMustMatchExactSemanticFacts() + { + assertAccountUnitDuplicateConflict(existing -> unitPosting(existing, LedgerPostingType.FEE).setAmount( + Values.Amount.factorize(8))); + assertAccountUnitDuplicateConflict(existing -> unitPosting(existing, LedgerPostingType.TAX).setCurrency( + CurrencyUnit.USD)); + assertAccountUnitDuplicateConflict(existing -> { + var posting = unitPosting(existing, LedgerPostingType.GROSS_VALUE); + + posting.setForexAmount(Values.Amount.factorize(301)); + posting.setForexCurrency(CurrencyUnit.EUR); + posting.setExchangeRate(BigDecimal.valueOf(0.6)); + }); + } + + @Test + public void testExistingDuplicateUnitPostingsAreComparedWithoutPostingUUIDOrListOrder() + { + var client = new Client(); + var account = register(client, account()); + var transaction = accountTransaction(AccountTransaction.Type.INTEREST, 100); + var existing = existingAccountProjectionEntry(LedgerEntryType.INTEREST, account, transaction.getUUID()); + + transaction.addUnit(new Unit(Unit.Type.FEE, money(4))); + transaction.addUnit(new Unit(Unit.Type.FEE, money(4))); + transaction.addUnit(new Unit(Unit.Type.TAX, money(3))); + account.addTransaction(transaction); + existing.addPosting(unitPosting(LedgerPostingType.TAX, 3)); + existing.addPosting(unitPosting(LedgerPostingType.FEE, 4)); + existing.addPosting(unitPosting(LedgerPostingType.FEE, 4)); + client.getLedger().addEntry(existing); + + var result = migrate(client); + + assertThat(result.getMigratedTransactionCount(), is(0)); + assertDiagnostic(result, "family=ACCOUNT", "reason=SKIPPED_ALREADY_MIGRATED", transaction.getUUID()); + assertThat(client.getLedger().getEntries().size(), is(1)); + assertTrue(account.getTransactions().get(0) instanceof LedgerBackedTransaction); + } + + @Test + public void testRerunAfterValidMultiProjectionAndDeliveryMigrationIsIdempotent() + { + var client = new Client(); + var sourceAccount = register(client, account()); + var targetAccount = register(client, account()); + var sourcePortfolio = register(client, portfolio()); + var targetPortfolio = register(client, portfolio()); + var deliveryPortfolio = register(client, portfolio()); + var accountTransfer = new AccountTransferEntry(sourceAccount, targetAccount); + var portfolioTransfer = new name.abuchen.portfolio.model.PortfolioTransferEntry(sourcePortfolio, + targetPortfolio); + var delivery = portfolioTransaction(PortfolioTransaction.Type.DELIVERY_INBOUND, security(), 100); + + accountTransfer.setDate(DATE_TIME); + accountTransfer.setAmount(Values.Amount.factorize(55)); + accountTransfer.setCurrencyCode(CurrencyUnit.EUR); + accountTransfer.insert(); + portfolioTransfer.setDate(DATE_TIME); + portfolioTransfer.setSecurity(security()); + portfolioTransfer.setShares(Values.Share.factorize(7)); + portfolioTransfer.setAmount(Values.Amount.factorize(400)); + portfolioTransfer.setCurrencyCode(CurrencyUnit.EUR); + portfolioTransfer.insert(); + deliveryPortfolio.addTransaction(delivery); + + var first = migrate(client); + var second = migrate(client); + + assertThat(first.getMigratedTransactionCount(), is(5)); + assertThat(second.getMigratedTransactionCount(), is(0)); + assertThat(client.getLedger().getEntries().size(), is(3)); + assertThat(sourceAccount.getTransactions().size(), is(1)); + assertThat(targetAccount.getTransactions().size(), is(1)); + assertThat(sourcePortfolio.getTransactions().size(), is(1)); + assertThat(targetPortfolio.getTransactions().size(), is(1)); + assertThat(deliveryPortfolio.getTransactions().size(), is(1)); + } + + @Test + public void testAccountTransferUnitsAreRejectedRatherThanDropped() + { + var client = new Client(); + var source = register(client, account()); + var target = register(client, account()); + var transfer = new AccountTransferEntry(source, target); + + transfer.setDate(DATE_TIME); + transfer.setAmount(Values.Amount.factorize(55)); + transfer.setCurrencyCode(CurrencyUnit.EUR); + transfer.getSourceTransaction().addUnit(new Unit(Unit.Type.FEE, money(1))); + transfer.insert(); + + var result = migrate(client); + + assertNoMigration(result, client); + assertDiagnostic(result, LedgerDiagnosticCode.LEDGER_IMPORT_010.prefix(), "family=ACCOUNT_TRANSFER", + "reason=UNSUPPORTED_UNITS", transfer.getSourceTransaction().getUUID(), + transfer.getTargetTransaction().getUUID()); + assertSame(transfer.getSourceTransaction(), source.getTransactions().get(0)); + assertSame(transfer.getTargetTransaction(), target.getTransactions().get(0)); + } + + @Test + public void testPortfolioTransferUnitsAreRejectedRatherThanDropped() + { + var client = new Client(); + var source = register(client, portfolio()); + var target = register(client, portfolio()); + var transfer = new name.abuchen.portfolio.model.PortfolioTransferEntry(source, target); + + transfer.setDate(DATE_TIME); + transfer.setSecurity(security()); + transfer.setShares(Values.Share.factorize(7)); + transfer.setAmount(Values.Amount.factorize(400)); + transfer.setCurrencyCode(CurrencyUnit.EUR); + transfer.getTargetTransaction().addUnit(new Unit(Unit.Type.TAX, money(1))); + transfer.insert(); + + var result = migrate(client); + + assertNoMigration(result, client); + assertDiagnostic(result, LedgerDiagnosticCode.LEDGER_IMPORT_015.prefix(), "family=PORTFOLIO_TRANSFER", + "reason=UNSUPPORTED_UNITS", transfer.getSourceTransaction().getUUID(), + transfer.getTargetTransaction().getUUID()); + assertSame(transfer.getSourceTransaction(), source.getTransactions().get(0)); + assertSame(transfer.getTargetTransaction(), target.getTransactions().get(0)); + } + + @Test + public void testUnsupportedUnitPostingMembershipHasImportCode() throws ReflectiveOperationException + { + var projection = new LedgerProjectionRef(); + var posting = new LedgerPosting(); + var builder = Class.forName( + "name.abuchen.portfolio.model.ledger.legacy.LegacyTransactionToLedgerMigrator$MigrationGraphBuilder"); + var method = builder.getDeclaredMethod("addUnitMemberships", LedgerProjectionRef.class, List.class); + + posting.setType(LedgerPostingType.CASH); + method.setAccessible(true); + + var exception = assertThrows(InvocationTargetException.class, + () -> method.invoke(null, projection, List.of(posting))); + + assertThat(exception.getCause(), instanceOf(IllegalArgumentException.class)); + assertTrue(exception.getCause().getMessage().contains(LedgerDiagnosticCode.LEDGER_IMPORT_021.prefix())); + assertTrue(exception.getCause().getMessage().contains("Unsupported unit posting type: CASH")); + } + + @Test + public void testClientAllTransactionsUsesDeduplicatedMigratedShape() + { + var client = new Client(); + var account = register(client, account()); + var portfolio = register(client, portfolio()); + var entry = buySellEntry(portfolio, account, PortfolioTransaction.Type.BUY); + + entry.insert(); + + migrate(client); + + assertThat(client.getAllTransactions().size(), is(1)); + assertSame(portfolio, client.getAllTransactions().get(0).getOwner()); + assertThat(client.getAllTransactions().get(0).getTransaction().getUUID(), + is(entry.getPortfolioTransaction().getUUID())); + } + + @Test + public void testInvestmentPlanLegacyTransactionRefsBecomeStableLedgerRefs() + { + var client = new Client(); + var account = register(client, account()); + var portfolio = register(client, portfolio()); + var entry = buySellEntry(portfolio, account, PortfolioTransaction.Type.BUY); + var plan = new InvestmentPlan(); + + entry.getPortfolioTransaction().addUnit(new Unit(Unit.Type.FEE, money(5))); + entry.insert(); + plan.getTransactions().add(entry.getPortfolioTransaction()); + client.addPlan(plan); + + var accountUUID = entry.getAccountTransaction().getUUID(); + var portfolioUUID = entry.getPortfolioTransaction().getUUID(); + + migrate(client); + + var ledgerEntry = onlyLedgerEntry(client); + var cashPosting = ledgerEntry.getPostings().stream() + .filter(posting -> posting.getType() == LedgerPostingType.CASH).findFirst().orElseThrow(); + var securityPosting = ledgerEntry.getPostings().stream() + .filter(posting -> posting.getType() == LedgerPostingType.SECURITY).findFirst().orElseThrow(); + var feePosting = ledgerEntry.getPostings().stream() + .filter(posting -> posting.getType() == LedgerPostingType.FEE).findFirst().orElseThrow(); + var ref = plan.getLedgerExecutionRefs().get(0); + + assertTrue(plan.getTransactions().isEmpty()); + assertThat(plan.getLedgerExecutionRefs().size(), is(1)); + assertThat(ref.getLedgerEntryUUID(), is(migratedEntryUUID(LedgerEntryType.BUY, portfolioUUID))); + assertThat(ref.getProjectionUUID(), is(portfolioUUID)); + assertThat(ref.getProjectionRole(), is(LedgerProjectionRole.PORTFOLIO)); + assertThat(ledgerEntry.getUUID(), is(ref.getLedgerEntryUUID())); + assertThat(cashPosting.getUUID(), is(migratedPostingUUID(accountUUID, LedgerPostingType.CASH, "primary"))); + assertThat(securityPosting.getUUID(), + is(migratedPostingUUID(portfolioUUID, LedgerPostingType.SECURITY, "primary"))); + assertThat(feePosting.getUUID(), is(migratedPostingUUID(portfolioUUID, LedgerPostingType.FEE, "unit-0"))); + assertValid(client); + } + + private void assertAccountOnlyMigration(AccountTransaction.Type type, LedgerEntryType entryType) + { + var client = new Client(); + var account = register(client, account()); + var security = security(); + var transaction = accountTransaction(type, 100); + + transaction.setSecurity(security); + transaction.setShares(Values.Share.factorize(2)); + transaction.addUnit(new Unit(Unit.Type.TAX, money(3))); + transaction.addUnit(new Unit(Unit.Type.FEE, money(4))); + account.addTransaction(transaction); + + var result = migrate(client); + var migrated = onlyAccountTransaction(account); + var entry = onlyLedgerEntry(client); + var projection = entry.getProjectionRefs().get(0); + + assertFalse(result.hasDiagnostics()); + assertThat(result.getMigratedTransactionCount(), is(1)); + assertThat(client.getLedger().getEntries().size(), is(1)); + assertThat(entry.getType(), is(entryType)); + assertThat(projection.getPrimaryMembership().orElseThrow().getPostingUUID(), + is(projection.getPrimaryPostingUUID())); + assertThat(projection.getMembershipsByRole(ProjectionMembershipRole.FEE_UNIT).size(), is(1)); + assertThat(projection.getMembershipsByRole(ProjectionMembershipRole.TAX_UNIT).size(), is(1)); + assertThat(migrated, instanceOf(LedgerBackedAccountTransaction.class)); + assertThat(migrated.getUUID(), is(transaction.getUUID())); + assertThat(migrated.getType(), is(type)); + assertThat(migrated.getDateTime(), is(transaction.getDateTime())); + assertThat(migrated.getAmount(), is(transaction.getAmount())); + assertThat(migrated.getCurrencyCode(), is(transaction.getCurrencyCode())); + assertSame(security, migrated.getSecurity()); + assertThat(migrated.getShares(), is(transaction.getShares())); + assertThat(migrated.getNote(), is(transaction.getNote())); + assertThat(migrated.getSource(), is(transaction.getSource())); + assertThat(migrated.getUnits().toList().size(), is(2)); + assertValid(client); + } + + private void assertBuySellMigration(PortfolioTransaction.Type type, LedgerEntryType entryType) + { + var client = new Client(); + var account = register(client, account()); + var portfolio = register(client, portfolio()); + var entry = buySellEntry(portfolio, account, type); + + entry.getPortfolioTransaction().addUnit(new Unit(Unit.Type.FEE, money(5))); + entry.getPortfolioTransaction().addUnit(new Unit(Unit.Type.TAX, money(6))); + entry.getPortfolioTransaction().addUnit(new Unit(Unit.Type.GROSS_VALUE, money(150), + Money.of(CurrencyUnit.USD, Values.Amount.factorize(300)), BigDecimal.valueOf(0.5))); + entry.insert(); + + var accountUUID = entry.getAccountTransaction().getUUID(); + var portfolioUUID = entry.getPortfolioTransaction().getUUID(); + + var result = migrate(client); + var ledgerEntry = onlyLedgerEntry(client); + var accountTransaction = onlyAccountTransaction(account); + var portfolioTransaction = onlyPortfolioTransaction(portfolio); + var accountProjection = ledgerEntry.getProjectionRefs().get(0); + var portfolioProjection = ledgerEntry.getProjectionRefs().get(1); + + assertFalse(result.hasDiagnostics()); + assertThat(client.getLedger().getEntries().size(), is(1)); + assertThat(ledgerEntry.getType(), is(entryType)); + assertThat(ledgerEntry.getProjectionRefs().size(), is(2)); + assertThat(accountTransaction.getUUID(), is(accountUUID)); + assertThat(portfolioTransaction.getUUID(), is(portfolioUUID)); + assertThat(accountProjection.getPrimaryMembership().orElseThrow().getPostingUUID(), + is(accountProjection.getPrimaryPostingUUID())); + assertThat(portfolioProjection.getPrimaryMembership().orElseThrow().getPostingUUID(), + is(portfolioProjection.getPrimaryPostingUUID())); + assertThat(accountProjection.getMembershipsByRole(ProjectionMembershipRole.FEE_UNIT).size(), is(1)); + assertThat(accountProjection.getMembershipsByRole(ProjectionMembershipRole.TAX_UNIT).size(), is(1)); + assertThat(accountProjection.getMembershipsByRole(ProjectionMembershipRole.GROSS_VALUE_UNIT).size(), is(1)); + assertThat(portfolioProjection.getMembershipsByRole(ProjectionMembershipRole.FEE_UNIT).size(), is(1)); + assertThat(portfolioProjection.getMembershipsByRole(ProjectionMembershipRole.TAX_UNIT).size(), is(1)); + assertThat(portfolioProjection.getMembershipsByRole(ProjectionMembershipRole.GROSS_VALUE_UNIT).size(), is(1)); + assertThat(accountTransaction.getType().name(), is(type.name())); + assertThat(portfolioTransaction.getType(), is(type)); + assertThat(accountTransaction.getDateTime(), is(entry.getAccountTransaction().getDateTime())); + assertThat(accountTransaction.getAmount(), is(entry.getAccountTransaction().getAmount())); + assertThat(accountTransaction.getCurrencyCode(), is(entry.getAccountTransaction().getCurrencyCode())); + assertSame(entry.getAccountTransaction().getSecurity(), accountTransaction.getSecurity()); + assertThat(accountTransaction.getNote(), is(entry.getAccountTransaction().getNote())); + assertThat(accountTransaction.getSource(), is(entry.getAccountTransaction().getSource())); + assertThat(portfolioTransaction.getAmount(), is(entry.getPortfolioTransaction().getAmount())); + assertThat(portfolioTransaction.getCurrencyCode(), is(entry.getPortfolioTransaction().getCurrencyCode())); + assertSame(entry.getPortfolioTransaction().getSecurity(), portfolioTransaction.getSecurity()); + assertThat(portfolioTransaction.getShares(), is(entry.getPortfolioTransaction().getShares())); + assertThat(portfolioTransaction.getNote(), is(entry.getPortfolioTransaction().getNote())); + assertThat(portfolioTransaction.getSource(), is(entry.getPortfolioTransaction().getSource())); + assertSame(portfolio, accountTransaction.getCrossEntry().getCrossOwner(accountTransaction)); + assertSame(portfolioTransaction, accountTransaction.getCrossEntry().getCrossTransaction(accountTransaction)); + assertThat(portfolioTransaction.getUnits().toList().size(), is(3)); + assertTrue(portfolioTransaction.getUnits().anyMatch(unit -> unit.getType() == Unit.Type.GROSS_VALUE + && CurrencyUnit.USD.equals(unit.getForex().getCurrencyCode()) + && unit.getForex().getAmount() == Values.Amount.factorize(300) + && BigDecimal.valueOf(0.5).compareTo(unit.getExchangeRate()) == 0)); + assertValid(client); + } + + private void assertDeliveryMigration(PortfolioTransaction.Type type, LedgerEntryType entryType) + { + var client = new Client(); + var portfolio = register(client, portfolio()); + var security = security(); + var transaction = portfolioTransaction(type, security, 100); + + transaction.addUnit(new Unit(Unit.Type.FEE, money(4))); + portfolio.addTransaction(transaction); + + var result = migrate(client); + var migrated = onlyPortfolioTransaction(portfolio); + + assertFalse(result.hasDiagnostics()); + assertThat(onlyLedgerEntry(client).getType(), is(entryType)); + assertThat(migrated.getUUID(), is(transaction.getUUID())); + assertThat(migrated.getType(), is(type)); + assertSame(security, migrated.getSecurity()); + assertThat(migrated.getShares(), is(transaction.getShares())); + assertThat(migrated.getAmount(), is(transaction.getAmount())); + assertThat(migrated.getCurrencyCode(), is(transaction.getCurrencyCode())); + assertThat(migrated.getDateTime(), is(transaction.getDateTime())); + assertThat(migrated.getNote(), is(transaction.getNote())); + assertThat(migrated.getSource(), is(transaction.getSource())); + assertThat(migrated.getUnits().toList().size(), is(1)); + assertValid(client); + } + + private void assertAccountDuplicateConflict(AccountTransaction.Type type, LedgerEntryType existingEntryType, + boolean wrongOwner, String expectedMismatch) + { + var client = new Client(); + var account = register(client, account()); + var existingAccount = wrongOwner ? register(client, account()) : account; + var transaction = accountTransaction(type, 100); + + account.addTransaction(transaction); + client.getLedger().addEntry(existingAccountProjectionEntry(existingEntryType, existingAccount, + transaction.getUUID())); + + var result = migrate(client); + + assertDuplicateConflictWithMismatch(result, "ACCOUNT", expectedMismatch, transaction.getUUID()); + assertThat(client.getLedger().getEntries().size(), is(1)); + assertSame(transaction, account.getTransactions().get(0)); + assertFalse(account.getTransactions().stream().anyMatch(LedgerBackedTransaction.class::isInstance)); + } + + private void assertDividendDuplicateConflict(LedgerEntryType existingEntryType, boolean wrongOwner, + boolean wrongSecurity, String expectedMismatch) + { + var client = new Client(); + var account = register(client, account()); + var existingAccount = wrongOwner ? register(client, account()) : account; + var security = security(); + var dividend = accountTransaction(AccountTransaction.Type.DIVIDENDS, 120); + var existing = existingAccountProjectionEntry(existingEntryType, existingAccount, dividend.getUUID()); + + dividend.setSecurity(security); + account.addTransaction(dividend); + existing.getPostings().get(0).setSecurity(wrongSecurity ? new Security("Other", CurrencyUnit.EUR) : security); + client.getLedger().addEntry(existing); + + var result = migrate(client); + + assertDuplicateConflictWithMismatch(result, "ACCOUNT", expectedMismatch, dividend.getUUID()); + assertThat(client.getLedger().getEntries().size(), is(1)); + assertSame(dividend, account.getTransactions().get(0)); + assertFalse(account.getTransactions().stream().anyMatch(LedgerBackedTransaction.class::isInstance)); + } + + private void assertBuySellDuplicateConflict(Consumer mutator, String expectedMismatch) + { + assertBuySellDuplicateConflict((existing, account, portfolio, otherAccount, otherPortfolio) -> mutator.accept( + existing), expectedMismatch); + } + + private void assertBuySellDuplicateConflict(BuySellDuplicateMutator mutator, String expectedMismatch) + { + var client = new Client(); + var account = register(client, account()); + var portfolio = register(client, portfolio()); + var otherAccount = register(client, account()); + var otherPortfolio = register(client, portfolio()); + var entry = buySellEntry(portfolio, account, PortfolioTransaction.Type.BUY); + var existing = existingBuySellEntry(account, portfolio, entry.getAccountTransaction().getUUID(), + entry.getPortfolioTransaction().getUUID()); + + entry.insert(); + mutator.accept(existing, account, portfolio, otherAccount, otherPortfolio); + client.getLedger().addEntry(existing); + + var result = migrate(client); + + assertDuplicateConflictWithMismatch(result, "BUY_SELL", expectedMismatch, entry.getAccountTransaction().getUUID(), + entry.getPortfolioTransaction().getUUID()); + assertThat(client.getLedger().getEntries().size(), is(1)); + assertSame(entry.getAccountTransaction(), account.getTransactions().get(0)); + assertSame(entry.getPortfolioTransaction(), portfolio.getTransactions().get(0)); + assertFalse(account.getTransactions().stream().anyMatch(LedgerBackedTransaction.class::isInstance)); + assertFalse(portfolio.getTransactions().stream().anyMatch(LedgerBackedTransaction.class::isInstance)); + } + + private void assertAccountTransferDuplicateConflict(Consumer mutator, String expectedMismatch) + { + assertAccountTransferDuplicateConflict((existing, source, target, other) -> mutator.accept(existing), + expectedMismatch); + } + + private void assertAccountTransferDuplicateConflict(AccountTransferDuplicateMutator mutator, + String expectedMismatch) + { + var client = new Client(); + var source = register(client, account()); + var target = register(client, account()); + var other = register(client, account()); + var transfer = new AccountTransferEntry(source, target); + transfer.setDate(DATE_TIME); + transfer.setAmount(Values.Amount.factorize(55)); + transfer.setCurrencyCode(CurrencyUnit.EUR); + transfer.insert(); + var existing = existingAccountTransferEntry(source, target, transfer.getSourceTransaction().getUUID(), + transfer.getTargetTransaction().getUUID()); + + mutator.accept(existing, source, target, other); + client.getLedger().addEntry(existing); + + var result = migrate(client); + + assertDuplicateConflictWithMismatch(result, "ACCOUNT_TRANSFER", expectedMismatch, + transfer.getSourceTransaction().getUUID(), + transfer.getTargetTransaction().getUUID()); + assertThat(client.getLedger().getEntries().size(), is(1)); + assertSame(transfer.getSourceTransaction(), source.getTransactions().get(0)); + assertSame(transfer.getTargetTransaction(), target.getTransactions().get(0)); + } + + private void assertPortfolioTransferDuplicateConflict(Consumer mutator, String expectedMismatch) + { + assertPortfolioTransferDuplicateConflict((existing, source, target, other) -> mutator.accept(existing), + expectedMismatch); + } + + private void assertPortfolioTransferDuplicateConflict(PortfolioTransferDuplicateMutator mutator, + String expectedMismatch) + { + var client = new Client(); + var source = register(client, portfolio()); + var target = register(client, portfolio()); + var other = register(client, portfolio()); + var transfer = new name.abuchen.portfolio.model.PortfolioTransferEntry(source, target); + transfer.setDate(DATE_TIME); + transfer.setSecurity(security()); + transfer.setShares(Values.Share.factorize(7)); + transfer.setAmount(Values.Amount.factorize(400)); + transfer.setCurrencyCode(CurrencyUnit.EUR); + transfer.insert(); + var existing = existingPortfolioTransferEntry(source, target, transfer.getSourceTransaction().getUUID(), + transfer.getTargetTransaction().getUUID()); + + mutator.accept(existing, source, target, other); + client.getLedger().addEntry(existing); + + var result = migrate(client); + + assertDuplicateConflictWithMismatch(result, "PORTFOLIO_TRANSFER", expectedMismatch, + transfer.getSourceTransaction().getUUID(), + transfer.getTargetTransaction().getUUID()); + assertThat(client.getLedger().getEntries().size(), is(1)); + assertSame(transfer.getSourceTransaction(), source.getTransactions().get(0)); + assertSame(transfer.getTargetTransaction(), target.getTransactions().get(0)); + } + + private void assertDeliveryDuplicateConflict(LedgerEntryType existingEntryType, boolean wrongOwner, + String expectedMismatch) + { + var client = new Client(); + var portfolio = register(client, portfolio()); + var existingPortfolio = wrongOwner ? register(client, portfolio()) : portfolio; + var transaction = portfolioTransaction(PortfolioTransaction.Type.DELIVERY_INBOUND, security(), 100); + + portfolio.addTransaction(transaction); + var role = existingEntryType == LedgerEntryType.DELIVERY_OUTBOUND ? LedgerProjectionRole.DELIVERY_OUTBOUND + : LedgerProjectionRole.DELIVERY_INBOUND; + client.getLedger().addEntry(existingDeliveryEntry(existingEntryType, existingPortfolio, transaction.getUUID(), + role)); + + var result = migrate(client); + + assertDuplicateConflictWithMismatch(result, "DELIVERY", expectedMismatch, transaction.getUUID()); + assertThat(client.getLedger().getEntries().size(), is(1)); + assertSame(transaction, portfolio.getTransactions().get(0)); + assertFalse(portfolio.getTransactions().stream().anyMatch(LedgerBackedTransaction.class::isInstance)); + } + + private void assertAccountUnitDuplicateConflict(Consumer mutator) + { + var client = new Client(); + var account = register(client, account()); + var transaction = accountTransaction(AccountTransaction.Type.INTEREST, 100); + var existing = existingAccountProjectionEntry(LedgerEntryType.INTEREST, account, transaction.getUUID()); + + transaction.addUnit(new Unit(Unit.Type.FEE, money(4))); + transaction.addUnit(new Unit(Unit.Type.TAX, money(3))); + transaction.addUnit(new Unit(Unit.Type.GROSS_VALUE, money(150), + Money.of(CurrencyUnit.USD, Values.Amount.factorize(300)), BigDecimal.valueOf(0.5))); + account.addTransaction(transaction); + existing.addPosting(unitPosting(LedgerPostingType.FEE, 4)); + existing.addPosting(unitPosting(LedgerPostingType.TAX, 3)); + existing.addPosting(grossValuePosting(150, CurrencyUnit.USD, 300, BigDecimal.valueOf(0.5))); + mutator.accept(existing); + client.getLedger().addEntry(existing); + + var result = migrate(client); + + assertDuplicateConflictWithMismatch(result, "ACCOUNT", "UNIT_POSTINGS", transaction.getUUID()); + assertThat(client.getLedger().getEntries().size(), is(1)); + assertSame(transaction, account.getTransactions().get(0)); + assertFalse(account.getTransactions().stream().anyMatch(LedgerBackedTransaction.class::isInstance)); + } + + private void assertBuySellMetadataMismatchIsRejected(String field) + { + var client = new Client(); + var account = register(client, account()); + var portfolio = register(client, portfolio()); + var entry = buySellEntry(portfolio, account, PortfolioTransaction.Type.BUY); + var expectedCode = switch (field) + { + case "dateTime" -> LedgerDiagnosticCode.LEDGER_IMPORT_018; + case "note" -> LedgerDiagnosticCode.LEDGER_IMPORT_019; + case "source" -> LedgerDiagnosticCode.LEDGER_IMPORT_020; + default -> throw new IllegalArgumentException(field); + }; + + switch (field) + { + case "dateTime" -> entry.getAccountTransaction().setDateTime(DATE_TIME.plusDays(1)); + case "note" -> entry.getAccountTransaction().setNote("different note"); + case "source" -> entry.getAccountTransaction().setSource("different source"); + default -> throw new IllegalArgumentException(field); + } + + entry.insert(); + + var result = migrate(client); + + assertNoMigration(result, client); + assertDiagnostic(result, expectedCode.prefix(), "family=BUY_SELL", "reason=METADATA_MISMATCH", + "field=" + field, entry.getAccountTransaction().getUUID(), + entry.getPortfolioTransaction().getUUID()); + assertSame(entry.getAccountTransaction(), account.getTransactions().get(0)); + assertSame(entry.getPortfolioTransaction(), portfolio.getTransactions().get(0)); + } + + private void assertNoMigration(LegacyTransactionToLedgerMigrator.MigrationResult result, Client client) + { + assertTrue(result.hasDiagnostics()); + assertThat(result.getMigratedTransactionCount(), is(0)); + assertTrue(client.getLedger().getEntries().isEmpty()); + } + + private void assertPartialDuplicate(LegacyTransactionToLedgerMigrator.MigrationResult result, String family, + String... uuids) + { + assertDuplicateConflict(result, family, uuids); + } + + private void assertDuplicateConflict(LegacyTransactionToLedgerMigrator.MigrationResult result, String family, + String... uuids) + { + assertThat(result.getMigratedTransactionCount(), is(0)); + assertDiagnostic(result, "family=" + family, "reason=DUPLICATE_CONFLICT"); + + for (var uuid : uuids) + assertDiagnostic(result, uuid); + } + + private void assertDuplicateConflictWithMismatch(LegacyTransactionToLedgerMigrator.MigrationResult result, + String family, + String expectedMismatch, String... uuids) + { + assertDuplicateConflict(result, family, uuids); + assertDiagnostic(result, "mismatch=" + expectedMismatch); + } + + private void assertDiagnostic(LegacyTransactionToLedgerMigrator.MigrationResult result, String... fragments) + { + assertTrue(result.getDiagnostics().stream().anyMatch(diagnostic -> List.of(fragments).stream() + .allMatch(diagnostic::contains))); + } + + private LedgerEntry existingAccountProjectionEntry(LedgerEntryType entryType, Account account, String projectionUUID) + { + var entry = new LedgerEntry(); + var posting = new LedgerPosting(); + var projection = new LedgerProjectionRef(projectionUUID); + + entry.setType(entryType); + entry.setDateTime(DATE_TIME); + posting.setType(LedgerPostingType.CASH); + posting.setAccount(account); + posting.setAmount(Values.Amount.factorize(1)); + posting.setCurrency(CurrencyUnit.EUR); + projection.setRole(LedgerProjectionRole.ACCOUNT); + projection.setAccount(account); + projection.setPrimaryPostingUUID(posting.getUUID()); + entry.addPosting(posting); + entry.addProjectionRef(projection); + + return entry; + } + + private LedgerEntry existingPortfolioProjectionEntry(LedgerEntryType entryType, Portfolio portfolio, + String projectionUUID, LedgerProjectionRole role) + { + var entry = new LedgerEntry(); + var posting = new LedgerPosting(); + var projection = new LedgerProjectionRef(projectionUUID); + + entry.setType(entryType); + entry.setDateTime(DATE_TIME); + posting.setType(LedgerPostingType.SECURITY); + posting.setPortfolio(portfolio); + posting.setAmount(Values.Amount.factorize(1)); + posting.setCurrency(CurrencyUnit.EUR); + posting.setSecurity(security()); + posting.setShares(Values.Share.factorize(1)); + projection.setRole(role); + projection.setPortfolio(portfolio); + projection.setPrimaryPostingUUID(posting.getUUID()); + entry.addPosting(posting); + entry.addProjectionRef(projection); + + return entry; + } + + private LedgerEntry existingBuySellEntry(Account account, Portfolio portfolio, String accountProjectionUUID, + String portfolioProjectionUUID) + { + var entry = new LedgerEntry(); + var cashPosting = new LedgerPosting(); + var securityPosting = new LedgerPosting(); + var accountProjection = new LedgerProjectionRef(accountProjectionUUID); + var portfolioProjection = new LedgerProjectionRef(portfolioProjectionUUID); + + entry.setType(LedgerEntryType.BUY); + entry.setDateTime(DATE_TIME); + cashPosting.setType(LedgerPostingType.CASH); + cashPosting.setAccount(account); + cashPosting.setAmount(Values.Amount.factorize(100)); + cashPosting.setCurrency(CurrencyUnit.EUR); + securityPosting.setType(LedgerPostingType.SECURITY); + securityPosting.setPortfolio(portfolio); + securityPosting.setAmount(Values.Amount.factorize(100)); + securityPosting.setCurrency(CurrencyUnit.EUR); + securityPosting.setSecurity(security()); + securityPosting.setShares(Values.Share.factorize(5)); + accountProjection.setRole(LedgerProjectionRole.ACCOUNT); + accountProjection.setAccount(account); + accountProjection.setPrimaryPostingUUID(cashPosting.getUUID()); + portfolioProjection.setRole(LedgerProjectionRole.PORTFOLIO); + portfolioProjection.setPortfolio(portfolio); + portfolioProjection.setPrimaryPostingUUID(securityPosting.getUUID()); + entry.addPosting(cashPosting); + entry.addPosting(securityPosting); + entry.addProjectionRef(accountProjection); + entry.addProjectionRef(portfolioProjection); + + return entry; + } + + private LedgerEntry existingAccountTransferEntry(Account source, Account target, String sourceProjectionUUID, + String targetProjectionUUID) + { + var entry = new LedgerEntry(); + var sourcePosting = new LedgerPosting(); + var targetPosting = new LedgerPosting(); + var sourceProjection = new LedgerProjectionRef(sourceProjectionUUID); + var targetProjection = new LedgerProjectionRef(targetProjectionUUID); + + entry.setType(LedgerEntryType.CASH_TRANSFER); + entry.setDateTime(DATE_TIME); + sourcePosting.setType(LedgerPostingType.CASH); + sourcePosting.setAccount(source); + sourcePosting.setAmount(Values.Amount.factorize(55)); + sourcePosting.setCurrency(CurrencyUnit.EUR); + targetPosting.setType(LedgerPostingType.CASH); + targetPosting.setAccount(target); + targetPosting.setAmount(Values.Amount.factorize(55)); + targetPosting.setCurrency(CurrencyUnit.EUR); + sourceProjection.setRole(LedgerProjectionRole.SOURCE_ACCOUNT); + sourceProjection.setAccount(source); + sourceProjection.setPrimaryPostingUUID(sourcePosting.getUUID()); + targetProjection.setRole(LedgerProjectionRole.TARGET_ACCOUNT); + targetProjection.setAccount(target); + targetProjection.setPrimaryPostingUUID(targetPosting.getUUID()); + entry.addPosting(sourcePosting); + entry.addPosting(targetPosting); + entry.addProjectionRef(sourceProjection); + entry.addProjectionRef(targetProjection); + + return entry; + } + + private LedgerEntry existingPortfolioTransferEntry(Portfolio source, Portfolio target, String sourceProjectionUUID, + String targetProjectionUUID) + { + var entry = new LedgerEntry(); + var sourcePosting = new LedgerPosting(); + var targetPosting = new LedgerPosting(); + var sourceProjection = new LedgerProjectionRef(sourceProjectionUUID); + var targetProjection = new LedgerProjectionRef(targetProjectionUUID); + var security = security(); + + entry.setType(LedgerEntryType.SECURITY_TRANSFER); + entry.setDateTime(DATE_TIME); + sourcePosting.setType(LedgerPostingType.SECURITY); + sourcePosting.setPortfolio(source); + sourcePosting.setAmount(Values.Amount.factorize(400)); + sourcePosting.setCurrency(CurrencyUnit.EUR); + sourcePosting.setSecurity(security); + sourcePosting.setShares(Values.Share.factorize(7)); + targetPosting.setType(LedgerPostingType.SECURITY); + targetPosting.setPortfolio(target); + targetPosting.setAmount(Values.Amount.factorize(400)); + targetPosting.setCurrency(CurrencyUnit.EUR); + targetPosting.setSecurity(security); + targetPosting.setShares(Values.Share.factorize(7)); + sourceProjection.setRole(LedgerProjectionRole.SOURCE_PORTFOLIO); + sourceProjection.setPortfolio(source); + sourceProjection.setPrimaryPostingUUID(sourcePosting.getUUID()); + targetProjection.setRole(LedgerProjectionRole.TARGET_PORTFOLIO); + targetProjection.setPortfolio(target); + targetProjection.setPrimaryPostingUUID(targetPosting.getUUID()); + entry.addPosting(sourcePosting); + entry.addPosting(targetPosting); + entry.addProjectionRef(sourceProjection); + entry.addProjectionRef(targetProjection); + + return entry; + } + + private LedgerEntry existingDeliveryEntry(LedgerEntryType entryType, Portfolio portfolio, String projectionUUID, + LedgerProjectionRole role) + { + var entry = new LedgerEntry(); + var posting = new LedgerPosting(); + var projection = new LedgerProjectionRef(projectionUUID); + + entry.setType(entryType); + entry.setDateTime(DATE_TIME); + posting.setType(LedgerPostingType.SECURITY); + posting.setPortfolio(portfolio); + posting.setAmount(Values.Amount.factorize(100)); + posting.setCurrency(CurrencyUnit.EUR); + posting.setSecurity(security()); + posting.setShares(Values.Share.factorize(5)); + projection.setRole(role); + projection.setPortfolio(portfolio); + projection.setPrimaryPostingUUID(posting.getUUID()); + entry.addPosting(posting); + entry.addProjectionRef(projection); + + return entry; + } + + private void swapBuySellProjectionSides(LedgerEntry entry) + { + var accountProjection = entry.getProjectionRefs().get(0); + var portfolioProjection = entry.getProjectionRefs().get(1); + var account = accountProjection.getAccount(); + var portfolio = portfolioProjection.getPortfolio(); + var cashPostingUUID = accountProjection.getPrimaryPostingUUID(); + var securityPostingUUID = portfolioProjection.getPrimaryPostingUUID(); + + accountProjection.setRole(LedgerProjectionRole.PORTFOLIO); + accountProjection.setAccount(null); + accountProjection.setPortfolio(portfolio); + accountProjection.setPrimaryPostingUUID(securityPostingUUID); + portfolioProjection.setRole(LedgerProjectionRole.ACCOUNT); + portfolioProjection.setPortfolio(null); + portfolioProjection.setAccount(account); + portfolioProjection.setPrimaryPostingUUID(cashPostingUUID); + } + + private LedgerPosting postingByUUID(LedgerEntry entry, String uuid) + { + return entry.getPostings().stream().filter(posting -> posting.getUUID().equals(uuid)).findFirst() + .orElseThrow(); + } + + private LedgerPosting unitPosting(LedgerEntry entry, LedgerPostingType type) + { + return entry.getPostings().stream().filter(posting -> posting.getType() == type).findFirst().orElseThrow(); + } + + private LedgerPosting unitPosting(LedgerPostingType type, int amount) + { + var posting = new LedgerPosting(); + + posting.setType(type); + posting.setAmount(Values.Amount.factorize(amount)); + posting.setCurrency(CurrencyUnit.EUR); + + return posting; + } + + private LedgerPosting grossValuePosting(int amount, String forexCurrency, int forexAmount, + BigDecimal exchangeRate) + { + var posting = unitPosting(LedgerPostingType.GROSS_VALUE, amount); + + posting.setForexAmount(Values.Amount.factorize(forexAmount)); + posting.setForexCurrency(forexCurrency); + posting.setExchangeRate(exchangeRate); + + return posting; + } + + @FunctionalInterface + private interface BuySellDuplicateMutator + { + void accept(LedgerEntry entry, Account account, Portfolio portfolio, Account otherAccount, + Portfolio otherPortfolio); + } + + @FunctionalInterface + private interface AccountTransferDuplicateMutator + { + void accept(LedgerEntry entry, Account source, Account target, Account other); + } + + @FunctionalInterface + private interface PortfolioTransferDuplicateMutator + { + void accept(LedgerEntry entry, Portfolio source, Portfolio target, Portfolio other); + } + + private LegacyTransactionToLedgerMigrator.MigrationResult migrate(Client client) + { + return new LegacyTransactionToLedgerMigrator().migrate(client); + } + + private void assertValid(Client client) + { + assertTrue(LedgerStructuralValidator.validate(client.getLedger()).isOK()); + } + + private Account register(Client client, Account account) + { + client.addAccount(account); + return account; + } + + private Portfolio register(Client client, Portfolio portfolio) + { + client.addPortfolio(portfolio); + return portfolio; + } + + private Account account() + { + var account = new Account(); + + account.setCurrencyCode(CurrencyUnit.EUR); + + return account; + } + + private Portfolio portfolio() + { + return new Portfolio(); + } + + private Security security() + { + return new Security("Security", CurrencyUnit.EUR); + } + + private AccountTransaction accountTransaction(AccountTransaction.Type type, int amount) + { + var transaction = new AccountTransaction(type); + + transaction.setDateTime(DATE_TIME); + transaction.setCurrencyCode(CurrencyUnit.EUR); + transaction.setAmount(Values.Amount.factorize(amount)); + transaction.setNote("note"); + transaction.setSource("source"); + + return transaction; + } + + private PortfolioTransaction portfolioTransaction(PortfolioTransaction.Type type, Security security, int amount) + { + var transaction = new PortfolioTransaction(type); + + transaction.setDateTime(DATE_TIME); + transaction.setCurrencyCode(CurrencyUnit.EUR); + transaction.setAmount(Values.Amount.factorize(amount)); + transaction.setSecurity(security); + transaction.setShares(Values.Share.factorize(5)); + transaction.setNote("note"); + transaction.setSource("source"); + + return transaction; + } + + private BuySellEntry buySellEntry(Portfolio portfolio, Account account, PortfolioTransaction.Type type) + { + var entry = new BuySellEntry(portfolio, account); + + entry.setType(type); + entry.setDate(DATE_TIME); + entry.setSecurity(security()); + entry.setShares(Values.Share.factorize(5)); + entry.setAmount(Values.Amount.factorize(100)); + entry.setCurrencyCode(CurrencyUnit.EUR); + entry.setNote("note"); + entry.setSource("source"); + + return entry; + } + + private AccountTransaction onlyAccountTransaction(Account account) + { + assertThat(account.getTransactions().size(), is(1)); + return account.getTransactions().get(0); + } + + private PortfolioTransaction onlyPortfolioTransaction(Portfolio portfolio) + { + assertThat(portfolio.getTransactions().size(), is(1)); + return portfolio.getTransactions().get(0); + } + + private LedgerEntry onlyLedgerEntry(Client client) + { + assertThat(client.getLedger().getEntries().size(), is(1)); + return client.getLedger().getEntries().get(0); + } + + private Money money(int amount) + { + return Money.of(CurrencyUnit.EUR, Values.Amount.factorize(amount)); + } + + private String migratedEntryUUID(LedgerEntryType type, String primaryProjectionUUID) + { + var key = "ledger-v6:migrated-entry:" + type + ":" + primaryProjectionUUID; + return UUID.nameUUIDFromBytes(key.getBytes(StandardCharsets.UTF_8)).toString(); + } + + private String migratedPostingUUID(String projectionUUID, LedgerPostingType type, String discriminator) + { + var key = "ledger-v6:migrated-posting:" + projectionUUID + ":" + type + ":" + discriminator; + return UUID.nameUUIDFromBytes(key.getBytes(StandardCharsets.UTF_8)).toString(); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/AccountTransferEntry.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/AccountTransferEntry.java index aa02f60709..1877ad7493 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/AccountTransferEntry.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/AccountTransferEntry.java @@ -9,6 +9,7 @@ public class AccountTransferEntry implements CrossEntry, Annotated private AccountTransaction transactionFrom; private Account accountTo; private AccountTransaction transactionTo; + private boolean readOnly; public AccountTransferEntry() { @@ -23,25 +24,45 @@ public AccountTransferEntry(Account accountFrom, Account accountTo) /* protobuf only */ AccountTransferEntry(Account accountFrom, AccountTransaction txFrom, Account accountTo, AccountTransaction txTo) { - this.transactionFrom = txFrom; - this.transactionFrom.setType(AccountTransaction.Type.TRANSFER_OUT); - this.transactionFrom.setCrossEntry(this); + this(accountFrom, txFrom, accountTo, txTo, false); + } + private AccountTransferEntry(Account accountFrom, AccountTransaction txFrom, Account accountTo, + AccountTransaction txTo, boolean readOnly) + { + this.transactionFrom = txFrom; this.transactionTo = txTo; - this.transactionTo.setType(AccountTransaction.Type.TRANSFER_IN); - this.transactionTo.setCrossEntry(this); - this.accountFrom = accountFrom; this.accountTo = accountTo; + this.readOnly = readOnly; + + if (!readOnly) + { + this.transactionFrom.setType(AccountTransaction.Type.TRANSFER_OUT); + this.transactionFrom.setCrossEntry(this); + + this.transactionTo.setType(AccountTransaction.Type.TRANSFER_IN); + this.transactionTo.setCrossEntry(this); + } + } + + public static AccountTransferEntry readOnly(Account accountFrom, AccountTransaction txFrom, Account accountTo, + AccountTransaction txTo) + { + return new AccountTransferEntry(accountFrom, txFrom, accountTo, txTo, true); } public void setSourceTransaction(AccountTransaction transaction) { + assertWritable(); + this.transactionFrom = transaction; } public void setTargetTransaction(AccountTransaction transaction) { + assertWritable(); + this.transactionTo = transaction; } @@ -57,6 +78,8 @@ public AccountTransaction getTargetTransaction() public void setSourceAccount(Account account) { + assertWritable(); + this.accountFrom = account; } @@ -67,6 +90,8 @@ public Account getSourceAccount() public void setTargetAccount(Account account) { + assertWritable(); + this.accountTo = account; } @@ -77,18 +102,24 @@ public Account getTargetAccount() public void setDate(LocalDateTime date) { + assertWritable(); + this.transactionFrom.setDateTime(date); this.transactionTo.setDateTime(date); } public void setAmount(long amount) { + assertWritable(); + this.transactionFrom.setAmount(amount); this.transactionTo.setAmount(amount); } public void setCurrencyCode(String currencyCode) { + assertWritable(); + this.transactionFrom.setCurrencyCode(currencyCode); this.transactionTo.setCurrencyCode(currencyCode); } @@ -102,6 +133,8 @@ public String getNote() @Override public void setNote(String note) { + assertWritable(); + this.transactionFrom.setNote(note); this.transactionTo.setNote(note); } @@ -115,6 +148,8 @@ public String getSource() @Override public void setSource(String source) { + assertWritable(); + this.transactionFrom.setSource(source); this.transactionTo.setSource(source); } @@ -122,6 +157,8 @@ public void setSource(String source) @Override public void insert() { + assertWritable(); + // perform both currency checks *before* adding the transactions to // avoid partially added transfer @@ -151,9 +188,19 @@ public void insert() public void updateFrom(Transaction t) { if (t == transactionFrom) + { + if (readOnly) + return; + copyAttributesOver(transactionFrom, transactionTo); + } else if (t == transactionTo) + { + if (readOnly) + return; + copyAttributesOver(transactionTo, transactionFrom); + } else throw new UnsupportedOperationException(); } @@ -178,6 +225,8 @@ else if (t.equals(transactionTo)) @Override public void setOwner(Transaction t, TransactionOwner owner) { + assertWritable(); + if (!(owner instanceof Account)) throw new IllegalArgumentException("owner isn't an account for transaction " + t); //$NON-NLS-1$ @@ -210,4 +259,10 @@ else if (t.equals(transactionTo)) else throw new UnsupportedOperationException(); } + + private void assertWritable() + { + if (readOnly) + throw new UnsupportedOperationException("Ledger-backed account transfer cross entries are read-only"); //$NON-NLS-1$ + } } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/BuySellEntry.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/BuySellEntry.java index 23a02f6500..74fe660893 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/BuySellEntry.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/BuySellEntry.java @@ -11,6 +11,7 @@ public class BuySellEntry implements CrossEntry, Annotated private PortfolioTransaction portfolioTransaction; private Account account; private AccountTransaction accountTransaction; + private boolean readOnly; public BuySellEntry() { @@ -30,18 +31,36 @@ public BuySellEntry(Portfolio portfolio, Account account) /* protobuf only */ BuySellEntry(Portfolio portfolio, PortfolioTransaction portfolioTx, Account account, AccountTransaction accountTx) + { + this(portfolio, portfolioTx, account, accountTx, false); + } + + private BuySellEntry(Portfolio portfolio, PortfolioTransaction portfolioTx, Account account, + AccountTransaction accountTx, boolean readOnly) { this.portfolio = portfolio; this.portfolioTransaction = portfolioTx; - this.portfolioTransaction.setCrossEntry(this); - this.account = account; this.accountTransaction = accountTx; - this.accountTransaction.setCrossEntry(this); + this.readOnly = readOnly; + + if (!readOnly) + { + this.portfolioTransaction.setCrossEntry(this); + this.accountTransaction.setCrossEntry(this); + } + } + + public static BuySellEntry readOnly(Portfolio portfolio, PortfolioTransaction portfolioTx, Account account, + AccountTransaction accountTx) + { + return new BuySellEntry(portfolio, portfolioTx, account, accountTx, true); } public void setPortfolio(Portfolio portfolio) { + assertWritable(); + this.portfolio = portfolio; } @@ -52,6 +71,8 @@ public Portfolio getPortfolio() public void setAccount(Account account) { + assertWritable(); + this.account = account; } @@ -62,41 +83,55 @@ public Account getAccount() public void setDate(LocalDateTime date) { + assertWritable(); + this.portfolioTransaction.setDateTime(date); this.accountTransaction.setDateTime(date); } public void setType(Type type) { + assertWritable(); + this.portfolioTransaction.setType(type); this.accountTransaction.setType(AccountTransaction.Type.valueOf(type.name())); } public void setSecurity(Security security) { + assertWritable(); + this.portfolioTransaction.setSecurity(security); this.accountTransaction.setSecurity(security); } public void setShares(long shares) { + assertWritable(); + this.portfolioTransaction.setShares(shares); } public void setAmount(long amount) { + assertWritable(); + this.portfolioTransaction.setAmount(amount); this.accountTransaction.setAmount(amount); } public void setCurrencyCode(String currencyCode) { + assertWritable(); + this.portfolioTransaction.setCurrencyCode(currencyCode); this.accountTransaction.setCurrencyCode(currencyCode); } public void setMonetaryAmount(Money amount) { + assertWritable(); + this.portfolioTransaction.setMonetaryAmount(amount); this.accountTransaction.setMonetaryAmount(amount); } @@ -110,6 +145,8 @@ public String getNote() @Override public void setNote(String note) { + assertWritable(); + this.portfolioTransaction.setNote(note); this.accountTransaction.setNote(note); } @@ -123,6 +160,8 @@ public String getSource() @Override public void setSource(String source) { + assertWritable(); + this.portfolioTransaction.setSource(source); this.accountTransaction.setSource(source); } @@ -130,6 +169,8 @@ public void setSource(String source) @Override public void insert() { + assertWritable(); + // add first the account transaction which might fail due to // a currency mismatch account.addTransaction(accountTransaction); @@ -141,12 +182,18 @@ public void updateFrom(Transaction t) { if (t == accountTransaction) { + if (readOnly) + return; + portfolioTransaction.setDateTime(accountTransaction.getDateTime()); portfolioTransaction.setSecurity(accountTransaction.getSecurity()); portfolioTransaction.setNote(accountTransaction.getNote()); } else if (t == portfolioTransaction) { + if (readOnly) + return; + accountTransaction.setDateTime(portfolioTransaction.getDateTime()); accountTransaction.setSecurity(portfolioTransaction.getSecurity()); accountTransaction.setNote(portfolioTransaction.getNote()); @@ -172,6 +219,8 @@ else if (t.equals(accountTransaction)) @Override public void setOwner(Transaction t, TransactionOwner owner) { + assertWritable(); + if (t.equals(portfolioTransaction) && owner instanceof Portfolio p) portfolio = p; else if (t.equals(accountTransaction) && owner instanceof Account a) @@ -211,4 +260,10 @@ public AccountTransaction getAccountTransaction() { return accountTransaction; } + + private void assertWritable() + { + if (readOnly) + throw new UnsupportedOperationException("Ledger-backed buy/sell cross entries are read-only"); //$NON-NLS-1$ + } } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/Client.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/Client.java index 908876eb7b..c8e5fa8e16 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/Client.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/Client.java @@ -21,6 +21,7 @@ import name.abuchen.portfolio.Messages; import name.abuchen.portfolio.model.Classification.Assignment; +import name.abuchen.portfolio.model.ledger.Ledger; import name.abuchen.portfolio.money.CurrencyUnit; public class Client @@ -68,6 +69,8 @@ public interface Properties // NOSONAR private Map properties; private ClientSettings settings; + private Ledger ledger = new Ledger(); + /** * Extension data for third-party extensions using protobuf Any type. This * preserves unknown extension data during load/save operations. @@ -117,6 +120,9 @@ public Client() else settings.doPostLoadInitialization(); + if (ledger == null) + ledger = new Ledger(); + // Add this missing initialization: if (extensions == null) extensions = new ArrayList<>(); @@ -193,6 +199,11 @@ public void addExtension(Any extension) this.extensions.add(extension); } + public Ledger getLedger() + { + return ledger; + } + public List getPlans() { return Collections.unmodifiableList(plans); diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/PortfolioTransferEntry.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/PortfolioTransferEntry.java index 1d475ade4a..83b658d4f8 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/PortfolioTransferEntry.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/PortfolioTransferEntry.java @@ -8,6 +8,7 @@ public class PortfolioTransferEntry implements CrossEntry, Annotated private PortfolioTransaction transactionFrom; private Portfolio portfolioTo; private PortfolioTransaction transactionTo; + private boolean readOnly; public PortfolioTransferEntry() { @@ -22,25 +23,45 @@ public PortfolioTransferEntry(Portfolio portfolioFrom, Portfolio portfolioTo) /* protobuf only */ PortfolioTransferEntry(Portfolio portfolioFrom, PortfolioTransaction txFrom, Portfolio portfolioTo, PortfolioTransaction txTo) { - this.transactionFrom = txFrom; - this.transactionFrom.setType(PortfolioTransaction.Type.TRANSFER_OUT); - this.transactionFrom.setCrossEntry(this); + this(portfolioFrom, txFrom, portfolioTo, txTo, false); + } + private PortfolioTransferEntry(Portfolio portfolioFrom, PortfolioTransaction txFrom, + Portfolio portfolioTo, PortfolioTransaction txTo, boolean readOnly) + { + this.transactionFrom = txFrom; this.transactionTo = txTo; - this.transactionTo.setType(PortfolioTransaction.Type.TRANSFER_IN); - this.transactionTo.setCrossEntry(this); - this.portfolioFrom = portfolioFrom; this.portfolioTo = portfolioTo; + this.readOnly = readOnly; + + if (!readOnly) + { + this.transactionFrom.setType(PortfolioTransaction.Type.TRANSFER_OUT); + this.transactionFrom.setCrossEntry(this); + + this.transactionTo.setType(PortfolioTransaction.Type.TRANSFER_IN); + this.transactionTo.setCrossEntry(this); + } + } + + public static PortfolioTransferEntry readOnly(Portfolio portfolioFrom, PortfolioTransaction txFrom, + Portfolio portfolioTo, PortfolioTransaction txTo) + { + return new PortfolioTransferEntry(portfolioFrom, txFrom, portfolioTo, txTo, true); } public void setSourceTransaction(PortfolioTransaction transaction) { + assertWritable(); + this.transactionFrom = transaction; } public void setTargetTransaction(PortfolioTransaction transaction) { + assertWritable(); + this.transactionTo = transaction; } @@ -56,11 +77,15 @@ public PortfolioTransaction getTargetTransaction() public void setSourcePortfolio(Portfolio portfolio) { + assertWritable(); + this.portfolioFrom = portfolio; } public void setTargetPortfolio(Portfolio portfolio) { + assertWritable(); + this.portfolioTo = portfolio; } @@ -76,30 +101,40 @@ public Portfolio getTargetPortfolio() public void setDate(LocalDateTime date) { + assertWritable(); + this.transactionFrom.setDateTime(date); this.transactionTo.setDateTime(date); } public void setSecurity(Security security) { + assertWritable(); + this.transactionFrom.setSecurity(security); this.transactionTo.setSecurity(security); } public void setShares(long shares) { + assertWritable(); + this.transactionFrom.setShares(shares); this.transactionTo.setShares(shares); } public void setAmount(long amount) { + assertWritable(); + this.transactionFrom.setAmount(amount); this.transactionTo.setAmount(amount); } public void setCurrencyCode(String currencyCode) { + assertWritable(); + this.transactionFrom.setCurrencyCode(currencyCode); this.transactionTo.setCurrencyCode(currencyCode); } @@ -113,6 +148,8 @@ public String getNote() @Override public void setNote(String note) { + assertWritable(); + this.transactionFrom.setNote(note); this.transactionTo.setNote(note); } @@ -126,6 +163,8 @@ public String getSource() @Override public void setSource(String source) { + assertWritable(); + this.transactionFrom.setSource(source); this.transactionTo.setSource(source); } @@ -133,6 +172,8 @@ public void setSource(String source) @Override public void insert() { + assertWritable(); + portfolioFrom.addTransaction(transactionFrom); portfolioTo.addTransaction(transactionTo); } @@ -141,9 +182,19 @@ public void insert() public void updateFrom(Transaction t) { if (t.equals(transactionFrom)) + { + if (readOnly) + return; + copyAttributesOver(transactionFrom, transactionTo); + } else if (t.equals(transactionTo)) + { + if (readOnly) + return; + copyAttributesOver(transactionTo, transactionFrom); + } else throw new UnsupportedOperationException("unable to update from transaction " + t); //$NON-NLS-1$ } @@ -170,6 +221,8 @@ else if (t.equals(transactionTo)) @Override public void setOwner(Transaction t, TransactionOwner owner) { + assertWritable(); + if (!(owner instanceof Portfolio)) throw new IllegalArgumentException( "invalid owner type for owner " + owner + " when trying to set it to transaction " + t); //$NON-NLS-1$ //$NON-NLS-2$ @@ -203,4 +256,10 @@ else if (t.equals(transactionTo)) else throw new UnsupportedOperationException("unable to get cross owner for transaction " + t); //$NON-NLS-1$ } + + private void assertWritable() + { + if (readOnly) + throw new UnsupportedOperationException("Ledger-backed portfolio transfer cross entries are read-only"); //$NON-NLS-1$ + } } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/Transaction.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/Transaction.java index ce2ed1a55d..27fd114820 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/Transaction.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/Transaction.java @@ -362,7 +362,7 @@ public CrossEntry getCrossEntry() return crossEntry; } - /* package */void setCrossEntry(CrossEntry crossEntry) + protected void setCrossEntry(CrossEntry crossEntry) { this.crossEntry = crossEntry; this.updatedAt = Instant.now(); diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/TransactionOwner.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/TransactionOwner.java index b3ca3654d5..c54c3c6d1e 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/TransactionOwner.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/TransactionOwner.java @@ -2,6 +2,9 @@ import java.util.List; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerTransactionDeleter; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; + /** * A transaction owner has transactions. * @@ -29,6 +32,12 @@ public interface TransactionOwner */ default void deleteTransaction(T transaction, Client client) { + if (transaction instanceof LedgerBackedTransaction ledgerBackedTransaction) + { + new LedgerTransactionDeleter(client).delete(ledgerBackedTransaction); + return; + } + if (transaction.getCrossEntry() != null) { Transaction other = transaction.getCrossEntry().getCrossTransaction(transaction); diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/TransactionPair.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/TransactionPair.java index 59bbbac934..bfa722cb48 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/TransactionPair.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/TransactionPair.java @@ -5,6 +5,8 @@ import java.util.Objects; import java.util.Optional; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerTransactionDeleter; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; import name.abuchen.portfolio.money.Values; /** @@ -47,6 +49,14 @@ public T getTransaction() return transaction; } + public Optional getLedgerEntryUUID() + { + if (transaction instanceof LedgerBackedTransaction ledgerBackedTransaction) + return Optional.of(ledgerBackedTransaction.getLedgerEntry().getUUID()); + + return Optional.empty(); + } + /** * Returns this if it wraps an AccountTransaction. */ @@ -87,6 +97,12 @@ public boolean isPortfolioTransaction() */ public void deleteTransaction(Client client) { + if (transaction instanceof LedgerBackedTransaction ledgerBackedTransaction) + { + new LedgerTransactionDeleter(client).delete(ledgerBackedTransaction); + return; + } + owner.deleteTransaction(transaction, client); } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigrator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigrator.java new file mode 100644 index 0000000000..ab1c0b3c10 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigrator.java @@ -0,0 +1,1129 @@ +package name.abuchen.portfolio.model.ledger.legacy; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.AccountTransferEntry; +import name.abuchen.portfolio.model.BuySellEntry; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.CrossEntry; +import name.abuchen.portfolio.model.InvestmentPlan; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.PortfolioTransaction; +import name.abuchen.portfolio.model.PortfolioTransferEntry; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.Transaction; +import name.abuchen.portfolio.model.Transaction.Unit; +import name.abuchen.portfolio.model.ledger.Ledger; +import name.abuchen.portfolio.model.ledger.LedgerDiagnosticMessageFormatter; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerParameter; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; +import name.abuchen.portfolio.model.ledger.ProjectionMembershipRole; +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.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionService; + +/** + * Migrates legacy transaction structures into persisted Ledger entries. + * This is compatibility loading infrastructure. It is not a general transaction editing API + * and should not reconstruct business facts by guessing. + */ +public final class LegacyTransactionToLedgerMigrator +{ + public MigrationResult migrate(Client client) + { + Objects.requireNonNull(client); + + var plan = new MigrationPlan(client); + var processedCrossEntries = new HashSet(); + + migrateAccounts(client, plan, processedCrossEntries); + migratePortfolios(client, plan, processedCrossEntries); + + if (validatePlan(client, plan) && plan.hasChanges()) + { + applyPlan(client, plan); + plan.markApplied(); + } + + return new MigrationResult(plan.getMigratedTransactionCount(), plan.getDiagnostics()); + } + + private void migrateAccounts(Client client, MigrationPlan plan, Set processedCrossEntries) + { + for (var account : client.getAccounts()) + { + for (var transaction : List.copyOf(account.getTransactions())) + { + if (transaction instanceof LedgerBackedTransaction) + continue; + + if (transaction.getCrossEntry() != null) + { + migrateAccountCrossEntry(transaction, plan, processedCrossEntries); + } + else + { + migrateAccountOnly(account, transaction, plan); + } + } + } + } + + private void migratePortfolios(Client client, MigrationPlan plan, Set processedCrossEntries) + { + for (var portfolio : client.getPortfolios()) + { + for (var transaction : List.copyOf(portfolio.getTransactions())) + { + if (transaction instanceof LedgerBackedTransaction) + continue; + + if (transaction.getCrossEntry() != null) + { + migratePortfolioCrossEntry(transaction, plan, processedCrossEntries); + } + else + { + migrateDelivery(portfolio, transaction, plan); + } + } + } + } + + private void migrateAccountOnly(Account account, AccountTransaction transaction, MigrationPlan plan) + { + var entryType = accountEntryType(transaction.getType()); + + if (entryType == null) + { + plan.addDiagnostic("ACCOUNT", "UNSUPPORTED_TYPE", "owner=account type=" + transaction.getType(), //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ + transaction); + return; + } + + var postingType = switch (entryType) + { + case FEES, FEES_REFUND -> LedgerPostingType.FEE; + case TAXES, TAX_REFUND -> LedgerPostingType.TAX; + default -> LedgerPostingType.CASH; + }; + var entry = MigrationGraphBuilder.entry(transaction, entryType); + var posting = MigrationGraphBuilder.cashPosting(postingType, account, transaction); + + if (transaction.getType() == AccountTransaction.Type.DIVIDENDS && transaction.getExDate() != null) + MigrationGraphBuilder.addParameter(posting, + LedgerParameter.ofLocalDateTime(LedgerParameterType.EX_DATE, + transaction.getExDate())); + + MigrationGraphBuilder.addPosting(entry, posting); + var unitPostings = MigrationGraphBuilder.addUnitPostings(entry, transaction); + var projection = MigrationGraphBuilder.accountProjection(transaction.getUUID(), LedgerProjectionRole.ACCOUNT, + account, posting.getUUID()); + MigrationGraphBuilder.addUnitMemberships(projection, unitPostings); + MigrationGraphBuilder.addProjectionRef(entry, projection); + + if (plan.handleAlreadyMigratedCompleteGroup("ACCOUNT", entry, transaction)) //$NON-NLS-1$ + return; + + plan.addEntry(entry, transaction); + } + + private LedgerEntryType accountEntryType(AccountTransaction.Type type) + { + return switch (type) + { + case DEPOSIT -> LedgerEntryType.DEPOSIT; + case REMOVAL -> LedgerEntryType.REMOVAL; + case INTEREST -> LedgerEntryType.INTEREST; + case INTEREST_CHARGE -> LedgerEntryType.INTEREST_CHARGE; + case FEES -> LedgerEntryType.FEES; + case FEES_REFUND -> LedgerEntryType.FEES_REFUND; + case TAXES -> LedgerEntryType.TAXES; + case TAX_REFUND -> LedgerEntryType.TAX_REFUND; + case DIVIDENDS -> LedgerEntryType.DIVIDENDS; + default -> null; + }; + } + + private void migrateAccountCrossEntry(AccountTransaction transaction, MigrationPlan plan, + Set processedCrossEntries) + { + var crossEntry = transaction.getCrossEntry(); + + if (!processedCrossEntries.add(crossEntry)) + return; + + if (crossEntry instanceof BuySellEntry buySellEntry) + migrateBuySell(buySellEntry, plan); + else if (crossEntry instanceof AccountTransferEntry transferEntry) + migrateAccountTransfer(transferEntry, plan); + else + plan.addDiagnostic("ACCOUNT", "UNSUPPORTED_CROSS_ENTRY", //$NON-NLS-1$ //$NON-NLS-2$ + "owner=account crossEntry=" + crossEntry.getClass().getName(), transaction); //$NON-NLS-1$ + } + + private void migratePortfolioCrossEntry(PortfolioTransaction transaction, MigrationPlan plan, + Set processedCrossEntries) + { + var crossEntry = transaction.getCrossEntry(); + + if (!processedCrossEntries.add(crossEntry)) + return; + + if (crossEntry instanceof BuySellEntry buySellEntry) + migrateBuySell(buySellEntry, plan); + else if (crossEntry instanceof PortfolioTransferEntry transferEntry) + migratePortfolioTransfer(transferEntry, plan); + else + plan.addDiagnostic("PORTFOLIO", "UNSUPPORTED_CROSS_ENTRY", //$NON-NLS-1$ //$NON-NLS-2$ + "owner=portfolio crossEntry=" + crossEntry.getClass().getName(), transaction); //$NON-NLS-1$ + } + + private void migrateBuySell(BuySellEntry buySellEntry, MigrationPlan plan) + { + var accountTransaction = buySellEntry.getAccountTransaction(); + var portfolioTransaction = buySellEntry.getPortfolioTransaction(); + var account = buySellEntry.getAccount(); + var portfolio = buySellEntry.getPortfolio(); + + if (!isValidBuySellGroup(buySellEntry, account, accountTransaction, portfolio, portfolioTransaction, plan)) + return; + + var entryType = portfolioTransaction.getType() == PortfolioTransaction.Type.BUY ? LedgerEntryType.BUY + : LedgerEntryType.SELL; + var entry = MigrationGraphBuilder.entry(portfolioTransaction, entryType); + var cashPosting = MigrationGraphBuilder.cashPosting(LedgerPostingType.CASH, account, accountTransaction); + var securityPosting = MigrationGraphBuilder.securityPosting(portfolio, portfolioTransaction); + + MigrationGraphBuilder.addPosting(entry, cashPosting); + MigrationGraphBuilder.addPosting(entry, securityPosting); + var unitPostings = MigrationGraphBuilder.addUnitPostings(entry, portfolioTransaction); + var accountProjection = MigrationGraphBuilder.accountProjection(accountTransaction.getUUID(), + LedgerProjectionRole.ACCOUNT, account, cashPosting.getUUID()); + var portfolioProjection = MigrationGraphBuilder.portfolioProjection(portfolioTransaction.getUUID(), + LedgerProjectionRole.PORTFOLIO, portfolio, securityPosting.getUUID()); + MigrationGraphBuilder.addUnitMemberships(accountProjection, unitPostings); + MigrationGraphBuilder.addUnitMemberships(portfolioProjection, unitPostings); + MigrationGraphBuilder.addProjectionRef(entry, accountProjection); + MigrationGraphBuilder.addProjectionRef(entry, portfolioProjection); + + if (plan.handleAlreadyMigratedCompleteGroup("BUY_SELL", entry, accountTransaction, portfolioTransaction)) //$NON-NLS-1$ + return; + + plan.addEntry(entry, accountTransaction, portfolioTransaction); + } + + private boolean isValidBuySellGroup(BuySellEntry entry, Account account, AccountTransaction accountTransaction, + Portfolio portfolio, PortfolioTransaction portfolioTransaction, MigrationPlan plan) + { + if (account == null || portfolio == null || accountTransaction == null || portfolioTransaction == null) + return malformed(plan, LedgerDiagnosticCode.LEDGER_IMPORT_001, "BUY_SELL", "MALFORMED_CROSS_ENTRY", //$NON-NLS-1$ //$NON-NLS-2$ + "incomplete", accountTransaction, portfolioTransaction); //$NON-NLS-1$ + + if (accountTransaction.getCrossEntry() != entry || portfolioTransaction.getCrossEntry() != entry) + return malformed(plan, LedgerDiagnosticCode.LEDGER_IMPORT_002, "BUY_SELL", "MALFORMED_CROSS_ENTRY", //$NON-NLS-1$ //$NON-NLS-2$ + "backReference", accountTransaction, portfolioTransaction); //$NON-NLS-1$ + + if (!account.getTransactions().contains(accountTransaction) + || !portfolio.getTransactions().contains(portfolioTransaction)) + return malformed(plan, LedgerDiagnosticCode.LEDGER_IMPORT_003, "BUY_SELL", "MALFORMED_CROSS_ENTRY", //$NON-NLS-1$ //$NON-NLS-2$ + "ownerMembership", accountTransaction, portfolioTransaction); //$NON-NLS-1$ + + if (!isBuySellAccountType(accountTransaction.getType()) || !isBuySellPortfolioType(portfolioTransaction.getType()) + || !sameBuySellDirection(accountTransaction, portfolioTransaction)) + return malformed(plan, LedgerDiagnosticCode.LEDGER_IMPORT_004, "BUY_SELL", "TYPE_MISMATCH", //$NON-NLS-1$ //$NON-NLS-2$ + "accountType=" + accountTransaction.getType() + " portfolioType=" //$NON-NLS-1$ //$NON-NLS-2$ + + portfolioTransaction.getType(), + accountTransaction, portfolioTransaction); + + if (!distinctUUIDs("BUY_SELL", accountTransaction, portfolioTransaction, plan)) //$NON-NLS-1$ + return false; + + if (!sameMetadata("BUY_SELL", accountTransaction, portfolioTransaction, plan)) //$NON-NLS-1$ + return false; + + if (hasUnits(accountTransaction)) + return malformed(plan, LedgerDiagnosticCode.LEDGER_IMPORT_005, "BUY_SELL", "UNSUPPORTED_UNITS", //$NON-NLS-1$ //$NON-NLS-2$ + "accountSideUnits", accountTransaction, portfolioTransaction); //$NON-NLS-1$ + + return true; + } + + private boolean isBuySellAccountType(AccountTransaction.Type type) + { + return type == AccountTransaction.Type.BUY || type == AccountTransaction.Type.SELL; + } + + private boolean isBuySellPortfolioType(PortfolioTransaction.Type type) + { + return type == PortfolioTransaction.Type.BUY || type == PortfolioTransaction.Type.SELL; + } + + private boolean sameBuySellDirection(AccountTransaction accountTransaction, + PortfolioTransaction portfolioTransaction) + { + return accountTransaction.getType() == AccountTransaction.Type.BUY + && portfolioTransaction.getType() == PortfolioTransaction.Type.BUY + || accountTransaction.getType() == AccountTransaction.Type.SELL + && portfolioTransaction.getType() == PortfolioTransaction.Type.SELL; + } + + private void migrateAccountTransfer(AccountTransferEntry transferEntry, MigrationPlan plan) + { + var sourceTransaction = transferEntry.getSourceTransaction(); + var targetTransaction = transferEntry.getTargetTransaction(); + var sourceAccount = transferEntry.getSourceAccount(); + var targetAccount = transferEntry.getTargetAccount(); + + if (!isValidAccountTransferGroup(transferEntry, sourceAccount, sourceTransaction, targetAccount, + targetTransaction, plan)) + return; + + var entry = MigrationGraphBuilder.entry(sourceTransaction, LedgerEntryType.CASH_TRANSFER); + var sourcePosting = MigrationGraphBuilder.cashPosting(LedgerPostingType.CASH, sourceAccount, + sourceTransaction); + var targetPosting = MigrationGraphBuilder.cashPosting(LedgerPostingType.CASH, targetAccount, + targetTransaction); + + MigrationGraphBuilder.addPosting(entry, sourcePosting); + MigrationGraphBuilder.addPosting(entry, targetPosting); + MigrationGraphBuilder.addProjectionRef(entry, MigrationGraphBuilder.accountProjection( + sourceTransaction.getUUID(), LedgerProjectionRole.SOURCE_ACCOUNT, sourceAccount, + sourcePosting.getUUID())); + MigrationGraphBuilder.addProjectionRef(entry, MigrationGraphBuilder.accountProjection( + targetTransaction.getUUID(), LedgerProjectionRole.TARGET_ACCOUNT, targetAccount, + targetPosting.getUUID())); + + if (plan.handleAlreadyMigratedCompleteGroup("ACCOUNT_TRANSFER", entry, sourceTransaction, targetTransaction)) //$NON-NLS-1$ + return; + + plan.addEntry(entry, sourceTransaction, targetTransaction); + } + + private boolean isValidAccountTransferGroup(AccountTransferEntry entry, Account sourceAccount, + AccountTransaction sourceTransaction, Account targetAccount, AccountTransaction targetTransaction, + MigrationPlan plan) + { + if (sourceAccount == null || targetAccount == null || sourceTransaction == null || targetTransaction == null) + return malformed(plan, LedgerDiagnosticCode.LEDGER_IMPORT_006, "ACCOUNT_TRANSFER", //$NON-NLS-1$ + "MALFORMED_CROSS_ENTRY", "incomplete", sourceTransaction, targetTransaction); //$NON-NLS-1$ //$NON-NLS-2$ + + if (sourceTransaction.getCrossEntry() != entry || targetTransaction.getCrossEntry() != entry) + return malformed(plan, LedgerDiagnosticCode.LEDGER_IMPORT_007, "ACCOUNT_TRANSFER", //$NON-NLS-1$ + "MALFORMED_CROSS_ENTRY", "backReference", sourceTransaction, targetTransaction); //$NON-NLS-1$ //$NON-NLS-2$ + + if (!sourceAccount.getTransactions().contains(sourceTransaction) + || !targetAccount.getTransactions().contains(targetTransaction)) + return malformed(plan, LedgerDiagnosticCode.LEDGER_IMPORT_008, "ACCOUNT_TRANSFER", //$NON-NLS-1$ + "MALFORMED_CROSS_ENTRY", "ownerMembership", sourceTransaction, targetTransaction); //$NON-NLS-1$ //$NON-NLS-2$ + + if (sourceTransaction.getType() != AccountTransaction.Type.TRANSFER_OUT + || targetTransaction.getType() != AccountTransaction.Type.TRANSFER_IN) + return malformed(plan, LedgerDiagnosticCode.LEDGER_IMPORT_009, "ACCOUNT_TRANSFER", "TYPE_MISMATCH", //$NON-NLS-1$ //$NON-NLS-2$ + "direction", sourceTransaction, targetTransaction); //$NON-NLS-1$ + + if (!distinctUUIDs("ACCOUNT_TRANSFER", sourceTransaction, targetTransaction, plan)) //$NON-NLS-1$ + return false; + + if (!sameMetadata("ACCOUNT_TRANSFER", sourceTransaction, targetTransaction, plan)) //$NON-NLS-1$ + return false; + + if (hasUnits(sourceTransaction) || hasUnits(targetTransaction)) + return malformed(plan, LedgerDiagnosticCode.LEDGER_IMPORT_010, "ACCOUNT_TRANSFER", //$NON-NLS-1$ + "UNSUPPORTED_UNITS", "transferUnits", sourceTransaction, targetTransaction); //$NON-NLS-1$ //$NON-NLS-2$ + + return true; + } + + private void migratePortfolioTransfer(PortfolioTransferEntry transferEntry, MigrationPlan plan) + { + var sourceTransaction = transferEntry.getSourceTransaction(); + var targetTransaction = transferEntry.getTargetTransaction(); + var sourcePortfolio = transferEntry.getSourcePortfolio(); + var targetPortfolio = transferEntry.getTargetPortfolio(); + + if (!isValidPortfolioTransferGroup(transferEntry, sourcePortfolio, sourceTransaction, targetPortfolio, + targetTransaction, plan)) + return; + + var entry = MigrationGraphBuilder.entry(sourceTransaction, LedgerEntryType.SECURITY_TRANSFER); + var sourcePosting = MigrationGraphBuilder.securityPosting(sourcePortfolio, sourceTransaction); + var targetPosting = MigrationGraphBuilder.securityPosting(targetPortfolio, targetTransaction); + + MigrationGraphBuilder.addPosting(entry, sourcePosting); + MigrationGraphBuilder.addPosting(entry, targetPosting); + MigrationGraphBuilder.addProjectionRef(entry, MigrationGraphBuilder.portfolioProjection( + sourceTransaction.getUUID(), LedgerProjectionRole.SOURCE_PORTFOLIO, sourcePortfolio, + sourcePosting.getUUID())); + MigrationGraphBuilder.addProjectionRef(entry, MigrationGraphBuilder.portfolioProjection( + targetTransaction.getUUID(), LedgerProjectionRole.TARGET_PORTFOLIO, targetPortfolio, + targetPosting.getUUID())); + + if (plan.handleAlreadyMigratedCompleteGroup("PORTFOLIO_TRANSFER", entry, sourceTransaction, targetTransaction)) //$NON-NLS-1$ + return; + + plan.addEntry(entry, sourceTransaction, targetTransaction); + } + + private boolean isValidPortfolioTransferGroup(PortfolioTransferEntry entry, Portfolio sourcePortfolio, + PortfolioTransaction sourceTransaction, Portfolio targetPortfolio, + PortfolioTransaction targetTransaction, MigrationPlan plan) + { + if (sourcePortfolio == null || targetPortfolio == null || sourceTransaction == null || targetTransaction == null) + return malformed(plan, LedgerDiagnosticCode.LEDGER_IMPORT_011, "PORTFOLIO_TRANSFER", //$NON-NLS-1$ + "MALFORMED_CROSS_ENTRY", "incomplete", sourceTransaction, targetTransaction); //$NON-NLS-1$ //$NON-NLS-2$ + + if (sourceTransaction.getCrossEntry() != entry || targetTransaction.getCrossEntry() != entry) + return malformed(plan, LedgerDiagnosticCode.LEDGER_IMPORT_012, "PORTFOLIO_TRANSFER", //$NON-NLS-1$ + "MALFORMED_CROSS_ENTRY", "backReference", sourceTransaction, targetTransaction); //$NON-NLS-1$ //$NON-NLS-2$ + + if (!sourcePortfolio.getTransactions().contains(sourceTransaction) + || !targetPortfolio.getTransactions().contains(targetTransaction)) + return malformed(plan, LedgerDiagnosticCode.LEDGER_IMPORT_013, "PORTFOLIO_TRANSFER", //$NON-NLS-1$ + "MALFORMED_CROSS_ENTRY", "ownerMembership", sourceTransaction, targetTransaction); //$NON-NLS-1$ //$NON-NLS-2$ + + if (sourceTransaction.getType() != PortfolioTransaction.Type.TRANSFER_OUT + || targetTransaction.getType() != PortfolioTransaction.Type.TRANSFER_IN) + return malformed(plan, LedgerDiagnosticCode.LEDGER_IMPORT_014, "PORTFOLIO_TRANSFER", //$NON-NLS-1$ + "TYPE_MISMATCH", "direction", sourceTransaction, targetTransaction); //$NON-NLS-1$ //$NON-NLS-2$ + + if (!distinctUUIDs("PORTFOLIO_TRANSFER", sourceTransaction, targetTransaction, plan)) //$NON-NLS-1$ + return false; + + if (!sameMetadata("PORTFOLIO_TRANSFER", sourceTransaction, targetTransaction, plan)) //$NON-NLS-1$ + return false; + + if (hasUnits(sourceTransaction) || hasUnits(targetTransaction)) + return malformed(plan, LedgerDiagnosticCode.LEDGER_IMPORT_015, "PORTFOLIO_TRANSFER", //$NON-NLS-1$ + "UNSUPPORTED_UNITS", "transferUnits", sourceTransaction, targetTransaction); //$NON-NLS-1$ //$NON-NLS-2$ + + return true; + } + + private boolean distinctUUIDs(String family, Transaction first, Transaction second, MigrationPlan plan) + { + if (first.getUUID() == null || second.getUUID() == null) + return malformed(plan, LedgerDiagnosticCode.LEDGER_IMPORT_016, family, "MALFORMED_CROSS_ENTRY", //$NON-NLS-1$ + "missingUUID", first, second); //$NON-NLS-1$ + + if (first.getUUID().equals(second.getUUID())) + return malformed(plan, LedgerDiagnosticCode.LEDGER_IMPORT_017, family, "MALFORMED_CROSS_ENTRY", //$NON-NLS-1$ + "duplicateUUID", first, second); //$NON-NLS-1$ + + return true; + } + + private boolean sameMetadata(String family, Transaction first, Transaction second, MigrationPlan plan) + { + if (!Objects.equals(first.getDateTime(), second.getDateTime())) + return malformed(plan, LedgerDiagnosticCode.LEDGER_IMPORT_018, family, "METADATA_MISMATCH", //$NON-NLS-1$ + "field=dateTime", first, second); //$NON-NLS-1$ + + if (!Objects.equals(first.getNote(), second.getNote())) + return malformed(plan, LedgerDiagnosticCode.LEDGER_IMPORT_019, family, "METADATA_MISMATCH", //$NON-NLS-1$ + "field=note", first, second); //$NON-NLS-1$ + + if (!Objects.equals(first.getSource(), second.getSource())) + return malformed(plan, LedgerDiagnosticCode.LEDGER_IMPORT_020, family, "METADATA_MISMATCH", //$NON-NLS-1$ + "field=source", first, second); //$NON-NLS-1$ + + return true; + } + + private boolean malformed(MigrationPlan plan, LedgerDiagnosticCode code, String family, String reason, String context, + Transaction... transactions) + { + plan.addDiagnostic(code, family, reason, context, transactions); + return false; + } + + private void migrateDelivery(Portfolio portfolio, PortfolioTransaction transaction, MigrationPlan plan) + { + var entryType = switch (transaction.getType()) + { + case DELIVERY_INBOUND -> LedgerEntryType.DELIVERY_INBOUND; + case DELIVERY_OUTBOUND -> LedgerEntryType.DELIVERY_OUTBOUND; + default -> null; + }; + + if (entryType == null) + { + plan.addDiagnostic("DELIVERY", "UNSUPPORTED_TYPE", //$NON-NLS-1$ //$NON-NLS-2$ + "owner=portfolio type=" + transaction.getType(), transaction); //$NON-NLS-1$ + return; + } + + var role = entryType == LedgerEntryType.DELIVERY_INBOUND ? LedgerProjectionRole.DELIVERY_INBOUND + : LedgerProjectionRole.DELIVERY_OUTBOUND; + var entry = MigrationGraphBuilder.entry(transaction, entryType); + var posting = MigrationGraphBuilder.securityPosting(portfolio, transaction); + + MigrationGraphBuilder.addPosting(entry, posting); + var unitPostings = MigrationGraphBuilder.addUnitPostings(entry, transaction); + var projection = MigrationGraphBuilder.portfolioProjection(transaction.getUUID(), role, portfolio, + posting.getUUID()); + MigrationGraphBuilder.addUnitMemberships(projection, unitPostings); + MigrationGraphBuilder.addProjectionRef(entry, projection); + + if (plan.handleAlreadyMigratedCompleteGroup("DELIVERY", entry, transaction)) //$NON-NLS-1$ + return; + + plan.addEntry(entry, transaction); + } + + private boolean hasUnits(Transaction transaction) + { + return transaction.getUnits().findAny().isPresent(); + } + + private boolean validatePlan(Client client, MigrationPlan plan) + { + if (plan.getEntries().isEmpty()) + return true; + + var candidate = new Ledger(); + + client.getLedger().getEntries().forEach(entry -> MigrationGraphBuilder.addEntry(candidate, entry)); + plan.getEntries().forEach(entry -> MigrationGraphBuilder.addEntry(candidate, entry)); + + var result = LedgerStructuralValidator.validate(candidate); + + if (result.isOK()) + return true; + + plan.addDiagnostic("MIGRATION", "FAILED_VALIDATION", "issues=" + result.format()); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ + return false; + } + + private void applyPlan(Client client, MigrationPlan plan) + { + plan.getEntries().forEach(entry -> MigrationGraphBuilder.addEntry(client.getLedger(), entry)); + convertInvestmentPlanTransactionsToLedgerRefs(client, plan); + removeMigratedLegacyTransactions(client, plan.getProjectionUUIDsToRemove(), plan.getLegacyTransactionsToRemove()); + LedgerProjectionService.materialize(client); + } + + private void convertInvestmentPlanTransactionsToLedgerRefs(Client client, MigrationPlan plan) + { + for (var investmentPlan : client.getPlans()) + { + for (var index = investmentPlan.getTransactions().size() - 1; index >= 0; index--) + { + var transaction = investmentPlan.getTransactions().get(index); + + if (!shouldRemove(transaction, plan.getProjectionUUIDsToRemove(), plan.getLegacyTransactionsToRemove())) + continue; + + var ref = ledgerExecutionRef(client, transaction.getUUID()); + + if (ref == null) + continue; + + if (investmentPlan.getLedgerExecutionRefs().stream() + .noneMatch(existing -> sameExecutionRef(existing, ref))) + investmentPlan.addLedgerExecutionRef(ref); + + investmentPlan.getTransactions().remove(index); + } + } + } + + private InvestmentPlan.LedgerExecutionRef ledgerExecutionRef(Client client, String projectionUUID) + { + for (var entry : client.getLedger().getEntries()) + { + for (var projection : entry.getProjectionRefs()) + { + if (projectionUUID.equals(projection.getUUID())) + return new InvestmentPlan.LedgerExecutionRef(entry.getUUID(), projection.getUUID(), + projection.getRole()); + } + } + + return null; + } + + private boolean sameExecutionRef(InvestmentPlan.LedgerExecutionRef left, InvestmentPlan.LedgerExecutionRef right) + { + return Objects.equals(left.getLedgerEntryUUID(), right.getLedgerEntryUUID()) + && Objects.equals(left.getProjectionUUID(), right.getProjectionUUID()) + && left.getProjectionRole() == right.getProjectionRole(); + } + + private void removeMigratedLegacyTransactions(Client client, Set migratedProjectionUUIDs, + List migratedTransactions) + { + for (var account : client.getAccounts()) + account.getTransactions().removeIf(transaction -> shouldRemove(transaction, migratedProjectionUUIDs, + migratedTransactions)); + + for (var portfolio : client.getPortfolios()) + portfolio.getTransactions().removeIf(transaction -> shouldRemove(transaction, migratedProjectionUUIDs, + migratedTransactions)); + } + + private boolean shouldRemove(Transaction transaction, Set migratedProjectionUUIDs, + List migratedTransactions) + { + return !(transaction instanceof LedgerBackedTransaction) && (migratedTransactions.contains(transaction) + || migratedProjectionUUIDs.contains(transaction.getUUID())); + } + + private static final class MigrationGraphBuilder + { + private MigrationGraphBuilder() + { + } + + private static LedgerEntry entry(Transaction transaction, LedgerEntryType type) + { + var entry = new LedgerEntry(migratedEntryUUID(type, transaction.getUUID())); + + entry.setType(type); + entry.setDateTime(transaction.getDateTime()); + entry.setNote(transaction.getNote()); + entry.setSource(transaction.getSource()); + entry.setUpdatedAt(transaction.getUpdatedAt()); + + return entry; + } + + private static LedgerPosting cashPosting(LedgerPostingType type, Account account, + AccountTransaction transaction) + { + var posting = new LedgerPosting(); + + posting.setUUID(migratedPostingUUID(transaction.getUUID(), type, "primary")); //$NON-NLS-1$ + posting.setType(type); + posting.setAccount(account); + posting.setAmount(transaction.getAmount()); + posting.setCurrency(transaction.getCurrencyCode()); + posting.setSecurity(transaction.getSecurity()); + posting.setShares(transaction.getShares()); + + return posting; + } + + private static LedgerPosting securityPosting(Portfolio portfolio, PortfolioTransaction transaction) + { + var posting = new LedgerPosting(); + + posting.setUUID(migratedPostingUUID(transaction.getUUID(), LedgerPostingType.SECURITY, "primary")); //$NON-NLS-1$ + posting.setType(LedgerPostingType.SECURITY); + posting.setPortfolio(portfolio); + posting.setAmount(transaction.getAmount()); + posting.setCurrency(transaction.getCurrencyCode()); + posting.setSecurity(transaction.getSecurity()); + posting.setShares(transaction.getShares()); + + return posting; + } + + private static List addUnitPostings(LedgerEntry entry, Transaction transaction) + { + var units = transaction.getUnits().toList(); + var postings = new ArrayList(); + + for (var index = 0; index < units.size(); index++) + { + var posting = unitPosting(units.get(index)); + + posting.setUUID(migratedPostingUUID(transaction.getUUID(), posting.getType(), "unit-" + index)); //$NON-NLS-1$ + addPosting(entry, posting); + postings.add(posting); + } + + return List.copyOf(postings); + } + + private static void addUnitMemberships(LedgerProjectionRef projection, List unitPostings) + { + for (var posting : unitPostings) + { + switch (posting.getType()) + { + case FEE -> projection.addMembership(posting.getUUID(), ProjectionMembershipRole.FEE_UNIT); + case TAX -> projection.addMembership(posting.getUUID(), ProjectionMembershipRole.TAX_UNIT); + case GROSS_VALUE -> projection.addMembership(posting.getUUID(), ProjectionMembershipRole.GROSS_VALUE_UNIT); + default -> throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_IMPORT_021 + .message("Unsupported unit posting type: " + posting.getType())); //$NON-NLS-1$ + } + } + } + + private static LedgerProjectionRef accountProjection(String uuid, LedgerProjectionRole role, Account account, + String primaryPostingUUID) + { + var projection = new LedgerProjectionRef(uuid); + + projection.setRole(role); + projection.setAccount(account); + projection.setPrimaryPostingTargetUUID(primaryPostingUUID); + + return projection; + } + + private static LedgerProjectionRef portfolioProjection(String uuid, LedgerProjectionRole role, + Portfolio portfolio, String primaryPostingUUID) + { + var projection = new LedgerProjectionRef(uuid); + + projection.setRole(role); + projection.setPortfolio(portfolio); + projection.setPrimaryPostingTargetUUID(primaryPostingUUID); + + return projection; + } + + private static void addEntry(Ledger ledger, LedgerEntry entry) + { + ledger.addEntry(entry); + } + + private static void setUpdatedAt(LedgerEntry entry, Transaction transaction) + { + entry.setUpdatedAt(transaction.getUpdatedAt()); + } + + private static void addPosting(LedgerEntry entry, LedgerPosting posting) + { + entry.addPosting(posting); + } + + private static void addProjectionRef(LedgerEntry entry, LedgerProjectionRef projection) + { + entry.addProjectionRef(projection); + } + + private static void addParameter(LedgerPosting posting, LedgerParameter parameter) + { + posting.addParameter(parameter); + } + + private static LedgerPosting unitPosting(Unit unit) + { + var posting = new LedgerPosting(); + var amount = unit.getAmount(); + + posting.setType(switch (unit.getType()) + { + case FEE -> LedgerPostingType.FEE; + case TAX -> LedgerPostingType.TAX; + case GROSS_VALUE -> LedgerPostingType.GROSS_VALUE; + }); + posting.setAmount(amount.getAmount()); + posting.setCurrency(amount.getCurrencyCode()); + + if (unit.getForex() != null) + { + posting.setForexAmount(unit.getForex().getAmount()); + posting.setForexCurrency(unit.getForex().getCurrencyCode()); + posting.setExchangeRate(unit.getExchangeRate()); + } + + return posting; + } + + private static String migratedEntryUUID(LedgerEntryType type, String primaryProjectionUUID) + { + var key = "ledger-v6:migrated-entry:" + type + ":" + primaryProjectionUUID; //$NON-NLS-1$ //$NON-NLS-2$ + return UUID.nameUUIDFromBytes(key.getBytes(StandardCharsets.UTF_8)).toString(); + } + + private static String migratedPostingUUID(String projectionUUID, LedgerPostingType type, String discriminator) + { + var key = "ledger-v6:migrated-posting:" + projectionUUID + ":" + type + ":" + discriminator; //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ + return UUID.nameUUIDFromBytes(key.getBytes(StandardCharsets.UTF_8)).toString(); + } + } + + private static final class MigrationPlan + { + private final Client client; + private final List entries = new ArrayList<>(); + private final List legacyTransactionsToRemove = new ArrayList<>(); + private final Set projectionUUIDsToRemove = new HashSet<>(); + private final Set plannedProjectionUUIDs = new HashSet<>(); + private final List diagnostics = new ArrayList<>(); + private int migratedTransactionCount; + private boolean applied; + + private MigrationPlan(Client client) + { + this.client = client; + } + + private void addEntry(LedgerEntry entry, Transaction... transactions) + { + entries.add(entry); + + for (var transaction : transactions) + markMigrated(transaction); + + for (var projection : entry.getProjectionRefs()) + plannedProjectionUUIDs.add(projection.getUUID()); + + MigrationGraphBuilder.setUpdatedAt(entry, transactions[0]); + } + + private boolean handleAlreadyMigratedCompleteGroup(String family, LedgerEntry expectedEntry, + Transaction... transactions) + { + var projectionUUIDs = projectionUUIDs(expectedEntry); + + if (projectionUUIDs.stream().anyMatch(plannedProjectionUUIDs::contains)) + { + addDiagnostic(family, "DUPLICATE_CONFLICT", "plannedProjectionConflict", transactions); //$NON-NLS-1$ //$NON-NLS-2$ + return true; + } + + var matchingEntries = existingEntriesContainingAny(projectionUUIDs); + + if (matchingEntries.isEmpty()) + return false; + + if (matchingEntries.size() == 1 && existingEntryContainsAll(matchingEntries.get(0), projectionUUIDs) + && isStructurallyValidExistingEntry(matchingEntries.get(0))) + { + var mismatch = semanticMismatch(matchingEntries.get(0), expectedEntry); + + if (mismatch == SemanticMismatch.NONE) + { + for (var transaction : transactions) + markAlreadyMigrated(transaction); + + addDiagnostic(family, "SKIPPED_ALREADY_MIGRATED", "completeExistingLedgerEntry", transactions); //$NON-NLS-1$ //$NON-NLS-2$ + return true; + } + + addDiagnostic(family, "DUPLICATE_CONFLICT", "existingProjectionConflict mismatch=" + mismatch, //$NON-NLS-1$ //$NON-NLS-2$ + transactions); + return true; + } + + addDiagnostic(family, "DUPLICATE_CONFLICT", //$NON-NLS-1$ + "existingProjectionConflict mismatch=" + duplicateMismatch(matchingEntries, //$NON-NLS-1$ + projectionUUIDs), + transactions); + return true; + } + + private void markMigrated(Transaction transaction) + { + legacyTransactionsToRemove.add(transaction); + projectionUUIDsToRemove.add(transaction.getUUID()); + migratedTransactionCount++; + } + + private void markAlreadyMigrated(Transaction transaction) + { + legacyTransactionsToRemove.add(transaction); + projectionUUIDsToRemove.add(transaction.getUUID()); + } + + private List existingEntriesContainingAny(List projectionUUIDs) + { + var result = new ArrayList(); + + for (var entry : client.getLedger().getEntries()) + { + if (entry.getProjectionRefs().stream().anyMatch(ref -> projectionUUIDs.contains(ref.getUUID()))) + result.add(entry); + } + + return result; + } + + private boolean existingEntryContainsAll(LedgerEntry entry, List projectionUUIDs) + { + var existingProjectionUUIDs = new HashSet(); + + for (var projection : entry.getProjectionRefs()) + existingProjectionUUIDs.add(projection.getUUID()); + + return existingProjectionUUIDs.containsAll(projectionUUIDs); + } + + private boolean isStructurallyValidExistingEntry(LedgerEntry entry) + { + var ledger = new Ledger(); + + MigrationGraphBuilder.addEntry(ledger, entry); + + return LedgerStructuralValidator.validate(ledger).isOK(); + } + + private SemanticMismatch duplicateMismatch(List matchingEntries, List projectionUUIDs) + { + if (matchingEntries.size() != 1) + return SemanticMismatch.PROJECTION_UUID; + + if (!existingEntryContainsAll(matchingEntries.get(0), projectionUUIDs)) + return SemanticMismatch.PROJECTION_UUID; + + if (!isStructurallyValidExistingEntry(matchingEntries.get(0))) + return SemanticMismatch.STRUCTURAL_VALIDATION; + + return SemanticMismatch.PROJECTION_UUID; + } + + private SemanticMismatch semanticMismatch(LedgerEntry existingEntry, LedgerEntry expectedEntry) + { + if (existingEntry.getType() != expectedEntry.getType()) + return SemanticMismatch.ENTRY_TYPE; + + if (existingEntry.getProjectionRefs().size() != expectedEntry.getProjectionRefs().size()) + return SemanticMismatch.PROJECTION_UUID; + + if (!postingTypeCounts(existingEntry).equals(postingTypeCounts(expectedEntry))) + return SemanticMismatch.POSTING_TYPE_SHAPE; + + if (!sameUnitPostingFacts(existingEntry, expectedEntry)) + return SemanticMismatch.UNIT_POSTINGS; + + for (var expectedProjection : expectedEntry.getProjectionRefs()) + { + var existingProjection = projectionByUUID(existingEntry, expectedProjection.getUUID()); + + if (existingProjection == null) + return SemanticMismatch.PROJECTION_UUID; + + if (existingProjection.getRole() != expectedProjection.getRole()) + return SemanticMismatch.PROJECTION_ROLE; + + if (existingProjection.getAccount() != expectedProjection.getAccount() + || existingProjection.getPortfolio() != expectedProjection.getPortfolio()) + return SemanticMismatch.PROJECTION_OWNER; + + var expectedPosting = postingByUUID(expectedEntry, expectedProjection.getPrimaryPostingUUID()); + var existingPosting = postingByUUID(existingEntry, existingProjection.getPrimaryPostingUUID()); + + if (expectedPosting == null || existingPosting == null) + return SemanticMismatch.PRIMARY_POSTING; + + var postingMismatch = postingOwnerMismatch(existingPosting, expectedPosting, expectedEntry.getType()); + + if (postingMismatch != SemanticMismatch.NONE) + return postingMismatch; + } + + return SemanticMismatch.NONE; + } + + private List projectionUUIDs(LedgerEntry entry) + { + var result = new ArrayList(); + + for (var projection : entry.getProjectionRefs()) + result.add(projection.getUUID()); + + return result; + } + + private java.util.Map postingTypeCounts(LedgerEntry entry) + { + var result = new java.util.EnumMap(LedgerPostingType.class); + + for (var posting : entry.getPostings()) + result.merge(posting.getType(), 1, Integer::sum); + + return result; + } + + private List unitPostingFacts(LedgerEntry entry) + { + var result = new ArrayList(); + + for (var posting : entry.getPostings()) + if (isUnitPosting(posting.getType())) + result.add(new UnitPostingFact(posting.getType(), posting.getAmount(), posting.getCurrency(), + posting.getForexAmount(), posting.getForexCurrency(), posting.getExchangeRate(), + posting.getAccount(), posting.getPortfolio(), posting.getSecurity(), + posting.getShares())); + + return result; + } + + private boolean sameUnitPostingFacts(LedgerEntry existingEntry, LedgerEntry expectedEntry) + { + var unmatchedExpectedFacts = new ArrayList<>(unitPostingFacts(expectedEntry)); + + for (var existingFact : unitPostingFacts(existingEntry)) + if (!unmatchedExpectedFacts.remove(existingFact)) + return false; + + return unmatchedExpectedFacts.isEmpty(); + } + + private boolean isUnitPosting(LedgerPostingType type) + { + return type == LedgerPostingType.FEE || type == LedgerPostingType.TAX + || type == LedgerPostingType.GROSS_VALUE; + } + + private LedgerProjectionRef projectionByUUID(LedgerEntry entry, String uuid) + { + for (var projection : entry.getProjectionRefs()) + if (Objects.equals(projection.getUUID(), uuid)) + return projection; + + return null; + } + + private LedgerPosting postingByUUID(LedgerEntry entry, String uuid) + { + for (var posting : entry.getPostings()) + if (Objects.equals(posting.getUUID(), uuid)) + return posting; + + return null; + } + + private SemanticMismatch postingOwnerMismatch(LedgerPosting existingPosting, LedgerPosting expectedPosting, + LedgerEntryType expectedEntryType) + { + if (existingPosting.getType() != expectedPosting.getType()) + return SemanticMismatch.POSTING_TYPE_SHAPE; + + if (existingPosting.getAccount() != expectedPosting.getAccount() + || existingPosting.getPortfolio() != expectedPosting.getPortfolio()) + return SemanticMismatch.POSTING_OWNER; + + if (expectedEntryType == LedgerEntryType.DIVIDENDS + && existingPosting.getSecurity() != expectedPosting.getSecurity()) + return SemanticMismatch.DIVIDEND_SECURITY; + + return SemanticMismatch.NONE; + } + + private void addDiagnostic(String family, String reason, String context, Transaction... transactions) + { + diagnostics.add(LedgerDiagnosticMessageFormatter.formatMigrationDiagnostic(client, + "family=" + family + " reason=" + reason + " uuids=" + uuids(transactions) + " " //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$ + + context, + transactions)); + } + + private void addDiagnostic(LedgerDiagnosticCode code, String family, String reason, String context, + Transaction... transactions) + { + diagnostics.add(LedgerDiagnosticMessageFormatter.formatMigrationDiagnostic(client, + code.prefix() + " family=" + family + " reason=" + reason + " uuids=" //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ + + uuids(transactions) + " " + context, //$NON-NLS-1$ + transactions)); + } + + private void addDiagnostic(String family, String reason, String context) + { + diagnostics.add("family=" + family + " reason=" + reason + " " + context); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ + } + + private String uuids(Transaction... transactions) + { + var result = new ArrayList(); + + for (var transaction : transactions) + result.add(transaction != null ? transaction.getUUID() : ""); //$NON-NLS-1$ + + return result.toString(); + } + + private List getEntries() + { + return entries; + } + + private List getLegacyTransactionsToRemove() + { + return legacyTransactionsToRemove; + } + + private Set getProjectionUUIDsToRemove() + { + return projectionUUIDsToRemove; + } + + private List getDiagnostics() + { + return diagnostics; + } + + private int getMigratedTransactionCount() + { + return applied ? migratedTransactionCount : 0; + } + + private boolean hasChanges() + { + return !entries.isEmpty() || !legacyTransactionsToRemove.isEmpty(); + } + + private void markApplied() + { + applied = true; + } + + private enum SemanticMismatch + { + NONE, + ENTRY_TYPE, + PROJECTION_UUID, + PROJECTION_ROLE, + PROJECTION_OWNER, + PRIMARY_POSTING, + POSTING_OWNER, + POSTING_TYPE_SHAPE, + UNIT_POSTINGS, + DIVIDEND_SECURITY, + STRUCTURAL_VALIDATION + } + + private record UnitPostingFact(LedgerPostingType type, long amount, String currency, Long forexAmount, + String forexCurrency, BigDecimal exchangeRate, Account account, Portfolio portfolio, + Security security, long shares) + {} + } + + public static final class MigrationResult + { + private final int migratedTransactionCount; + private final List diagnostics; + + private MigrationResult(int migratedTransactionCount, List diagnostics) + { + this.migratedTransactionCount = migratedTransactionCount; + this.diagnostics = List.copyOf(diagnostics); + } + + public int getMigratedTransactionCount() + { + return migratedTransactionCount; + } + + public List getDiagnostics() + { + return diagnostics; + } + + public boolean hasDiagnostics() + { + return !diagnostics.isEmpty(); + } + } +} From 4ea2a84cc530710d5425b9fcab85ce7620f36c9c Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Fri, 26 Jun 2026 14:29:50 +0200 Subject: [PATCH 07/68] Add Ledger conversion and mutation support Add Ledger converters, mutation helpers, owner/unit/share patching, deletion support, and tests. This keeps mutation and conversion review separate from basic creators and UI wiring. Dialog/viewer availability, NLS text, and documentation are intentionally left to later commits. --- .../LedgerOwnerChangeFailurePathTest.java | 288 +++++ .../LedgerCrossEntryWriteGuardrailTest.java | 236 +++++ .../model/ledger/LedgerGraphWriterTest.java | 147 +++ .../ledger/LedgerGuardrailTestSupport.java | 231 ++++ .../model/ledger/LedgerModelCopyTest.java | 225 ++++ .../ledger/LedgerMutationContextTest.java | 982 ++++++++++++++++++ ...TransferToDepositRemovalConverterTest.java | 678 ++++++++++++ .../LedgerAccountTypeToggleConverterTest.java | 474 +++++++++ .../LedgerBuySellDeliveryConverterTest.java | 851 +++++++++++++++ .../LedgerBuySellReversalConverterTest.java | 472 +++++++++ .../LedgerDeliveryDirectionConverterTest.java | 407 ++++++++ .../LedgerTransferDirectionConverterTest.java | 560 ++++++++++ ...ountTransferToDepositRemovalConverter.java | 38 + .../LedgerAccountTypeToggleConverter.java | 51 + .../model/LedgerBuySellDeliveryConverter.java | 86 ++ .../model/LedgerBuySellReversalConverter.java | 44 + .../LedgerDeliveryDirectionConverter.java | 58 ++ ...LedgerPortfolioCompositeTypeConverter.java | 37 + .../LedgerTransferDirectionConverter.java | 94 ++ .../model/ledger/LedgerEntryEditSupport.java | 68 ++ .../ledger/LedgerEntryMetadataPatch.java | 93 ++ .../LedgerEntryMetadataPatchHelper.java | 78 ++ .../model/ledger/LedgerFieldEdit.java | 78 ++ .../model/ledger/LedgerGraphWriter.java | 59 ++ .../model/ledger/LedgerModelCopy.java | 86 ++ .../model/ledger/LedgerMutationContext.java | 277 +++++ ...ountTransferToDepositRemovalConverter.java | 241 +++++ .../LedgerAccountTypeToggleConverter.java | 147 +++ .../LedgerBuySellDeliveryConverter.java | 293 ++++++ .../LedgerBuySellReversalConverter.java | 212 ++++ .../LedgerDeliveryDirectionConverter.java | 185 ++++ .../compatibility/LedgerOwnerPatchHelper.java | 231 ++++ ...LedgerPortfolioCompositeTypeConverter.java | 435 ++++++++ .../compatibility/LedgerPostingPatch.java | 148 +++ .../LedgerShareAdjustmentHelper.java | 190 ++++ .../LedgerTransactionDeleter.java | 48 + .../LedgerTransferDirectionConverter.java | 249 +++++ .../compatibility/LedgerUnitPostingEdit.java | 133 +++ .../compatibility/LedgerUnitPostingPatch.java | 45 + .../LedgerUnitPostingUpdater.java | 91 ++ 40 files changed, 9346 insertions(+) create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerOwnerChangeFailurePathTest.java create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCrossEntryWriteGuardrailTest.java create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerGraphWriterTest.java create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerGuardrailTestSupport.java create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerModelCopyTest.java create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerMutationContextTest.java create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferToDepositRemovalConverterTest.java create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTypeToggleConverterTest.java create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellDeliveryConverterTest.java create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellReversalConverterTest.java create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryDirectionConverterTest.java create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerTransferDirectionConverterTest.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerAccountTransferToDepositRemovalConverter.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerAccountTypeToggleConverter.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerBuySellDeliveryConverter.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerBuySellReversalConverter.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerDeliveryDirectionConverter.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerPortfolioCompositeTypeConverter.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerTransferDirectionConverter.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerEntryEditSupport.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerEntryMetadataPatch.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerEntryMetadataPatchHelper.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerFieldEdit.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerGraphWriter.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerModelCopy.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerMutationContext.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferToDepositRemovalConverter.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTypeToggleConverter.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellDeliveryConverter.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellReversalConverter.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryDirectionConverter.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerOwnerPatchHelper.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioCompositeTypeConverter.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPostingPatch.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerShareAdjustmentHelper.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerTransactionDeleter.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerTransferDirectionConverter.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerUnitPostingEdit.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerUnitPostingPatch.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerUnitPostingUpdater.java diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerOwnerChangeFailurePathTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerOwnerChangeFailurePathTest.java new file mode 100644 index 0000000000..09390eef15 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerOwnerChangeFailurePathTest.java @@ -0,0 +1,288 @@ +package name.abuchen.portfolio.model; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.Test; + +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountOnlyTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountTransferTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerBuySellTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerDeliveryTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferTransactionCreator; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Values; + +/** + * Tests rollback behavior for failed ledger owner changes. + * These tests make sure owner lists are restored when a later validation step rejects the change. + */ +@SuppressWarnings("nls") +public class LedgerOwnerChangeFailurePathTest +{ + private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 6, 7, 8, 9); + + /** + * Verifies that an account-only owner move rolls back when the later edit is invalid. + * The booking must stay on the original account and no partial owner-list move may remain. + */ + @Test + public void testAccountOnlyOwnerMoveRollsBackWhenLaterEditRejects() + { + var client = new Client(); + var source = account("Source"); + var target = account("Target"); + client.addAccount(source); + client.addAccount(target); + + var creator = new LedgerAccountOnlyTransactionCreator(client); + var transaction = creator.create(source, AccountTransaction.Type.DEPOSIT, DATE_TIME, + Values.Amount.factorize(100), CurrencyUnit.EUR, null, List.of(), "note", "source"); + var before = ClientSnapshot.capture(client); + + assertThrows(IllegalArgumentException.class, + () -> creator.update(transaction, target, AccountTransaction.Type.DEPOSIT, DATE_TIME, + -Values.Amount.factorize(1), CurrencyUnit.EUR, null, List.of(), "bad", + "bad source")); + + assertThat(ClientSnapshot.capture(client), is(before)); + assertThat(source.getTransactions(), is(List.of(transaction))); + assertTrue(target.getTransactions().isEmpty()); + } + + /** + * Verifies that a delivery owner move rolls back when the later edit is invalid. + * The booking must stay on the original portfolio and no partial owner-list move may remain. + */ + @Test + public void testDeliveryOwnerMoveRollsBackWhenLaterEditRejects() + { + var fixture = portfolioFixture(); + var target = portfolio("Target", fixture.account()); + fixture.client().addPortfolio(target); + + var creator = new LedgerDeliveryTransactionCreator(fixture.client()); + var transaction = creator.create(fixture.portfolio(), PortfolioTransaction.Type.DELIVERY_INBOUND, DATE_TIME, + Values.Amount.factorize(100), CurrencyUnit.EUR, fixture.security(), + Values.Share.factorize(10), null, null, List.of(), "note", "source"); + var before = ClientSnapshot.capture(fixture.client()); + + assertThrows(IllegalArgumentException.class, + () -> creator.update(transaction, target, PortfolioTransaction.Type.DELIVERY_INBOUND, + DATE_TIME, -Values.Amount.factorize(1), CurrencyUnit.EUR, + fixture.security(), Values.Share.factorize(10), null, null, List.of(), "bad", + "bad source")); + + assertThat(ClientSnapshot.capture(fixture.client()), is(before)); + assertThat(fixture.portfolio().getTransactions(), is(List.of(transaction))); + assertTrue(target.getTransactions().isEmpty()); + } + + /** + * Verifies that a buy/sell owner move rolls back when the later edit is invalid. + * Both account and portfolio projections must remain on their original owners. + */ + @Test + public void testBuySellOwnerMoveRollsBackWhenLaterEditRejects() + { + var fixture = portfolioFixture(); + var targetPortfolio = portfolio("Target", fixture.account()); + fixture.client().addPortfolio(targetPortfolio); + + var creator = new LedgerBuySellTransactionCreator(fixture.client()); + var entry = creator.create(fixture.portfolio(), fixture.account(), PortfolioTransaction.Type.BUY, DATE_TIME, + Values.Amount.factorize(100), CurrencyUnit.EUR, fixture.security(), + Values.Share.factorize(10), List.of(), "note", "source"); + var before = ClientSnapshot.capture(fixture.client()); + + assertThrows(IllegalArgumentException.class, + () -> creator.update(entry, targetPortfolio, fixture.account(), PortfolioTransaction.Type.BUY, + DATE_TIME, -Values.Amount.factorize(1), CurrencyUnit.EUR, fixture.security(), + Values.Share.factorize(10), List.of(), "bad", "bad source")); + + assertThat(ClientSnapshot.capture(fixture.client()), is(before)); + assertThat(fixture.portfolio().getTransactions(), is(List.of(entry.getPortfolioTransaction()))); + assertTrue(targetPortfolio.getTransactions().isEmpty()); + assertThat(fixture.account().getTransactions(), is(List.of(entry.getAccountTransaction()))); + } + + /** + * Verifies that an account-transfer owner move rolls back when the later edit is invalid. + * Source and target account lists must stay unchanged after the failed update. + */ + @Test + public void testAccountTransferOwnerMoveRollsBackWhenLaterEditRejects() + { + var client = new Client(); + var source = account("Source"); + var target = account("Target"); + var newSource = account("New Source"); + client.addAccount(source); + client.addAccount(target); + client.addAccount(newSource); + + var creator = new LedgerAccountTransferTransactionCreator(client); + var transfer = creator.create(source, target, DATE_TIME, Values.Amount.factorize(100), CurrencyUnit.EUR, + Values.Amount.factorize(100), CurrencyUnit.EUR, null, null, "note", "source"); + var before = ClientSnapshot.capture(client); + + assertThrows(IllegalArgumentException.class, + () -> creator.update(transfer, newSource, target, DATE_TIME, -Values.Amount.factorize(1), + CurrencyUnit.EUR, Values.Amount.factorize(100), CurrencyUnit.EUR, null, null, + "bad", "bad source")); + + assertThat(ClientSnapshot.capture(client), is(before)); + assertThat(source.getTransactions(), is(List.of(transfer.getSourceTransaction()))); + assertThat(target.getTransactions(), is(List.of(transfer.getTargetTransaction()))); + assertTrue(newSource.getTransactions().isEmpty()); + } + + /** + * Verifies that a portfolio-transfer owner move rolls back when the later edit is invalid. + * Source and target portfolio lists must stay unchanged after the failed update. + */ + @Test + public void testPortfolioTransferOwnerMoveRollsBackWhenLaterEditRejects() + { + var fixture = portfolioTransferFixture(); + var newSource = portfolio("New Source", fixture.account()); + fixture.client().addPortfolio(newSource); + + var creator = new LedgerPortfolioTransferTransactionCreator(fixture.client()); + var transfer = creator.create(fixture.source(), fixture.target(), fixture.security(), DATE_TIME, + Values.Share.factorize(10), Values.Amount.factorize(100), CurrencyUnit.EUR, "note", + "source"); + var before = ClientSnapshot.capture(fixture.client()); + + assertThrows(IllegalArgumentException.class, + () -> creator.update(transfer, newSource, fixture.target(), fixture.security(), DATE_TIME, + Values.Share.factorize(10), -Values.Amount.factorize(1), CurrencyUnit.EUR, + "bad", "bad source")); + + assertThat(ClientSnapshot.capture(fixture.client()), is(before)); + assertThat(fixture.source().getTransactions(), is(List.of(transfer.getSourceTransaction()))); + assertThat(fixture.target().getTransactions(), is(List.of(transfer.getTargetTransaction()))); + assertTrue(newSource.getTransactions().isEmpty()); + } + + private static Account account(String name) + { + var account = new Account(name); + + account.setCurrencyCode(CurrencyUnit.EUR); + + return account; + } + + private static Portfolio portfolio(String name, Account account) + { + var portfolio = new Portfolio(name); + + portfolio.setReferenceAccount(account); + + return portfolio; + } + + private static PortfolioFixture portfolioFixture() + { + var client = new Client(); + var account = account("Account"); + var portfolio = portfolio("Portfolio", account); + var security = new Security("Security", CurrencyUnit.EUR); + + client.addAccount(account); + client.addPortfolio(portfolio); + client.addSecurity(security); + + return new PortfolioFixture(client, account, portfolio, security); + } + + private static PortfolioTransferFixture portfolioTransferFixture() + { + var client = new Client(); + var account = account("Account"); + var source = portfolio("Source", account); + var target = portfolio("Target", account); + var security = new Security("Security", CurrencyUnit.EUR); + + client.addAccount(account); + client.addPortfolio(source); + client.addPortfolio(target); + client.addSecurity(security); + + return new PortfolioTransferFixture(client, account, source, target, security); + } + + private record PortfolioFixture(Client client, Account account, Portfolio portfolio, Security security) + { + } + + private record PortfolioTransferFixture(Client client, Account account, Portfolio source, Portfolio target, + Security security) + { + } + + private record ClientSnapshot(List entries, List> accountTransactions, + List> portfolioTransactions, List allTransactions) + { + static ClientSnapshot capture(Client client) + { + return new ClientSnapshot(client.getLedger().getEntries().stream().map(EntrySnapshot::capture).toList(), + client.getAccounts().stream().map(account -> transactionUUIDs(account.getTransactions())) + .toList(), + client.getPortfolios().stream() + .map(portfolio -> transactionUUIDs(portfolio.getTransactions())).toList(), + client.getAllTransactions().stream().map(pair -> pair.getTransaction().getUUID()).toList()); + } + + private static List transactionUUIDs(List transactions) + { + return transactions.stream().map(Transaction::getUUID).toList(); + } + } + + private record EntrySnapshot(String uuid, Object type, Object dateTime, String note, String source, + List postings, List projections) + { + static EntrySnapshot capture(LedgerEntry entry) + { + return new EntrySnapshot(entry.getUUID(), entry.getType(), entry.getDateTime(), entry.getNote(), + entry.getSource(), entry.getPostings().stream().map(PostingSnapshot::capture).toList(), + entry.getProjectionRefs().stream().map(ProjectionSnapshot::capture).toList()); + } + } + + private record PostingSnapshot(String uuid, Object type, long amount, String currency, Long forexAmount, + String forexCurrency, Object exchangeRate, Security security, long shares, Account account, + Portfolio portfolio, List parameters) + { + static PostingSnapshot capture(LedgerPosting posting) + { + return new PostingSnapshot(posting.getUUID(), posting.getType(), posting.getAmount(), posting.getCurrency(), + posting.getForexAmount(), posting.getForexCurrency(), posting.getExchangeRate(), + posting.getSecurity(), posting.getShares(), posting.getAccount(), posting.getPortfolio(), + posting.getParameters().stream().map(parameter -> List.of(parameter.getType(), + parameter.getValueKind(), parameter.getValue())).map(Object.class::cast) + .toList()); + } + } + + private record ProjectionSnapshot(String uuid, Object role, Account account, Portfolio portfolio, + String primaryPostingUUID, String postingGroupUUID) + { + static ProjectionSnapshot capture(LedgerProjectionRef projection) + { + return new ProjectionSnapshot(projection.getUUID(), projection.getRole(), projection.getAccount(), + projection.getPortfolio(), projection.getPrimaryPostingUUID(), + projection.getPostingGroupUUID()); + } + } +} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCrossEntryWriteGuardrailTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCrossEntryWriteGuardrailTest.java new file mode 100644 index 0000000000..27c61b4e57 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCrossEntryWriteGuardrailTest.java @@ -0,0 +1,236 @@ +package name.abuchen.portfolio.model.ledger; + +import static name.abuchen.portfolio.model.ledger.LedgerGuardrailTestSupport.DATE_TIME; +import static name.abuchen.portfolio.model.ledger.LedgerGuardrailTestSupport.account; +import static name.abuchen.portfolio.model.ledger.LedgerGuardrailTestSupport.assertAcceptedWithoutChangingState; +import static name.abuchen.portfolio.model.ledger.LedgerGuardrailTestSupport.assertRejectedWithoutChangingState; +import static name.abuchen.portfolio.model.ledger.LedgerGuardrailTestSupport.cashLeg; +import static name.abuchen.portfolio.model.ledger.LedgerGuardrailTestSupport.creator; +import static name.abuchen.portfolio.model.ledger.LedgerGuardrailTestSupport.metadata; +import static name.abuchen.portfolio.model.ledger.LedgerGuardrailTestSupport.money; +import static name.abuchen.portfolio.model.ledger.LedgerGuardrailTestSupport.portfolio; +import static name.abuchen.portfolio.model.ledger.LedgerGuardrailTestSupport.portfolioLeg; +import static name.abuchen.portfolio.model.ledger.LedgerGuardrailTestSupport.security; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; + +import org.junit.Test; + +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.AccountTransferEntry; +import name.abuchen.portfolio.model.BuySellEntry; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.PortfolioTransaction; +import name.abuchen.portfolio.model.PortfolioTransferEntry; +import name.abuchen.portfolio.model.Transaction; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerCashTransferLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerCreationUnits; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferSecurity; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionService; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.Values; + +/** + * Tests write guardrails on ledger-backed cross-entry compatibility wrappers. + * These tests make sure legacy cross-entry APIs cannot mutate runtime projections as a second source of truth. + */ +@SuppressWarnings("nls") +public class LedgerCrossEntryWriteGuardrailTest +{ + /** + * Verifies that ledger-backed cross entries allow only replay no-op behavior. + * Legacy write methods must not mutate runtime projections or create a second truth. + */ + @Test + public void testCrossEntryWriteMethodsStillRejectMutationExceptReplayNoop() + { + var client = new Client(); + var account = account(); + var portfolio = portfolio(); + + creator(client).createBuy(metadata(), cashLeg(account, 100), portfolioLeg(portfolio, 100), + LedgerCreationUnits.none()); + LedgerProjectionService.materialize(client); + + var transaction = account.getTransactions().get(0); + + assertThrows(UnsupportedOperationException.class, () -> transaction.getCrossEntry().insert()); + transaction.getCrossEntry().updateFrom(transaction); + assertSame(portfolio, transaction.getCrossEntry().getCrossOwner(transaction)); + assertSame(portfolio.getTransactions().get(0), transaction.getCrossEntry().getCrossTransaction(transaction)); + assertThrows(UnsupportedOperationException.class, + () -> transaction.getCrossEntry().setOwner(transaction, account)); + assertThrows(UnsupportedOperationException.class, + () -> transaction.getCrossEntry().setSource("legacy source")); + assertSame(portfolio, transaction.getCrossEntry().getCrossOwner(transaction)); + assertSame(portfolio.getTransactions().get(0), transaction.getCrossEntry().getCrossTransaction(transaction)); + } + + /** + * Verifies that all ledger-backed cross-entry families reject legacy write methods. + * Buy/sell, account transfers, and portfolio transfers must stay unchanged after rejected writes. + */ + @Test + public void testCrossEntryWriteMethodsRejectForAllLedgerBackedCrossEntryFamiliesWithoutPartialMutation() + { + var client = new Client(); + var account = account(); + var targetAccount = account(); + var portfolio = portfolio(); + var targetPortfolio = portfolio(); + + creator(client).createBuy(metadata(), cashLeg(account, 100), portfolioLeg(portfolio, 100), + LedgerCreationUnits.none()); + creator(client).createAccountTransfer(metadata(), LedgerCashTransferLeg.of(account, money(40)), + LedgerCashTransferLeg.of(targetAccount, money(40))); + creator(client).createPortfolioTransfer(metadata(), + LedgerPortfolioTransferSecurity.of(security(), Values.Share.factorize(5)), + LedgerPortfolioTransferLeg.of(portfolio, money(50)), + LedgerPortfolioTransferLeg.of(targetPortfolio, money(50))); + LedgerProjectionService.materialize(client); + + assertCrossEntryWritesRejectWithoutChangingState(account.getTransactions().get(0)); + assertCrossEntryWritesRejectWithoutChangingState(account.getTransactions().get(1)); + assertCrossEntryWritesRejectWithoutChangingState(targetAccount.getTransactions().get(0)); + assertCrossEntryWritesRejectWithoutChangingState(portfolio.getTransactions().get(1)); + assertCrossEntryWritesRejectWithoutChangingState(targetPortfolio.getTransactions().get(0)); + } + + /** + * Verifies that ledger-backed legacy wrapper mutators reject without partial mutation. + * Compatibility wrappers may expose old APIs, but they must not write business facts directly. + */ + @Test + public void testLedgerBackedLegacyWrapperMutatorsRejectWithoutPartialMutation() + { + var client = new Client(); + var account = account(); + var targetAccount = account(); + var portfolio = portfolio(); + var targetPortfolio = portfolio(); + + creator(client).createBuy(metadata(), cashLeg(account, 100), portfolioLeg(portfolio, 100), + LedgerCreationUnits.none()); + creator(client).createAccountTransfer(metadata(), LedgerCashTransferLeg.of(account, money(40)), + LedgerCashTransferLeg.of(targetAccount, money(40))); + creator(client).createPortfolioTransfer(metadata(), + LedgerPortfolioTransferSecurity.of(security(), Values.Share.factorize(5)), + LedgerPortfolioTransferLeg.of(portfolio, money(50)), + LedgerPortfolioTransferLeg.of(targetPortfolio, money(50))); + LedgerProjectionService.materialize(client); + + var buySellTransaction = account.getTransactions().get(0); + var buySell = (BuySellEntry) buySellTransaction.getCrossEntry(); + assertSame(portfolio, buySell.getCrossOwner(buySellTransaction)); + assertSame(portfolio.getTransactions().get(0), buySell.getCrossTransaction(buySellTransaction)); + assertBuySellWrapperMutatorsRejectWithoutChangingState(buySellTransaction, buySell); + + var accountTransferTransaction = account.getTransactions().get(1); + var accountTransfer = (AccountTransferEntry) accountTransferTransaction.getCrossEntry(); + assertSame(targetAccount, accountTransfer.getCrossOwner(accountTransferTransaction)); + assertSame(targetAccount.getTransactions().get(0), + accountTransfer.getCrossTransaction(accountTransferTransaction)); + assertAccountTransferWrapperMutatorsRejectWithoutChangingState(accountTransferTransaction, accountTransfer); + + var portfolioTransferTransaction = portfolio.getTransactions().get(1); + var portfolioTransfer = (PortfolioTransferEntry) portfolioTransferTransaction.getCrossEntry(); + assertSame(targetPortfolio, portfolioTransfer.getCrossOwner(portfolioTransferTransaction)); + assertSame(targetPortfolio.getTransactions().get(0), + portfolioTransfer.getCrossTransaction(portfolioTransferTransaction)); + assertPortfolioTransferWrapperMutatorsRejectWithoutChangingState(portfolioTransferTransaction, + portfolioTransfer); + } + + private void assertCrossEntryWritesRejectWithoutChangingState(Transaction transaction) + { + var crossEntry = transaction.getCrossEntry(); + var owner = crossEntry.getOwner(transaction); + var crossTransaction = crossEntry.getCrossTransaction(transaction); + var crossOwner = crossEntry.getCrossOwner(transaction); + + assertRejectedWithoutChangingState((LedgerBackedTransaction) transaction, crossEntry::insert); + assertAcceptedWithoutChangingState((LedgerBackedTransaction) transaction, () -> crossEntry.updateFrom(transaction)); + assertRejectedWithoutChangingState((LedgerBackedTransaction) transaction, + () -> crossEntry.setOwner(transaction, owner)); + assertRejectedWithoutChangingState((LedgerBackedTransaction) transaction, + () -> crossEntry.setSource("legacy source")); + + assertSame(owner, crossEntry.getOwner(transaction)); + assertSame(crossTransaction, crossEntry.getCrossTransaction(transaction)); + assertSame(crossOwner, crossEntry.getCrossOwner(transaction)); + } + + private void assertBuySellWrapperMutatorsRejectWithoutChangingState(Transaction transaction, BuySellEntry wrapper) + { + assertRejectedWithoutChangingState((LedgerBackedTransaction) transaction, () -> wrapper.setPortfolio(portfolio())); + assertRejectedWithoutChangingState((LedgerBackedTransaction) transaction, () -> wrapper.setAccount(account())); + assertRejectedWithoutChangingState((LedgerBackedTransaction) transaction, + () -> wrapper.setDate(DATE_TIME.plusDays(1))); + assertRejectedWithoutChangingState((LedgerBackedTransaction) transaction, + () -> wrapper.setType(PortfolioTransaction.Type.SELL)); + assertRejectedWithoutChangingState((LedgerBackedTransaction) transaction, () -> wrapper.setSecurity(security())); + assertRejectedWithoutChangingState((LedgerBackedTransaction) transaction, () -> wrapper.setShares(1L)); + assertRejectedWithoutChangingState((LedgerBackedTransaction) transaction, () -> wrapper.setAmount(1L)); + assertRejectedWithoutChangingState((LedgerBackedTransaction) transaction, + () -> wrapper.setCurrencyCode(CurrencyUnit.USD)); + assertRejectedWithoutChangingState((LedgerBackedTransaction) transaction, + () -> wrapper.setMonetaryAmount(Money.of(CurrencyUnit.USD, 1L))); + assertRejectedWithoutChangingState((LedgerBackedTransaction) transaction, () -> wrapper.setNote("note")); + assertRejectedWithoutChangingState((LedgerBackedTransaction) transaction, () -> wrapper.setSource("source")); + assertRejectedWithoutChangingState((LedgerBackedTransaction) transaction, wrapper::insert); + assertAcceptedWithoutChangingState((LedgerBackedTransaction) transaction, () -> wrapper.updateFrom(transaction)); + assertRejectedWithoutChangingState((LedgerBackedTransaction) transaction, + () -> wrapper.setOwner(transaction, wrapper.getOwner(transaction))); + } + + private void assertAccountTransferWrapperMutatorsRejectWithoutChangingState(Transaction transaction, + AccountTransferEntry wrapper) + { + assertRejectedWithoutChangingState((LedgerBackedTransaction) transaction, + () -> wrapper.setSourceTransaction(new AccountTransaction())); + assertRejectedWithoutChangingState((LedgerBackedTransaction) transaction, + () -> wrapper.setTargetTransaction(new AccountTransaction())); + assertRejectedWithoutChangingState((LedgerBackedTransaction) transaction, () -> wrapper.setSourceAccount(account())); + assertRejectedWithoutChangingState((LedgerBackedTransaction) transaction, () -> wrapper.setTargetAccount(account())); + assertRejectedWithoutChangingState((LedgerBackedTransaction) transaction, + () -> wrapper.setDate(DATE_TIME.plusDays(1))); + assertRejectedWithoutChangingState((LedgerBackedTransaction) transaction, () -> wrapper.setAmount(1L)); + assertRejectedWithoutChangingState((LedgerBackedTransaction) transaction, + () -> wrapper.setCurrencyCode(CurrencyUnit.USD)); + assertRejectedWithoutChangingState((LedgerBackedTransaction) transaction, () -> wrapper.setNote("note")); + assertRejectedWithoutChangingState((LedgerBackedTransaction) transaction, () -> wrapper.setSource("source")); + assertRejectedWithoutChangingState((LedgerBackedTransaction) transaction, wrapper::insert); + assertAcceptedWithoutChangingState((LedgerBackedTransaction) transaction, () -> wrapper.updateFrom(transaction)); + assertRejectedWithoutChangingState((LedgerBackedTransaction) transaction, + () -> wrapper.setOwner(transaction, wrapper.getOwner(transaction))); + } + + private void assertPortfolioTransferWrapperMutatorsRejectWithoutChangingState(Transaction transaction, + PortfolioTransferEntry wrapper) + { + assertRejectedWithoutChangingState((LedgerBackedTransaction) transaction, + () -> wrapper.setSourceTransaction(new PortfolioTransaction())); + assertRejectedWithoutChangingState((LedgerBackedTransaction) transaction, + () -> wrapper.setTargetTransaction(new PortfolioTransaction())); + assertRejectedWithoutChangingState((LedgerBackedTransaction) transaction, + () -> wrapper.setSourcePortfolio(portfolio())); + assertRejectedWithoutChangingState((LedgerBackedTransaction) transaction, + () -> wrapper.setTargetPortfolio(portfolio())); + assertRejectedWithoutChangingState((LedgerBackedTransaction) transaction, + () -> wrapper.setDate(DATE_TIME.plusDays(1))); + assertRejectedWithoutChangingState((LedgerBackedTransaction) transaction, () -> wrapper.setSecurity(security())); + assertRejectedWithoutChangingState((LedgerBackedTransaction) transaction, () -> wrapper.setShares(1L)); + assertRejectedWithoutChangingState((LedgerBackedTransaction) transaction, () -> wrapper.setAmount(1L)); + assertRejectedWithoutChangingState((LedgerBackedTransaction) transaction, + () -> wrapper.setCurrencyCode(CurrencyUnit.USD)); + assertRejectedWithoutChangingState((LedgerBackedTransaction) transaction, () -> wrapper.setNote("note")); + assertRejectedWithoutChangingState((LedgerBackedTransaction) transaction, () -> wrapper.setSource("source")); + assertRejectedWithoutChangingState((LedgerBackedTransaction) transaction, wrapper::insert); + assertAcceptedWithoutChangingState((LedgerBackedTransaction) transaction, () -> wrapper.updateFrom(transaction)); + assertRejectedWithoutChangingState((LedgerBackedTransaction) transaction, + () -> wrapper.setOwner(transaction, wrapper.getOwner(transaction))); + } +} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerGraphWriterTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerGraphWriterTest.java new file mode 100644 index 0000000000..44c5b073f8 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerGraphWriterTest.java @@ -0,0 +1,147 @@ +package name.abuchen.portfolio.model.ledger; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; + +import java.math.BigDecimal; +import java.time.Instant; +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.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 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 LedgerGraphWriterTest +{ + /** + * Checks the Ledger-V6 scenario: add remove and replace entry. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testAddRemoveAndReplaceEntry() + { + var ledger = new Ledger(); + var current = entry("entry-1"); + var replacement = entry("entry-2"); + + LedgerGraphWriter.addEntry(ledger, current); + + assertThat(ledger.getEntries().size(), is(1)); + assertSame(current, ledger.getEntries().get(0)); + + LedgerGraphWriter.replaceEntry(ledger, current, replacement); + + assertThat(ledger.getEntries().size(), is(1)); + assertSame(replacement, ledger.getEntries().get(0)); + + LedgerGraphWriter.removeEntry(ledger, replacement); + + assertThat(ledger.getEntries().size(), is(0)); + } + + /** + * Checks the Ledger-V6 scenario: replace entry contents preserves source graph and separates mutable children. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testReplaceEntryContentsPreservesSourceGraphAndSeparatesMutableChildren() + { + var source = entry("source-entry"); + var target = entry("target-entry"); + + LedgerGraphWriter.replaceEntryContents(target, source); + + assertThat(target.getUUID(), is(source.getUUID())); + assertThat(target.getType(), is(source.getType())); + assertThat(target.getDateTime(), is(source.getDateTime())); + assertThat(target.getNote(), is(source.getNote())); + assertThat(target.getSource(), is(source.getSource())); + assertThat(target.getUpdatedAt(), is(source.getUpdatedAt())); + + assertThat(target.getParameters().size(), is(2)); + assertThat(target.getParameters().get(0).getType(), is(LedgerParameterType.EVENT_REFERENCE)); + assertThat(target.getParameters().get(1).getType(), is(LedgerParameterType.RATIO_NUMERATOR)); + + assertThat(target.getPostings().size(), is(2)); + assertThat(target.getPostings().get(0).getUUID(), is("source-entry-cash")); + assertThat(target.getPostings().get(1).getUUID(), is("source-entry-security")); + + assertThat(target.getProjectionRefs().size(), is(2)); + assertThat(target.getProjectionRefs().get(0).getUUID(), is("source-entry-account")); + assertThat(target.getProjectionRefs().get(0).getPrimaryPostingUUID(), is("source-entry-cash")); + assertThat(target.getProjectionRefs().get(0).getPostingGroupUUID(), is("source-entry-security")); + assertThat(target.getProjectionRefs().get(1).getUUID(), is("source-entry-portfolio")); + assertThat(target.getProjectionRefs().get(1).getPrimaryPostingUUID(), is("source-entry-security")); + + assertNotSame(source.getPostings().get(0), target.getPostings().get(0)); + assertNotSame(source.getProjectionRefs().get(0), target.getProjectionRefs().get(0)); + + target.getPostings().get(0).setAmount(Values.Amount.factorize(200)); + target.getProjectionRefs().get(0).setPrimaryPostingUUID("changed"); + + assertThat(source.getPostings().get(0).getAmount(), is(Values.Amount.factorize(100))); + assertThat(source.getProjectionRefs().get(0).getPrimaryPostingUUID(), is("source-entry-cash")); + } + + private LedgerEntry entry(String uuid) + { + var account = new Account(); + var portfolio = new Portfolio(); + var security = new Security("Security", CurrencyUnit.EUR); + var entry = new LedgerEntry(uuid); + var cash = new LedgerPosting(uuid + "-cash"); + var securityPosting = new LedgerPosting(uuid + "-security"); + var accountProjection = new LedgerProjectionRef(uuid + "-account"); + var portfolioProjection = new LedgerProjectionRef(uuid + "-portfolio"); + + entry.setType(LedgerEntryType.BUY); + entry.setDateTime(LocalDateTime.of(2026, 1, 2, 0, 0)); + entry.setNote("note-" + uuid); + entry.setSource("source-" + uuid); + entry.addParameter(LedgerParameter.ofString(LedgerParameterType.EVENT_REFERENCE, uuid + "-event")); + entry.addParameter(LedgerParameter.ofDecimal(LedgerParameterType.RATIO_NUMERATOR, BigDecimal.ONE)); + + cash.setType(LedgerPostingType.CASH); + cash.setAmount(Values.Amount.factorize(100)); + cash.setCurrency(CurrencyUnit.EUR); + cash.setAccount(account); + + securityPosting.setType(LedgerPostingType.SECURITY); + securityPosting.setSecurity(security); + securityPosting.setShares(Values.Share.factorize(10)); + securityPosting.setPortfolio(portfolio); + + accountProjection.setRole(LedgerProjectionRole.ACCOUNT); + accountProjection.setAccount(account); + accountProjection.setPrimaryPosting(cash); + accountProjection.setPostingGroup(securityPosting); + + portfolioProjection.setRole(LedgerProjectionRole.PORTFOLIO); + portfolioProjection.setPortfolio(portfolio); + portfolioProjection.setPrimaryPosting(securityPosting); + + entry.addPosting(cash); + entry.addPosting(securityPosting); + entry.addProjectionRef(accountProjection); + entry.addProjectionRef(portfolioProjection); + entry.setUpdatedAt(Instant.parse("2026-06-20T12:00:00Z")); + + return entry; + } +} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerGuardrailTestSupport.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerGuardrailTestSupport.java new file mode 100644 index 0000000000..6e43b4163c --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerGuardrailTestSupport.java @@ -0,0 +1,231 @@ +package name.abuchen.portfolio.model.ledger; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertThrows; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.function.Consumer; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.Transaction; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountCashLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerDeliveryLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioSecurityLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerSecurityQuantity; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerTransactionCreator; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.Values; + +@SuppressWarnings("nls") +final class LedgerGuardrailTestSupport +{ + static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 1, 2, 0, 0); + + private LedgerGuardrailTestSupport() + { + } + + static void assertMetadataSetterUpdatesLedgerTruthOnly(Transaction transaction, int index) + { + var ledgerBacked = (LedgerBackedTransaction) transaction; + var entry = ledgerBacked.getLedgerEntry(); + var snapshot = Snapshot.of(entry); + var dateTime = DATE_TIME.plusDays(10 + index); + var note = "guard note " + index; + var source = "guard source " + index; + + transaction.setDateTime(dateTime); + transaction.setNote(note); + transaction.setSource(source); + + snapshot.assertOnlyMetadataChanged(entry, dateTime, note, source); + assertThat(transaction.getDateTime(), is(dateTime)); + assertThat(transaction.getNote(), is(note)); + assertThat(transaction.getSource(), is(source)); + } + + static void assertRejectedWithoutChangingState(T transaction, Consumer mutation) + { + var ledgerBacked = (LedgerBackedTransaction) transaction; + var entry = ledgerBacked.getLedgerEntry(); + var snapshot = Snapshot.of(entry); + + assertThrows(UnsupportedOperationException.class, () -> mutation.accept(transaction)); + + snapshot.assertUnchanged(entry); + } + + static void assertRejectedWithoutChangingState(LedgerBackedTransaction ledgerBacked, Runnable mutation) + { + var entry = ledgerBacked.getLedgerEntry(); + var snapshot = Snapshot.of(entry); + + assertThrows(UnsupportedOperationException.class, mutation::run); + + snapshot.assertUnchanged(entry); + } + + static void assertAcceptedWithoutChangingState(LedgerBackedTransaction ledgerBacked, Runnable mutation) + { + var entry = ledgerBacked.getLedgerEntry(); + var snapshot = Snapshot.of(entry); + + mutation.run(); + + snapshot.assertUnchanged(entry); + } + + static LedgerTransactionCreator creator(Client client) + { + return new LedgerTransactionCreator(client); + } + + static LedgerTransactionMetadata metadata() + { + return LedgerTransactionMetadata.of(DATE_TIME).withNote("note").withSource("source"); + } + + static Account account() + { + return new Account(); + } + + static Portfolio portfolio() + { + return new Portfolio(); + } + + static Security security() + { + return new Security("Security", CurrencyUnit.EUR); + } + + static LedgerAccountCashLeg cashLeg(Account account, int amount) + { + return LedgerAccountCashLeg.of(account, money(amount)); + } + + static LedgerDeliveryLeg deliveryLeg(Portfolio portfolio) + { + return LedgerDeliveryLeg.of(portfolio, LedgerSecurityQuantity.of(security(), Values.Share.factorize(5)), + money(100)); + } + + static LedgerPortfolioSecurityLeg portfolioLeg(Portfolio portfolio, int amount) + { + return LedgerPortfolioSecurityLeg.of(portfolio, + LedgerSecurityQuantity.of(security(), Values.Share.factorize(5)), money(amount)); + } + + static Money money(int amount) + { + return Money.of(CurrencyUnit.EUR, Values.Amount.factorize(amount)); + } + + static LedgerPosting posting(LedgerEntry entry, LedgerPostingType type) + { + return entry.getPostings().stream().filter(posting -> posting.getType() == type).findFirst().orElseThrow(); + } + + static LedgerProjectionRef projection(LedgerEntry entry, LedgerProjectionRole role) + { + return entry.getProjectionRefs().stream().filter(projection -> projection.getRole() == role).findFirst() + .orElseThrow(); + } + + private record Snapshot(LocalDateTime dateTime, String note, String source, List postings, + List projections, List accountProjectionUUIDs, + List portfolioProjectionUUIDs) + { + static Snapshot of(LedgerEntry entry) + { + return new Snapshot(entry.getDateTime(), entry.getNote(), entry.getSource(), + entry.getPostings().stream().map(PostingSnapshot::of).toList(), + entry.getProjectionRefs().stream().map(ProjectionSnapshot::of).toList(), + accountOwners(entry).stream().map(OwnerListSnapshot::of).toList(), + portfolioOwners(entry).stream().map(OwnerListSnapshot::of).toList()); + } + + void assertUnchanged(LedgerEntry entry) + { + assertThat(entry.getDateTime(), is(dateTime)); + assertThat(entry.getNote(), is(note)); + assertThat(entry.getSource(), is(source)); + assertThat(entry.getPostings().stream().map(PostingSnapshot::of).toList(), is(postings)); + assertThat(entry.getProjectionRefs().stream().map(ProjectionSnapshot::of).toList(), is(projections)); + assertThat(accountOwners(entry).stream().map(OwnerListSnapshot::of).toList(), is(accountProjectionUUIDs)); + assertThat(portfolioOwners(entry).stream().map(OwnerListSnapshot::of).toList(), is(portfolioProjectionUUIDs)); + } + + void assertOnlyMetadataChanged(LedgerEntry entry, LocalDateTime newDateTime, String newNote, String newSource) + { + assertThat(entry.getDateTime(), is(newDateTime)); + assertThat(entry.getNote(), is(newNote)); + assertThat(entry.getSource(), is(newSource)); + assertThat(entry.getPostings().stream().map(PostingSnapshot::of).toList(), is(postings)); + assertThat(entry.getProjectionRefs().stream().map(ProjectionSnapshot::of).toList(), is(projections)); + assertThat(accountOwners(entry).stream().map(OwnerListSnapshot::of).toList(), is(accountProjectionUUIDs)); + assertThat(portfolioOwners(entry).stream().map(OwnerListSnapshot::of).toList(), is(portfolioProjectionUUIDs)); + } + + private static List accountOwners(LedgerEntry entry) + { + return entry.getProjectionRefs().stream().map(LedgerProjectionRef::getAccount) + .filter(account -> account != null).distinct().toList(); + } + + private static List portfolioOwners(LedgerEntry entry) + { + return entry.getProjectionRefs().stream().map(LedgerProjectionRef::getPortfolio) + .filter(portfolio -> portfolio != null).distinct().toList(); + } + } + + private record OwnerListSnapshot(String ownerUUID, List transactionUUIDs) + { + static OwnerListSnapshot of(Account account) + { + return new OwnerListSnapshot(account.getUUID(), + account.getTransactions().stream().map(Transaction::getUUID).toList()); + } + + static OwnerListSnapshot of(Portfolio portfolio) + { + return new OwnerListSnapshot(portfolio.getUUID(), + portfolio.getTransactions().stream().map(Transaction::getUUID).toList()); + } + } + + private record PostingSnapshot(String uuid, LedgerPostingType type, long amount, String currency, Long forexAmount, + String forexCurrency, BigDecimal exchangeRate, Security security, long shares, Account account, + Portfolio portfolio, List> parameters) + { + static PostingSnapshot of(LedgerPosting posting) + { + return new PostingSnapshot(posting.getUUID(), posting.getType(), posting.getAmount(), posting.getCurrency(), + posting.getForexAmount(), posting.getForexCurrency(), posting.getExchangeRate(), + posting.getSecurity(), posting.getShares(), posting.getAccount(), posting.getPortfolio(), + List.copyOf(posting.getParameters())); + } + } + + private record ProjectionSnapshot(String uuid, LedgerProjectionRole role, Account account, Portfolio portfolio, + String primaryPostingUUID, String postingGroupUUID) + { + static ProjectionSnapshot of(LedgerProjectionRef projection) + { + return new ProjectionSnapshot(projection.getUUID(), projection.getRole(), projection.getAccount(), + projection.getPortfolio(), projection.getPrimaryPostingUUID(), + projection.getPostingGroupUUID()); + } + } +} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerModelCopyTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerModelCopyTest.java new file mode 100644 index 0000000000..6253cba284 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerModelCopyTest.java @@ -0,0 +1,225 @@ +package name.abuchen.portfolio.model.ledger; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; + +import java.math.BigDecimal; +import java.time.Instant; +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.configuration.FeeReason; +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.Money; +import name.abuchen.portfolio.money.Values; + +/** + * 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 LedgerModelCopyTest +{ + /** + * Checks the Ledger-V6 scenario: copy entry preserves complete entry graph. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testCopyEntryPreservesCompleteEntryGraph() + { + var original = entryGraph(); + var copy = LedgerModelCopy.copyEntry(original); + + assertNotSame(original, copy); + assertThat(copy.getUUID(), is(original.getUUID())); + assertThat(copy.getType(), is(original.getType())); + assertThat(copy.getDateTime(), is(original.getDateTime())); + assertThat(copy.getNote(), is(original.getNote())); + assertThat(copy.getSource(), is(original.getSource())); + assertThat(copy.getUpdatedAt(), is(original.getUpdatedAt())); + + assertThat(copy.getParameters().size(), is(2)); + assertParameterCopied(original.getParameters().get(0), copy.getParameters().get(0)); + assertParameterCopied(original.getParameters().get(1), copy.getParameters().get(1)); + + assertThat(copy.getPostings().size(), is(2)); + assertPostingCopied(original.getPostings().get(0), copy.getPostings().get(0)); + assertPostingCopied(original.getPostings().get(1), copy.getPostings().get(1)); + + assertThat(copy.getProjectionRefs().size(), is(2)); + assertProjectionCopied(original.getProjectionRefs().get(0), copy.getProjectionRefs().get(0)); + assertProjectionCopied(original.getProjectionRefs().get(1), copy.getProjectionRefs().get(1)); + } + + /** + * Checks the Ledger-V6 scenario: copy ledger preserves entry order and separates collections. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testCopyLedgerPreservesEntryOrderAndSeparatesCollections() + { + var first = entryGraph(); + var second = new LedgerEntry("entry-2"); + var ledger = new Ledger(); + + second.setType(LedgerEntryType.DEPOSIT); + second.setDateTime(LocalDateTime.of(2026, 1, 4, 0, 0)); + ledger.addEntry(first); + ledger.addEntry(second); + + var copy = LedgerModelCopy.copyLedger(ledger); + + assertNotSame(ledger, copy); + assertThat(copy.getEntries().stream().map(LedgerEntry::getUUID).toList(), is( + ledger.getEntries().stream().map(LedgerEntry::getUUID).toList())); + assertNotSame(ledger.getEntries().get(0), copy.getEntries().get(0)); + assertNotSame(ledger.getEntries().get(1), copy.getEntries().get(1)); + + copy.getEntries().get(0).setNote("changed copy"); + + assertThat(first.getNote(), is("source note")); + assertThat(copy.getEntries().get(0).getNote(), is("changed copy")); + } + + /** + * Checks the Ledger-V6 scenario: copy entry does not share mutable child objects. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testCopyEntryDoesNotShareMutableChildObjects() + { + var original = entryGraph(); + var copy = LedgerModelCopy.copyEntry(original); + + copy.removeParameter(copy.getParameters().get(0)); + copy.getPostings().get(1).setShares(Values.Share.factorize(99)); + copy.getPostings().get(0).removeParameter(copy.getPostings().get(0).getParameters().get(0)); + copy.getProjectionRefs().get(0).setPrimaryPostingUUID("changed-posting"); + copy.getProjectionRefs().get(0).getMemberships().get(0).setPostingUUID("changed-membership"); + + assertThat(original.getParameters().size(), is(2)); + assertThat(original.getPostings().get(1).getShares(), is(Values.Share.factorize(10))); + assertThat(original.getPostings().get(0).getParameters().size(), is(2)); + assertThat(original.getProjectionRefs().get(0).getPrimaryPostingUUID(), is("posting-1")); + assertThat(original.getProjectionRefs().get(0).getMemberships().get(0).getPostingUUID(), is("posting-1")); + } + + private LedgerEntry entryGraph() + { + var account = new Account(); + var portfolio = new Portfolio(); + var security = new Security("Siemens", CurrencyUnit.EUR); + var entry = new LedgerEntry("entry-1"); + var cash = new LedgerPosting("posting-1"); + var securityPosting = new LedgerPosting("posting-2"); + var accountProjection = new LedgerProjectionRef("projection-1"); + var portfolioProjection = new LedgerProjectionRef("projection-2"); + + entry.setType(LedgerEntryType.BUY); + entry.setDateTime(LocalDateTime.of(2026, 1, 2, 0, 0)); + entry.setNote("source note"); + entry.setSource("source system"); + entry.addParameter(LedgerParameter.ofString(LedgerParameterType.EVENT_REFERENCE, "event-1")); + entry.addParameter(LedgerParameter.ofBoolean(LedgerParameterType.MANUAL_VALUATION_OVERRIDE, true)); + + cash.setType(LedgerPostingType.CASH); + cash.setAmount(Values.Amount.factorize(100)); + cash.setCurrency(CurrencyUnit.EUR); + cash.setForexAmount(Values.Amount.factorize(110)); + cash.setForexCurrency(CurrencyUnit.USD); + cash.setExchangeRate(new BigDecimal("0.9091")); + cash.setAccount(account); + cash.addParameter(LedgerParameter.ofString(LedgerParameterType.FEE_REASON, FeeReason.BROKER_FEE.getCode())); + cash.addParameter(LedgerParameter.ofMoney(LedgerParameterType.FAIR_MARKET_VALUE, + Money.of(CurrencyUnit.EUR, Values.Amount.factorize(100)))); + + securityPosting.setType(LedgerPostingType.SECURITY); + securityPosting.setAmount(Values.Amount.factorize(100)); + securityPosting.setCurrency(CurrencyUnit.EUR); + securityPosting.setSecurity(security); + securityPosting.setShares(Values.Share.factorize(10)); + securityPosting.setPortfolio(portfolio); + + accountProjection.setRole(LedgerProjectionRole.ACCOUNT); + accountProjection.setAccount(account); + accountProjection.setPrimaryPosting(cash); + accountProjection.setPostingGroup(cash); + accountProjection.addMembership(cash.getUUID(), ProjectionMembershipRole.PRIMARY); + accountProjection.addMembership(cash.getUUID(), ProjectionMembershipRole.GROUP_ANCHOR); + + portfolioProjection.setRole(LedgerProjectionRole.PORTFOLIO); + portfolioProjection.setPortfolio(portfolio); + portfolioProjection.setPrimaryPosting(securityPosting); + portfolioProjection.addMembership(securityPosting.getUUID(), ProjectionMembershipRole.PRIMARY); + + entry.addPosting(cash); + entry.addPosting(securityPosting); + entry.addProjectionRef(accountProjection); + entry.addProjectionRef(portfolioProjection); + entry.setUpdatedAt(Instant.parse("2026-06-20T12:00:00Z")); + + return entry; + } + + private void assertPostingCopied(LedgerPosting original, LedgerPosting copy) + { + assertNotSame(original, copy); + assertThat(copy.getUUID(), is(original.getUUID())); + assertThat(copy.getType(), is(original.getType())); + assertThat(copy.getAmount(), is(original.getAmount())); + assertThat(copy.getCurrency(), is(original.getCurrency())); + assertThat(copy.getForexAmount(), is(original.getForexAmount())); + assertThat(copy.getForexCurrency(), is(original.getForexCurrency())); + assertThat(copy.getExchangeRate(), is(original.getExchangeRate())); + assertSame(original.getSecurity(), copy.getSecurity()); + assertThat(copy.getShares(), is(original.getShares())); + assertSame(original.getAccount(), copy.getAccount()); + assertSame(original.getPortfolio(), copy.getPortfolio()); + assertThat(copy.getParameters().size(), is(original.getParameters().size())); + + for (var index = 0; index < original.getParameters().size(); index++) + assertParameterCopied(original.getParameters().get(index), copy.getParameters().get(index)); + } + + private void assertProjectionCopied(LedgerProjectionRef original, LedgerProjectionRef copy) + { + assertNotSame(original, copy); + assertThat(copy.getUUID(), is(original.getUUID())); + assertThat(copy.getRole(), is(original.getRole())); + assertSame(original.getAccount(), copy.getAccount()); + assertSame(original.getPortfolio(), copy.getPortfolio()); + assertThat(copy.getPrimaryPostingUUID(), is(original.getPrimaryPostingUUID())); + assertThat(copy.getPostingGroupUUID(), is(original.getPostingGroupUUID())); + assertThat(copy.getMemberships().size(), is(original.getMemberships().size())); + + for (var index = 0; index < original.getMemberships().size(); index++) + assertProjectionMembershipCopied(original.getMemberships().get(index), copy.getMemberships().get(index)); + } + + private void assertProjectionMembershipCopied(ProjectionMembership original, ProjectionMembership copy) + { + assertNotSame(original, copy); + assertThat(copy.getPostingUUID(), is(original.getPostingUUID())); + assertThat(copy.getRole(), is(original.getRole())); + } + + private void assertParameterCopied(LedgerParameter original, LedgerParameter copy) + { + assertNotSame(original, copy); + assertThat(copy.getType(), is(original.getType())); + assertThat(copy.getValueKind(), is(original.getValueKind())); + assertSame(original.getValue(), copy.getValue()); + } +} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerMutationContextTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerMutationContextTest.java new file mode 100644 index 0000000000..5939fb28ee --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerMutationContextTest.java @@ -0,0 +1,982 @@ +package name.abuchen.portfolio.model.ledger; + +import static org.hamcrest.CoreMatchers.instanceOf; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.Test; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.PortfolioTransaction; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountCashLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerCashTransferLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerCreationUnits; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerDeliveryLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerOwnerPatchHelper; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioSecurityLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferSecurity; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerSecurityQuantity; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerTransactionDeleter; +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.model.ledger.projection.LedgerBackedAccountTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedPortfolioTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionService; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionSupport; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.Values; + +/** + * Tests atomic ledger mutation and projection refresh behavior. + * These tests make sure failed or repeated updates do not leave partial ledger changes or duplicate runtime rows. + */ +@SuppressWarnings("nls") +public class LedgerMutationContextTest +{ + private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 1, 2, 0, 0); + + /** + * Checks the ledger mutation scenario: owner patch removes stale projection and materializes current projection. + * Failed or repeated operations must not leave partial changes or duplicate projections. + * This protects atomic ledger mutation behavior. + */ + @Test + public void testOwnerPatchRemovesStaleProjectionAndMaterializesCurrentProjection() + { + var client = new Client(); + var source = register(client, account()); + var target = register(client, account()); + var entry = creator(client).createDeposit(metadata(), cashLeg(source, 100)).getEntry(); + var entryUUID = entry.getUUID(); + var projectionUUID = entry.getProjectionRefs().get(0).getUUID(); + var postingUUID = entry.getPostings().get(0).getUUID(); + + LedgerProjectionService.materialize(client); + + var transaction = (LedgerBackedAccountTransaction) source.getTransactions().get(0); + + new LedgerOwnerPatchHelper(client).moveAccountOnly(transaction, target); + + assertTrue(source.getTransactions().isEmpty()); + assertThat(target.getTransactions().size(), is(1)); + assertThat(target.getTransactions().get(0), instanceOf(LedgerBackedAccountTransaction.class)); + assertThat(target.getTransactions().get(0).getUUID(), is(projectionUUID)); + assertThat(((LedgerBackedTransaction) target.getTransactions().get(0)).getLedgerEntry().getUUID(), is(entryUUID)); + assertThat(entry.getPostings().get(0).getUUID(), is(postingUUID)); + assertSame(target, entry.getPostings().get(0).getAccount()); + assertSame(target, entry.getProjectionRefs().get(0).getAccount()); + } + + /** + * Checks the ledger mutation scenario: repeated refresh does not duplicate ledger backed projections. + * Failed or repeated operations must not leave partial changes or duplicate projections. + * This protects atomic ledger mutation behavior. + */ + @Test + public void testRepeatedRefreshDoesNotDuplicateLedgerBackedProjections() + { + var client = new Client(); + var account = register(client, account()); + + creator(client).createDeposit(metadata(), cashLeg(account, 100)); + + var context = new LedgerMutationContext(client); + + context.refresh(); + context.refresh(); + + assertThat(account.getTransactions().size(), is(1)); + } + + /** + * Checks the ledger mutation scenario: attach entry adds validated copy without projection refresh. + * Failed or repeated operations must not leave partial changes or duplicate projections. + * This protects atomic ledger mutation behavior. + */ + @Test + public void testAttachEntryAddsValidatedCopyWithoutProjectionRefresh() + { + var client = new Client(); + var account = register(client, account()); + var entry = targetedAccountEntry(account); + var context = new LedgerMutationContext(client); + var entryUUID = entry.getUUID(); + var projectionUUID = entry.getProjectionRefs().get(0).getUUID(); + var postingUUID = entry.getPostings().get(0).getUUID(); + + var liveEntry = context.attachEntry(entry); + + assertSame(liveEntry, client.getLedger().getEntries().get(0)); + assertNotSame(entry, liveEntry); + assertThat(liveEntry.getUUID(), is(entryUUID)); + assertThat(liveEntry.getPostings().get(0).getUUID(), is(postingUUID)); + assertThat(liveEntry.getProjectionRefs().get(0).getUUID(), is(projectionUUID)); + assertThat(liveEntry.getProjectionRefs().get(0).getPrimaryPostingUUID(), is(postingUUID)); + assertTrue(account.getTransactions().isEmpty()); + + context.refresh(); + + assertThat(account.getTransactions().size(), is(1)); + assertThat(account.getTransactions().get(0).getUUID(), is(projectionUUID)); + } + + /** + * Checks the ledger mutation scenario: attach entry validates before live mutation. + * Failed or repeated operations must not leave partial changes or duplicate projections. + * This protects atomic ledger mutation behavior. + */ + @Test + public void testAttachEntryValidatesBeforeLiveMutation() + { + var client = new Client(); + var entry = new LedgerEntry(); + + entry.setType(LedgerEntryType.DEPOSIT); + entry.setDateTime(DATE_TIME); + + assertThrows(IllegalArgumentException.class, () -> new LedgerMutationContext(client).attachEntry(entry)); + + assertTrue(client.getLedger().getEntries().isEmpty()); + } + + /** + * Checks the ledger mutation scenario: remove entry fails without partial mutation for unknown entry. + * Failed or repeated operations must not leave partial changes or duplicate projections. + * This protects atomic ledger mutation behavior. + */ + @Test + public void testRemoveEntryFailsWithoutPartialMutationForUnknownEntry() + { + var client = new Client(); + var account = register(client, account()); + var entry = creator(client).createDeposit(metadata(), cashLeg(account, 100)).getEntry(); + var projectionUUID = entry.getProjectionRefs().get(0).getUUID(); + var unknown = creator(new Client()).createDeposit(metadata(), cashLeg(account, 200)).getEntry(); + + LedgerProjectionService.materialize(client); + + assertThrows(IllegalArgumentException.class, () -> new LedgerMutationContext(client).removeEntry(unknown)); + + assertSame(entry, client.getLedger().getEntries().get(0)); + assertThat(account.getTransactions().size(), is(1)); + assertThat(account.getTransactions().get(0).getUUID(), is(projectionUUID)); + } + + /** + * Checks the ledger mutation scenario: failed context mutation does not refresh owner lists or mutate live ledger. + * Failed or repeated operations must not leave partial changes or duplicate projections. + * This protects atomic ledger mutation behavior. + */ + @Test + public void testFailedContextMutationDoesNotRefreshOwnerListsOrMutateLiveLedger() + { + var client = new Client(); + var account = register(client, account()); + var target = register(client, account()); + var entry = creator(client).createDeposit(metadata(), cashLeg(account, 100)).getEntry(); + var projectionUUID = entry.getProjectionRefs().get(0).getUUID(); + + LedgerProjectionService.materialize(client); + + var originalProjectionAccount = entry.getProjectionRefs().get(0).getAccount(); + var originalPostingAccount = entry.getPostings().get(0).getAccount(); + + var exception = assertThrows(IllegalArgumentException.class, + () -> new LedgerMutationContext(client).mutateEntry(entry, + editedEntry -> editedEntry.getProjectionRefs().get(0).setAccount(null))); + + assertTrue(exception.getMessage(), exception.getMessage().contains("[PROJECTION_REF_ACCOUNT_REQUIRED] ")); + assertTrue(exception.getMessage(), exception.getMessage().contains("\n Projection:\n")); + + assertSame(originalProjectionAccount, entry.getProjectionRefs().get(0).getAccount()); + assertSame(originalPostingAccount, entry.getPostings().get(0).getAccount()); + assertThat(account.getTransactions().size(), is(1)); + assertThat(account.getTransactions().get(0).getUUID(), is(projectionUUID)); + assertThat(entry.getProjectionRefs().size(), is(1)); + assertTrue(target.getTransactions().isEmpty()); + } + + /** + * Checks the ledger mutation scenario: failed context mutation rolls back entry parameters. + * Failed or repeated operations must not leave partial changes or duplicate projections. + * This protects atomic ledger mutation behavior. + */ + @Test + public void testFailedContextMutationRollsBackEntryParameters() + { + var client = new Client(); + var account = register(client, account()); + var entry = creator(client).createDeposit(metadata(), cashLeg(account, 100)).getEntry(); + + entry.addParameter(LedgerParameter.ofString(LedgerParameterType.EVENT_REFERENCE, + "original")); + + assertThrows(IllegalArgumentException.class, () -> new LedgerMutationContext(client).mutateEntry(entry, + editedEntry -> { + editedEntry.addParameter(LedgerParameter.ofString( + LedgerParameterType.CORPORATE_ACTION_KIND, + CorporateActionKind.SPIN_OFF.getCode())); + editedEntry.getProjectionRefs().get(0).setAccount(null); + })); + + assertThat(entry.getParameters().size(), is(1)); + assertThat(entry.getParameters().get(0).getType(), is(LedgerParameterType.EVENT_REFERENCE)); + assertThat(entry.getParameters().get(0).getValue(), is("original")); + } + + /** + * Checks the ledger mutation scenario: context mutation synchronizes entry parameters. + * Failed or repeated operations must not leave partial changes or duplicate projections. + * This protects atomic ledger mutation behavior. + */ + @Test + public void testContextMutationSynchronizesEntryParameters() + { + var client = new Client(); + var account = register(client, account()); + var entry = creator(client).createDeposit(metadata(), cashLeg(account, 100)).getEntry(); + + entry.addParameter(LedgerParameter.ofString(LedgerParameterType.EVENT_REFERENCE, + "original")); + + new LedgerMutationContext(client).mutateEntry(entry, editedEntry -> { + editedEntry.removeParameter(editedEntry.getParameters().get(0)); + editedEntry.addParameter(LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_KIND, + CorporateActionKind.SPIN_OFF.getCode())); + }); + + assertThat(entry.getParameters().size(), is(1)); + assertThat(entry.getParameters().get(0).getType(), is(LedgerParameterType.CORPORATE_ACTION_KIND)); + assertThat(entry.getParameters().get(0).getValue(), is(CorporateActionKind.SPIN_OFF.getCode())); + } + + /** + * Checks the ledger mutation scenario: mutation lambda is applied only to candidate. + * Failed or repeated operations must not leave partial changes or duplicate projections. + * This protects atomic ledger mutation behavior. + */ + @Test + public void testMutationLambdaIsAppliedOnlyToCandidate() + { + var client = new Client(); + var account = register(client, account()); + var entry = creator(client).createDeposit(metadata(), cashLeg(account, 100)).getEntry(); + var applications = new AtomicInteger(); + + new LedgerMutationContext(client).mutateEntry(entry, editedEntry -> { + applications.incrementAndGet(); + editedEntry.setNote("changed"); + }); + + assertThat(applications.get(), is(1)); + assertThat(entry.getNote(), is("changed")); + } + + /** + * Checks the ledger mutation scenario: mutation that would fail on second application still synchronizes from candidate. + * Failed or repeated operations must not leave partial changes or duplicate projections. + * This protects atomic ledger mutation behavior. + */ + @Test + public void testMutationThatWouldFailOnSecondApplicationStillSynchronizesFromCandidate() + { + var client = new Client(); + var source = register(client, account()); + var target = register(client, account()); + var entry = creator(client).createDeposit(metadata(), cashLeg(source, 100)).getEntry(); + var projectionUUID = entry.getProjectionRefs().get(0).getUUID(); + var applications = new AtomicInteger(); + + LedgerProjectionService.materialize(client); + + new LedgerMutationContext(client).mutateEntry(entry, editedEntry -> { + if (applications.incrementAndGet() > 1) + throw new AssertionError("Mutation lambda must not be applied to the live ledger"); + + editedEntry.getProjectionRefs().get(0).setAccount(target); + editedEntry.getPostings().get(0).setAccount(target); + }); + + assertThat(applications.get(), is(1)); + assertTrue(source.getTransactions().isEmpty()); + assertThat(target.getTransactions().size(), is(1)); + assertThat(target.getTransactions().get(0).getUUID(), is(projectionUUID)); + assertSame(target, entry.getProjectionRefs().get(0).getAccount()); + assertSame(target, entry.getPostings().get(0).getAccount()); + } + + /** + * Checks the ledger mutation scenario: projection membership and role changes refresh materialized projections. + * Failed or repeated operations must not leave partial changes or duplicate projections. + * This protects atomic ledger mutation behavior. + */ + @Test + public void testProjectionMembershipAndRoleChangesRefreshMaterializedProjections() + { + var client = new Client(); + var account = register(client, account()); + var entry = targetedAccountEntry(account); + var projectionUUID = entry.getProjectionRefs().get(0).getUUID(); + var context = new LedgerMutationContext(client); + + client.getLedger().addEntry(entry); + context.refresh(); + context.mutateEntry(entry, + editedEntry -> editedEntry.getProjectionRefs().get(0).setRole(LedgerProjectionRole.CASH_COMPENSATION)); + + assertThat(account.getTransactions().size(), is(1)); + assertThat(account.getTransactions().get(0).getUUID(), is(projectionUUID)); + assertThat(entry.getProjectionRefs().get(0).getRole(), is(LedgerProjectionRole.CASH_COMPENSATION)); + + context.mutateEntry(entry, editedEntry -> editedEntry.removeProjectionRef(editedEntry.getProjectionRefs().get(0))); + + assertTrue(entry.getProjectionRefs().isEmpty()); + assertTrue(account.getTransactions().isEmpty()); + } + + /** + * Checks the ledger mutation scenario: mutation context preserves entry local projection targeting during copy sync. + * Failed or repeated operations must not leave partial changes or duplicate projections. + * This protects atomic ledger mutation behavior. + */ + @Test + public void testMutationContextPreservesEntryLocalProjectionTargetingDuringCopySync() + { + var client = new Client(); + var account = register(client, account()); + var entry = targetedAccountEntry(account); + var projection = entry.getProjectionRefs().get(0); + var posting = entry.getPostings().get(0); + var projectionUUID = projection.getUUID(); + var postingUUID = posting.getUUID(); + var context = new LedgerMutationContext(client); + + client.getLedger().addEntry(entry); + context.refresh(); + + context.mutateEntry(entry, + editedEntry -> editedEntry.getPostings().get(0).setAmount(Values.Amount.factorize(200))); + + assertThat(entry.getProjectionRefs().get(0).getUUID(), is(projectionUUID)); + assertThat(entry.getProjectionRefs().get(0).getPrimaryPostingUUID(), is(postingUUID)); + assertSame(entry.getPostings().get(0), + LedgerProjectionSupport.primaryPosting(entry, entry.getProjectionRefs().get(0))); + assertThat(account.getTransactions().size(), is(1)); + assertThat(account.getTransactions().get(0).getUUID(), is(projectionUUID)); + } + + private LedgerEntry targetedAccountEntry(Account account) + { + var entry = new LedgerEntry(); + var posting = new LedgerPosting(); + var projection = new LedgerProjectionRef(); + + entry.setType(LedgerEntryType.SPIN_OFF); + entry.setDateTime(DATE_TIME); + posting.setType(LedgerPostingType.CASH_COMPENSATION); + posting.setAccount(account); + posting.setAmount(Values.Amount.factorize(100)); + posting.setCurrency(CurrencyUnit.EUR); + projection.setRole(LedgerProjectionRole.ACCOUNT); + projection.setAccount(account); + projection.setPrimaryPosting(posting); + entry.addPosting(posting); + entry.addProjectionRef(projection); + + return entry; + } + + /** + * Checks the ledger mutation scenario: unrelated legacy and ledger backed transactions remain untouched. + * Failed or repeated operations must not leave partial changes or duplicate projections. + * This protects atomic ledger mutation behavior. + */ + @Test + public void testUnrelatedLegacyAndLedgerBackedTransactionsRemainUntouched() + { + var client = new Client(); + var movedSource = register(client, account()); + var movedTarget = register(client, account()); + var unrelated = register(client, account()); + var legacy = legacyDeposit(); + + unrelated.getTransactions().add(legacy); + creator(client).createDeposit(metadata(), cashLeg(movedSource, 100)); + var unrelatedEntry = creator(client).createDeposit(metadata(), cashLeg(unrelated, 200)).getEntry(); + var unrelatedProjectionUUID = unrelatedEntry.getProjectionRefs().get(0).getUUID(); + + LedgerProjectionService.materialize(client); + + var movedTransaction = (LedgerBackedAccountTransaction) movedSource.getTransactions().get(0); + var unrelatedLedgerTransaction = unrelated.getTransactions().stream() + .filter(transaction -> transaction instanceof LedgerBackedTransaction).findFirst().orElseThrow(); + + new LedgerOwnerPatchHelper(client).moveAccountOnly(movedTransaction, movedTarget); + + assertSame(legacy, unrelated.getTransactions().get(0)); + assertTrue(unrelated.getTransactions().contains(unrelatedLedgerTransaction)); + assertThat(unrelatedLedgerTransaction.getUUID(), is(unrelatedProjectionUUID)); + } + + /** + * Checks the ledger mutation scenario: deleting buy/sell projection deletes whole entry and both materialized projections. + * Failed or repeated operations must not leave partial changes or duplicate projections. + * This protects atomic ledger mutation behavior. + */ + @Test + public void testDeletingBuySellProjectionDeletesWholeEntryAndBothMaterializedProjections() + { + var client = new Client(); + var account = register(client, account()); + var portfolio = register(client, portfolio()); + var entry = creator(client).createBuy(metadata(), cashLeg(account, 100), portfolioLeg(portfolio, 100), + LedgerCreationUnits.none()).getEntry(); + + LedgerProjectionService.materialize(client); + + new LedgerTransactionDeleter(client).delete((LedgerBackedTransaction) account.getTransactions().get(0)); + + assertTrue(client.getLedger().getEntries().stream().noneMatch(item -> item.getUUID().equals(entry.getUUID()))); + assertTrue(account.getTransactions().isEmpty()); + assertTrue(portfolio.getTransactions().isEmpty()); + } + + /** + * Checks the ledger mutation scenario: deleting transfer and delivery entries removes their materialized projections. + * Failed or repeated operations must not leave partial changes or duplicate projections. + * This protects atomic ledger mutation behavior. + */ + @Test + public void testDeletingTransferAndDeliveryEntriesRemovesTheirMaterializedProjections() + { + assertDeleteRemovesMaterializedAccountTransfer(); + assertDeleteRemovesMaterializedPortfolioTransfer(); + assertDeleteRemovesMaterializedDelivery(); + } + + /** + * Checks the ledger mutation scenario: delete does not delete legacy transactions. + * Failed or repeated operations must not leave partial changes or duplicate projections. + * This protects atomic ledger mutation behavior. + */ + @Test + public void testDeleteDoesNotDeleteLegacyTransactions() + { + var client = new Client(); + var account = register(client, account()); + var legacy = legacyDeposit(); + + account.getTransactions().add(legacy); + creator(client).createDeposit(metadata(), cashLeg(account, 100)); + LedgerProjectionService.materialize(client); + + var ledgerTransaction = account.getTransactions().stream() // + .filter(transaction -> transaction instanceof LedgerBackedTransaction) // + .map(LedgerBackedTransaction.class::cast) // + .findFirst().orElseThrow(); + + new LedgerTransactionDeleter(client).delete(ledgerTransaction); + + assertThat(account.getTransactions().size(), is(1)); + assertSame(legacy, account.getTransactions().get(0)); + } + + /** + * Checks the ledger mutation scenario: delete leaves unrelated ledger entry projection and legacy transaction untouched. + * Failed or repeated operations must not leave partial changes or duplicate projections. + * This protects atomic ledger mutation behavior. + */ + @Test + public void testDeleteLeavesUnrelatedLedgerEntryProjectionAndLegacyTransactionUntouched() + { + var client = new Client(); + var account = register(client, account()); + var legacy = legacyDeposit(); + + account.getTransactions().add(legacy); + + var deletedEntry = creator(client).createDeposit(metadata(), cashLeg(account, 100)).getEntry(); + var survivingEntry = creator(client).createDeposit(metadata(), cashLeg(account, 200)).getEntry(); + var survivingEntryUUID = survivingEntry.getUUID(); + var survivingProjectionUUID = survivingEntry.getProjectionRefs().get(0).getUUID(); + + LedgerProjectionService.materialize(client); + + var deletedTransaction = account.getTransactions().stream() // + .filter(transaction -> transaction instanceof LedgerBackedTransaction + && transaction.getUUID().equals(deletedEntry.getProjectionRefs().get(0).getUUID())) + .map(LedgerBackedTransaction.class::cast) // + .findFirst().orElseThrow(); + + new LedgerTransactionDeleter(client).delete(deletedTransaction); + + assertTrue(client.getLedger().getEntries().stream() + .noneMatch(entry -> entry.getUUID().equals(deletedEntry.getUUID()))); + assertTrue(client.getLedger().getEntries().stream() + .anyMatch(entry -> entry.getUUID().equals(survivingEntryUUID))); + assertSame(legacy, account.getTransactions().get(0)); + assertTrue(account.getTransactions().stream().anyMatch(transaction -> transaction instanceof LedgerBackedTransaction + && transaction.getUUID().equals(survivingProjectionUUID))); + } + + /** + * Checks the ledger mutation scenario: delivery owner patch updates posting and projection portfolio. + * Failed or repeated operations must not leave partial changes or duplicate projections. + * This protects atomic ledger mutation behavior. + */ + @Test + public void testDeliveryOwnerPatchUpdatesPostingAndProjectionPortfolio() + { + var client = new Client(); + var source = register(client, portfolio()); + var target = register(client, portfolio()); + var entry = creator(client).createInboundDelivery(metadata(), LedgerDeliveryLeg.of(source, + LedgerSecurityQuantity.of(security(), Values.Share.factorize(5)), money(100))).getEntry(); + var projectionUUID = entry.getProjectionRefs().get(0).getUUID(); + var postingUUID = entry.getPostings().get(0).getUUID(); + + LedgerProjectionService.materialize(client); + + new LedgerOwnerPatchHelper(client).moveDelivery((LedgerBackedPortfolioTransaction) source.getTransactions().get(0), + target); + + assertTrue(source.getTransactions().isEmpty()); + assertThat(target.getTransactions().get(0).getUUID(), is(projectionUUID)); + assertThat(entry.getPostings().get(0).getUUID(), is(postingUUID)); + assertSame(target, entry.getPostings().get(0).getPortfolio()); + assertSame(target, entry.getProjectionRefs().get(0).getPortfolio()); + } + + /** + * Checks the ledger mutation scenario: buy/sell owner patch preserves cross entry read compatibility. + * Failed or repeated operations must not leave partial changes or duplicate projections. + * This protects atomic ledger mutation behavior. + */ + @Test + public void testBuySellOwnerPatchPreservesCrossEntryReadCompatibility() + { + var client = new Client(); + var account = register(client, account()); + var targetAccount = register(client, account()); + var portfolio = register(client, portfolio()); + var targetPortfolio = register(client, portfolio()); + var entry = creator(client).createBuy(metadata(), cashLeg(account, 100), portfolioLeg(portfolio, 100), + LedgerCreationUnits.none()).getEntry(); + var accountProjection = projection(entry, LedgerProjectionRole.ACCOUNT); + var portfolioProjection = projection(entry, LedgerProjectionRole.PORTFOLIO); + var accountProjectionUUID = accountProjection.getUUID(); + var portfolioProjectionUUID = portfolioProjection.getUUID(); + var cashPostingUUID = LedgerProjectionSupport.primaryPosting(entry, accountProjection).getUUID(); + var securityPostingUUID = LedgerProjectionSupport.primaryPosting(entry, portfolioProjection).getUUID(); + + LedgerProjectionService.materialize(client); + + var helper = new LedgerOwnerPatchHelper(client); + + helper.moveBuySellAccountSide(entry, targetAccount); + + assertSame(targetAccount, projection(entry, LedgerProjectionRole.ACCOUNT).getAccount()); + assertSame(portfolio, projection(entry, LedgerProjectionRole.PORTFOLIO).getPortfolio()); + assertSame(targetAccount, posting(entry, cashPostingUUID).getAccount()); + assertSame(portfolio, posting(entry, securityPostingUUID).getPortfolio()); + + helper.moveBuySellPortfolioSide(entry, targetPortfolio); + + var accountTransaction = targetAccount.getTransactions().get(0); + var portfolioTransaction = targetPortfolio.getTransactions().get(0); + + assertThat(accountTransaction.getUUID(), is(accountProjectionUUID)); + assertThat(portfolioTransaction.getUUID(), is(portfolioProjectionUUID)); + assertSame(targetAccount, projection(entry, LedgerProjectionRole.ACCOUNT).getAccount()); + assertSame(targetPortfolio, projection(entry, LedgerProjectionRole.PORTFOLIO).getPortfolio()); + assertSame(targetAccount, posting(entry, cashPostingUUID).getAccount()); + assertSame(targetPortfolio, posting(entry, securityPostingUUID).getPortfolio()); + assertSame(targetPortfolio, accountTransaction.getCrossEntry().getCrossOwner(accountTransaction)); + assertSame(portfolioTransaction, accountTransaction.getCrossEntry().getCrossTransaction(accountTransaction)); + } + + /** + * Checks the ledger mutation scenario: transfer owner patches do not swap direction. + * Failed or repeated operations must not leave partial changes or duplicate projections. + * This protects atomic ledger mutation behavior. + */ + @Test + public void testTransferOwnerPatchesDoNotSwapDirection() + { + var client = new Client(); + var source = register(client, account()); + var target = register(client, account()); + var newSource = register(client, account()); + var newTarget = register(client, account()); + var entry = creator(client).createAccountTransfer(metadata(), LedgerCashTransferLeg.of(source, money(100)), + LedgerCashTransferLeg.of(target, money(100))).getEntry(); + var sourceProjection = projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT); + var targetProjection = projection(entry, LedgerProjectionRole.TARGET_ACCOUNT); + var sourcePostingUUID = LedgerProjectionSupport.primaryPosting(entry, sourceProjection).getUUID(); + var targetPostingUUID = LedgerProjectionSupport.primaryPosting(entry, targetProjection).getUUID(); + + LedgerProjectionService.materialize(client); + + var helper = new LedgerOwnerPatchHelper(client); + + helper.moveAccountTransferSource(entry, newSource); + helper.moveAccountTransferTarget(entry, newTarget); + + assertThat(newSource.getTransactions().get(0).getType(), is(AccountTransaction.Type.TRANSFER_OUT)); + assertThat(newTarget.getTransactions().get(0).getType(), is(AccountTransaction.Type.TRANSFER_IN)); + assertSame(newSource, projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT).getAccount()); + assertSame(newTarget, projection(entry, LedgerProjectionRole.TARGET_ACCOUNT).getAccount()); + assertSame(newSource, posting(entry, sourcePostingUUID).getAccount()); + assertSame(newTarget, posting(entry, targetPostingUUID).getAccount()); + } + + /** + * Checks the ledger mutation scenario: portfolio transfer owner patches do not swap direction. + * Failed or repeated operations must not leave partial changes or duplicate projections. + * This protects atomic ledger mutation behavior. + */ + @Test + public void testPortfolioTransferOwnerPatchesDoNotSwapDirection() + { + var client = new Client(); + var source = register(client, portfolio()); + var target = register(client, portfolio()); + var newSource = register(client, portfolio()); + var newTarget = register(client, portfolio()); + var entry = creator(client).createPortfolioTransfer(metadata(), + LedgerPortfolioTransferSecurity.of(security(), Values.Share.factorize(5)), + LedgerPortfolioTransferLeg.of(source, money(100)), + LedgerPortfolioTransferLeg.of(target, money(100))).getEntry(); + var sourceProjection = projection(entry, LedgerProjectionRole.SOURCE_PORTFOLIO); + var targetProjection = projection(entry, LedgerProjectionRole.TARGET_PORTFOLIO); + var sourcePostingUUID = LedgerProjectionSupport.primaryPosting(entry, sourceProjection).getUUID(); + var targetPostingUUID = LedgerProjectionSupport.primaryPosting(entry, targetProjection).getUUID(); + + LedgerProjectionService.materialize(client); + + var helper = new LedgerOwnerPatchHelper(client); + + helper.movePortfolioTransferSource(entry, newSource); + helper.movePortfolioTransferTarget(entry, newTarget); + + assertThat(newSource.getTransactions().get(0).getType(), is(PortfolioTransaction.Type.TRANSFER_OUT)); + assertThat(newTarget.getTransactions().get(0).getType(), is(PortfolioTransaction.Type.TRANSFER_IN)); + assertSame(newSource, projection(entry, LedgerProjectionRole.SOURCE_PORTFOLIO).getPortfolio()); + assertSame(newTarget, projection(entry, LedgerProjectionRole.TARGET_PORTFOLIO).getPortfolio()); + assertSame(newSource, posting(entry, sourcePostingUUID).getPortfolio()); + assertSame(newTarget, posting(entry, targetPostingUUID).getPortfolio()); + } + + /** + * Checks the ledger mutation scenario: unsupported owner patch fails without partial mutation. + * Failed or repeated operations must not leave partial changes or duplicate projections. + * This protects atomic ledger mutation behavior. + */ + @Test + public void testUnsupportedOwnerPatchFailsWithoutPartialMutation() + { + var client = new Client(); + var account = register(client, account()); + var portfolio = register(client, portfolio()); + var entry = creator(client).createBuy(metadata(), cashLeg(account, 100), portfolioLeg(portfolio, 100), + LedgerCreationUnits.none()).getEntry(); + var duplicate = new LedgerProjectionRef(); + + duplicate.setRole(LedgerProjectionRole.ACCOUNT); + duplicate.setAccount(account); + entry.addProjectionRef(duplicate); + + var exception = assertThrows(IllegalArgumentException.class, + () -> new LedgerOwnerPatchHelper(client).moveBuySellAccountSide(entry, register(client, account()))); + assertThat(exception.getMessage(), is(LedgerDiagnosticCode.LEDGER_PROJ_042 + .message("Expected one projection for role ACCOUNT but found 2"))); + + assertSame(account, projection(entry, LedgerProjectionRole.ACCOUNT).getAccount()); + assertSame(account, entry.getPostings().get(0).getAccount()); + } + + /** + * Checks the ledger mutation scenario: same shape replacement preserves entry and projection uuids. + * Failed or repeated operations must not leave partial changes or duplicate projections. + * This protects atomic ledger mutation behavior. + */ + @Test + public void testSameShapeReplacementPreservesEntryAndProjectionUUIDs() + { + var client = new Client(); + var account = register(client, account()); + var entry = creator(client).createDeposit(metadata(), cashLeg(account, 100)).getEntry(); + var entryUUID = entry.getUUID(); + var projectionUUID = entry.getProjectionRefs().get(0).getUUID(); + var originalPostingUUID = entry.getPostings().get(0).getUUID(); + var replacement = creator(new Client()).createDeposit(metadata(), cashLeg(account, 200)).getEntry(); + var replacementPostingUUID = replacement.getPostings().get(0).getUUID(); + + assertTrue(!originalPostingUUID.equals(replacementPostingUUID)); + + LedgerProjectionService.materialize(client); + + new LedgerMutationContext(client).replaceSameShapeEntry(entry, replacement); + + var replacedEntry = client.getLedger().getEntries().get(0); + + assertThat(replacedEntry.getUUID(), is(entryUUID)); + assertThat(replacedEntry.getProjectionRefs().get(0).getUUID(), is(projectionUUID)); + assertThat(replacedEntry.getPostings().get(0).getUUID(), is(replacementPostingUUID)); + assertThat(account.getTransactions().size(), is(1)); + assertThat(account.getTransactions().get(0).getAmount(), is(Values.Amount.factorize(200))); + } + + /** + * Checks the ledger mutation scenario: entry replacement fails when projection identity is ambiguous. + * Failed or repeated operations must not leave partial changes or duplicate projections. + * This protects atomic ledger mutation behavior. + */ + @Test + public void testEntryReplacementFailsWhenProjectionIdentityIsAmbiguous() + { + var client = new Client(); + var account = register(client, account()); + var entry = creator(client).createDeposit(metadata(), cashLeg(account, 100)).getEntry(); + var replacement = creator(new Client()).createDeposit(metadata(), cashLeg(account, 200)).getEntry(); + var duplicate = new LedgerProjectionRef(); + + duplicate.setRole(LedgerProjectionRole.ACCOUNT); + duplicate.setAccount(account); + replacement.addProjectionRef(duplicate); + + var exception = assertThrows(IllegalArgumentException.class, + () -> new LedgerMutationContext(client).replaceSameShapeEntry(entry, replacement)); + + assertTrue(exception.getMessage(), exception.getMessage().contains(LedgerDiagnosticCode.LEDGER_PROJ_003.prefix())); + assertSame(entry, client.getLedger().getEntries().get(0)); + assertThat(entry.getPostings().get(0).getAmount(), is(Values.Amount.factorize(100))); + } + + /** + * Checks the ledger mutation scenario: entry split requires replacement entries. + * Failed or repeated operations must not leave partial changes or duplicate projections. + * This protects atomic ledger mutation behavior. + */ + @Test + public void testEntrySplitRequiresReplacementEntries() + { + var client = new Client(); + var account = register(client, account()); + var entry = creator(client).createDeposit(metadata(), cashLeg(account, 100)).getEntry(); + var projectionUUID = entry.getProjectionRefs().get(0).getUUID(); + + LedgerProjectionService.materialize(client); + + var exception = assertThrows(IllegalArgumentException.class, + () -> new LedgerMutationContext(client).splitEntry(entry, List.of())); + + assertTrue(exception.getMessage(), exception.getMessage().contains(LedgerDiagnosticCode.LEDGER_CORE_007.prefix())); + assertThat(client.getLedger().getEntries().size(), is(1)); + assertSame(entry, client.getLedger().getEntries().get(0)); + assertThat(account.getTransactions().size(), is(1)); + assertThat(account.getTransactions().get(0).getUUID(), is(projectionUUID)); + } + + /** + * Checks the ledger mutation scenario: direct owner list removal can be restored by explicit refresh. + * Failed or repeated operations must not leave partial changes or duplicate projections. + * This protects atomic ledger mutation behavior. + */ + @Test + public void testDirectOwnerListRemovalCanBeRestoredByExplicitRefresh() + { + var client = new Client(); + var account = register(client, account()); + + creator(client).createDeposit(metadata(), cashLeg(account, 100)); + + var context = new LedgerMutationContext(client); + + context.refresh(); + account.getTransactions().remove(0); + + assertTrue(account.getTransactions().isEmpty()); + + context.refresh(); + + assertThat(account.getTransactions().size(), is(1)); + assertThat(account.getTransactions().get(0), instanceOf(LedgerBackedAccountTransaction.class)); + } + + /** + * Checks the ledger mutation scenario: cross entry write methods still reject mutation except replay noop. + * Failed or repeated operations must not leave partial changes or duplicate projections. + * This protects atomic ledger mutation behavior. + */ + @Test + public void testCrossEntryWriteMethodsStillRejectMutationExceptReplayNoop() + { + var client = new Client(); + var account = register(client, account()); + var portfolio = register(client, portfolio()); + + creator(client).createBuy(metadata(), cashLeg(account, 100), portfolioLeg(portfolio, 100), + LedgerCreationUnits.none()); + LedgerProjectionService.materialize(client); + + var transaction = account.getTransactions().get(0); + + assertThrows(UnsupportedOperationException.class, () -> transaction.getCrossEntry().insert()); + transaction.getCrossEntry().updateFrom(transaction); + assertThat(account.getTransactions(), is(List.of(transaction))); + assertThat(portfolio.getTransactions().size(), is(1)); + assertThrows(UnsupportedOperationException.class, () -> transaction.getCrossEntry().setOwner(transaction, account)); + assertThrows(UnsupportedOperationException.class, () -> transaction.getCrossEntry().setSource("new source")); + } + + private void assertDeleteRemovesMaterializedAccountTransfer() + { + var client = new Client(); + var source = register(client, account()); + var target = register(client, account()); + var entry = creator(client).createAccountTransfer(metadata(), LedgerCashTransferLeg.of(source, money(100)), + LedgerCashTransferLeg.of(target, money(100))).getEntry(); + + LedgerProjectionService.materialize(client); + + new LedgerTransactionDeleter(client).delete((LedgerBackedTransaction) source.getTransactions().get(0)); + + assertTrue(client.getLedger().getEntries().stream().noneMatch(item -> item.getUUID().equals(entry.getUUID()))); + assertTrue(source.getTransactions().isEmpty()); + assertTrue(target.getTransactions().isEmpty()); + } + + private void assertDeleteRemovesMaterializedPortfolioTransfer() + { + var client = new Client(); + var source = register(client, portfolio()); + var target = register(client, portfolio()); + var entry = creator(client).createPortfolioTransfer(metadata(), + LedgerPortfolioTransferSecurity.of(security(), Values.Share.factorize(5)), + LedgerPortfolioTransferLeg.of(source, money(100)), + LedgerPortfolioTransferLeg.of(target, money(100))).getEntry(); + + LedgerProjectionService.materialize(client); + + new LedgerTransactionDeleter(client).delete((LedgerBackedTransaction) source.getTransactions().get(0)); + + assertTrue(client.getLedger().getEntries().stream().noneMatch(item -> item.getUUID().equals(entry.getUUID()))); + assertTrue(source.getTransactions().isEmpty()); + assertTrue(target.getTransactions().isEmpty()); + } + + private void assertDeleteRemovesMaterializedDelivery() + { + var client = new Client(); + var portfolio = register(client, portfolio()); + var entry = creator(client).createInboundDelivery(metadata(), LedgerDeliveryLeg.of(portfolio, + LedgerSecurityQuantity.of(security(), Values.Share.factorize(5)), money(100))).getEntry(); + + LedgerProjectionService.materialize(client); + + new LedgerTransactionDeleter(client).delete((LedgerBackedTransaction) portfolio.getTransactions().get(0)); + + assertTrue(client.getLedger().getEntries().stream().noneMatch(item -> item.getUUID().equals(entry.getUUID()))); + assertTrue(portfolio.getTransactions().isEmpty()); + } + + private LedgerTransactionCreator creator(Client client) + { + return new LedgerTransactionCreator(client); + } + + private LedgerTransactionMetadata metadata() + { + return LedgerTransactionMetadata.of(DATE_TIME).withNote("note").withSource("source"); + } + + private Account register(Client client, Account account) + { + client.addAccount(account); + return account; + } + + private Portfolio register(Client client, Portfolio portfolio) + { + client.addPortfolio(portfolio); + return portfolio; + } + + private Account account() + { + var account = new Account(); + + account.setCurrencyCode(CurrencyUnit.EUR); + + return account; + } + + private Portfolio portfolio() + { + return new Portfolio(); + } + + private name.abuchen.portfolio.model.Security security() + { + return new name.abuchen.portfolio.model.Security("Security", CurrencyUnit.EUR); + } + + private LedgerAccountCashLeg cashLeg(Account account, int amount) + { + return LedgerAccountCashLeg.of(account, money(amount)); + } + + private LedgerPortfolioSecurityLeg portfolioLeg(Portfolio portfolio, int amount) + { + return LedgerPortfolioSecurityLeg.of(portfolio, + LedgerSecurityQuantity.of(security(), Values.Share.factorize(5)), money(amount)); + } + + private Money money(int amount) + { + return Money.of(CurrencyUnit.EUR, Values.Amount.factorize(amount)); + } + + private AccountTransaction legacyDeposit() + { + var transaction = new AccountTransaction(); + + transaction.setType(AccountTransaction.Type.DEPOSIT); + transaction.setDateTime(DATE_TIME); + transaction.setCurrencyCode(CurrencyUnit.EUR); + transaction.setAmount(Values.Amount.factorize(1)); + + return transaction; + } + + private LedgerProjectionRef projection(LedgerEntry entry, LedgerProjectionRole role) + { + return entry.getProjectionRefs().stream().filter(projection -> projection.getRole() == role).findFirst() + .orElseThrow(); + } + + private LedgerPosting posting(LedgerEntry entry, String uuid) + { + return entry.getPostings().stream().filter(posting -> posting.getUUID().equals(uuid)).findFirst() + .orElseThrow(); + } +} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferToDepositRemovalConverterTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferToDepositRemovalConverterTest.java new file mode 100644 index 0000000000..d78e27871f --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferToDepositRemovalConverterTest.java @@ -0,0 +1,678 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import static org.hamcrest.CoreMatchers.instanceOf; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.List; + +import org.junit.Test; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.AccountTransferEntry; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.ClientFactory; +import name.abuchen.portfolio.model.InvestmentPlan; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.ProtobufTestUtilities; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.Transaction; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.Values; + +/** + * Tests the ledger-aware conversion from an account transfer to separate deposit and removal transactions. + * These tests make sure generated bookings stay traceable and stale transfer references are not left behind. + */ +@SuppressWarnings("nls") +public class LedgerAccountTransferToDepositRemovalConverterTest +{ + private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 6, 16, 10, 11); + private static final BigDecimal EXCHANGE_RATE = new BigDecimal("0.5000"); + + /** + * Verifies that an account transfer can be split into one removal and one deposit. + * The source side must remain the removal and the target side must remain the deposit after save/load. + * This protects the split from leaving duplicate transfer truth behind. + */ + @Test + public void testSplitsSameCurrencyTransferPreservingIdentityAndTruth() throws Exception + { + var fixture = fixture(CurrencyUnit.EUR, CurrencyUnit.EUR); + var transfer = createTransfer(fixture, Values.Amount.factorize(123), CurrencyUnit.EUR, + Values.Amount.factorize(123), CurrencyUnit.EUR, null, null); + var entry = fixture.client().getLedger().getEntries().get(0); + var transferEntryUUID = entry.getUUID(); + var sourcePostingUUID = posting(entry, fixture.source()).getUUID(); + var targetPostingUUID = posting(entry, fixture.target()).getUUID(); + var sourceProjectionUUID = projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT).getUUID(); + var targetProjectionUUID = projection(entry, LedgerProjectionRole.TARGET_ACCOUNT).getUUID(); + + var result = converter(fixture).split(transfer); + + assertSplit(fixture, transferEntryUUID, sourcePostingUUID, targetPostingUUID, sourceProjectionUUID, + targetProjectionUUID, Values.Amount.factorize(123), CurrencyUnit.EUR, + Values.Amount.factorize(123), CurrencyUnit.EUR); + assertThat(result.removal().getUUID(), is(sourceProjectionUUID)); + assertThat(result.deposit().getUUID(), is(targetProjectionUUID)); + assertThat(posting(removalEntry(fixture.client()), fixture.source()).getExchangeRate(), is(nullValue())); + assertThat(posting(depositEntry(fixture.client()), fixture.target()).getExchangeRate(), is(nullValue())); + + assertRoundtrip(loadXml(saveXml(fixture.client())), transferEntryUUID, sourcePostingUUID, targetPostingUUID, + sourceProjectionUUID, targetProjectionUUID, CurrencyUnit.EUR, CurrencyUnit.EUR, null); + assertRoundtrip(loadProtobuf(saveProtobuf(fixture.client())), transferEntryUUID, sourcePostingUUID, + targetPostingUUID, sourceProjectionUUID, targetProjectionUUID, CurrencyUnit.EUR, + CurrencyUnit.EUR, null); + } + + /** + * Verifies that a cross-currency transfer split keeps the source-side forex facts explicit. + * When no better rate exists, the removal side uses the default exchange rate one and survives save/load. + */ + @Test + public void testSplitsCrossCurrencyTransferWithFallbackExchangeRateOne() throws Exception + { + var fixture = fixture(CurrencyUnit.EUR, CurrencyUnit.USD); + var transfer = createTransfer(fixture, Values.Amount.factorize(100), CurrencyUnit.EUR, + Values.Amount.factorize(200), CurrencyUnit.USD, null, null); + var entry = fixture.client().getLedger().getEntries().get(0); + var transferEntryUUID = entry.getUUID(); + var sourcePostingUUID = posting(entry, fixture.source()).getUUID(); + var targetPostingUUID = posting(entry, fixture.target()).getUUID(); + var sourceProjectionUUID = projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT).getUUID(); + var targetProjectionUUID = projection(entry, LedgerProjectionRole.TARGET_ACCOUNT).getUUID(); + + converter(fixture).split(transfer); + + assertSplit(fixture, transferEntryUUID, sourcePostingUUID, targetPostingUUID, sourceProjectionUUID, + targetProjectionUUID, Values.Amount.factorize(100), CurrencyUnit.EUR, + Values.Amount.factorize(200), CurrencyUnit.USD); + + var removalPosting = posting(removalEntry(fixture.client()), fixture.source()); + + assertThat(removalPosting.getForexAmount(), is(Values.Amount.factorize(200))); + assertThat(removalPosting.getForexCurrency(), is(CurrencyUnit.USD)); + assertThat(removalPosting.getExchangeRate(), is(BigDecimal.ONE)); + assertThat(posting(depositEntry(fixture.client()), fixture.target()).getExchangeRate(), is(nullValue())); + + assertRoundtrip(loadXml(saveXml(fixture.client())), transferEntryUUID, sourcePostingUUID, targetPostingUUID, + sourceProjectionUUID, targetProjectionUUID, CurrencyUnit.EUR, CurrencyUnit.USD, + BigDecimal.ONE); + assertRoundtrip(loadProtobuf(saveProtobuf(fixture.client())), transferEntryUUID, sourcePostingUUID, + targetPostingUUID, sourceProjectionUUID, targetProjectionUUID, CurrencyUnit.EUR, + CurrencyUnit.USD, BigDecimal.ONE); + } + + /** + * Verifies that an existing valid forex amount is preserved when a transfer is split. + * The converter must not replace known user facts with the default exchange rate. + */ + @Test + public void testPreservesExistingValidForexWhenSplittingCrossCurrencyTransfer() + { + var fixture = fixture(CurrencyUnit.EUR, CurrencyUnit.USD); + var transfer = createTransfer(fixture, Values.Amount.factorize(100), CurrencyUnit.EUR, + Values.Amount.factorize(200), CurrencyUnit.USD, + Money.of(CurrencyUnit.USD, Values.Amount.factorize(200)), EXCHANGE_RATE); + + converter(fixture).split(transfer); + + var removalPosting = posting(removalEntry(fixture.client()), fixture.source()); + + assertThat(removalPosting.getForexAmount(), is(Values.Amount.factorize(200))); + assertThat(removalPosting.getForexCurrency(), is(CurrencyUnit.USD)); + assertThat(removalPosting.getExchangeRate(), is(EXCHANGE_RATE)); + } + + /** + * Verifies that transfers with extra unit postings are not split automatically. + * Without a defined split policy for those facts, the converter must reject before changing the ledger. + */ + @Test + public void testUnitBearingTransferRejectsBeforeMutation() + { + var fixture = fixture(CurrencyUnit.EUR, CurrencyUnit.EUR); + var transfer = createTransfer(fixture, Values.Amount.factorize(123), CurrencyUnit.EUR, + Values.Amount.factorize(123), CurrencyUnit.EUR, null, null); + var entry = fixture.client().getLedger().getEntries().get(0); + var fee = new LedgerPosting(); + + fee.setType(LedgerPostingType.FEE); + fee.setAmount(Values.Amount.factorize(1)); + fee.setCurrency(CurrencyUnit.EUR); + entry.addPosting(fee); + + assertRejectsWithoutMutation(fixture, () -> converter(fixture).split(transfer), + UnsupportedOperationException.class); + } + + /** + * Verifies that a cash transfer carrying security facts is not split into deposit and removal. + * The converter must reject before mutation because the security side cannot be inferred safely. + */ + @Test + public void testSecurityBearingTransferRejectsBeforeMutation() + { + var fixture = fixture(CurrencyUnit.EUR, CurrencyUnit.EUR); + var transfer = createTransfer(fixture, Values.Amount.factorize(123), CurrencyUnit.EUR, + Values.Amount.factorize(123), CurrencyUnit.EUR, null, null); + var entry = fixture.client().getLedger().getEntries().get(0); + var security = new Security("Security", CurrencyUnit.EUR); + + fixture.client().addSecurity(security); + posting(entry, fixture.source()).setSecurity(security); + posting(entry, fixture.source()).setShares(Values.Share.factorize(1)); + + assertRejectsWithoutMutation(fixture, () -> converter(fixture).split(transfer), + UnsupportedOperationException.class); + } + + /** + * Verifies that a malformed transfer shape is rejected before the split starts. + * The original ledger entry and owner lists must stay unchanged when a required projection is missing. + */ + @Test + public void testMalformedTransferRejectsBeforeMutation() + { + var fixture = fixture(CurrencyUnit.EUR, CurrencyUnit.EUR); + var transfer = createTransfer(fixture, Values.Amount.factorize(123), CurrencyUnit.EUR, + Values.Amount.factorize(123), CurrencyUnit.EUR, null, null); + var entry = fixture.client().getLedger().getEntries().get(0); + + entry.removeProjectionRef(projection(entry, LedgerProjectionRole.TARGET_ACCOUNT)); + + assertRejectsWithoutMutation(fixture, () -> converter(fixture).split(transfer), IllegalArgumentException.class); + } + + /** + * Verifies that a plan-generated transfer referenced on the source side continues as a removal. + * The old cash transfer must no longer be referenced, and the execution ref must resolve after save/load. + */ + @Test + public void testInvestmentPlanSourceExecutionRefMigratesToRemovalAfterTransferSplit() throws Exception + { + var fixture = fixture(CurrencyUnit.EUR, CurrencyUnit.EUR); + var transfer = createTransfer(fixture, Values.Amount.factorize(123), CurrencyUnit.EUR, + Values.Amount.factorize(123), CurrencyUnit.EUR, null, null); + var entry = fixture.client().getLedger().getEntries().get(0); + var sourceProjectionUUID = projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT).getUUID(); + var targetProjectionUUID = projection(entry, LedgerProjectionRole.TARGET_ACCOUNT).getUUID(); + var plan = planWithExecutionRef(fixture.client(), (LedgerBackedTransaction) transfer.getSourceTransaction()); + + converter(fixture).split(transfer); + + var removalEntry = removalEntry(fixture.client()); + + assertExecutionRef(plan, 0, removalEntry.getUUID(), sourceProjectionUUID, LedgerProjectionRole.ACCOUNT); + assertResolvedPlanTransaction(fixture.client(), plan, 0, AccountTransaction.Type.REMOVAL, + sourceProjectionUUID); + assertNoExecutionRefTargetsCashTransfer(fixture.client(), plan); + + assertExecutionRefResolvesAfterRoundtrip(loadXml(saveXml(fixture.client())), 0, removalEntry.getUUID(), + sourceProjectionUUID, AccountTransaction.Type.REMOVAL); + assertExecutionRefResolvesAfterRoundtrip(loadProtobuf(saveProtobuf(fixture.client())), 0, + removalEntry.getUUID(), sourceProjectionUUID, AccountTransaction.Type.REMOVAL); + assertThat(projection(depositEntry(fixture.client()), LedgerProjectionRole.ACCOUNT).getUUID(), + is(targetProjectionUUID)); + } + + /** + * Verifies that a plan-generated transfer referenced on the target side continues as a deposit. + * The execution ref must point to the new deposit booking and resolve after save/load. + */ + @Test + public void testInvestmentPlanTargetExecutionRefMigratesToDepositAfterTransferSplit() throws Exception + { + var fixture = fixture(CurrencyUnit.EUR, CurrencyUnit.EUR); + var transfer = createTransfer(fixture, Values.Amount.factorize(123), CurrencyUnit.EUR, + Values.Amount.factorize(123), CurrencyUnit.EUR, null, null); + var entry = fixture.client().getLedger().getEntries().get(0); + var targetProjectionUUID = projection(entry, LedgerProjectionRole.TARGET_ACCOUNT).getUUID(); + var plan = planWithExecutionRef(fixture.client(), (LedgerBackedTransaction) transfer.getTargetTransaction()); + + converter(fixture).split(transfer); + + var depositEntry = depositEntry(fixture.client()); + + assertExecutionRef(plan, 0, depositEntry.getUUID(), targetProjectionUUID, LedgerProjectionRole.ACCOUNT); + assertResolvedPlanTransaction(fixture.client(), plan, 0, AccountTransaction.Type.DEPOSIT, + targetProjectionUUID); + assertNoExecutionRefTargetsCashTransfer(fixture.client(), plan); + + assertExecutionRefResolvesAfterRoundtrip(loadXml(saveXml(fixture.client())), 0, depositEntry.getUUID(), + targetProjectionUUID, AccountTransaction.Type.DEPOSIT); + assertExecutionRefResolvesAfterRoundtrip(loadProtobuf(saveProtobuf(fixture.client())), 0, + depositEntry.getUUID(), targetProjectionUUID, AccountTransaction.Type.DEPOSIT); + } + + /** + * Verifies that source and target execution refs can both survive one transfer split. + * The source ref must follow the removal and the target ref must follow the deposit independently. + */ + @Test + public void testInvestmentPlanExecutionRefsOnBothTransferSidesMigrateAfterTransferSplit() throws Exception + { + var fixture = fixture(CurrencyUnit.EUR, CurrencyUnit.EUR); + var transfer = createTransfer(fixture, Values.Amount.factorize(123), CurrencyUnit.EUR, + Values.Amount.factorize(123), CurrencyUnit.EUR, null, null); + var entry = fixture.client().getLedger().getEntries().get(0); + var sourceProjectionUUID = projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT).getUUID(); + var targetProjectionUUID = projection(entry, LedgerProjectionRole.TARGET_ACCOUNT).getUUID(); + var plan = newPlan(); + + plan.addLedgerExecutionRef(InvestmentPlan.LedgerExecutionRef.of( + (LedgerBackedTransaction) transfer.getSourceTransaction())); + plan.addLedgerExecutionRef(InvestmentPlan.LedgerExecutionRef.of( + (LedgerBackedTransaction) transfer.getTargetTransaction())); + fixture.client().addPlan(plan); + + converter(fixture).split(transfer); + + var removalEntry = removalEntry(fixture.client()); + var depositEntry = depositEntry(fixture.client()); + + assertExecutionRef(plan, 0, removalEntry.getUUID(), sourceProjectionUUID, LedgerProjectionRole.ACCOUNT); + assertExecutionRef(plan, 1, depositEntry.getUUID(), targetProjectionUUID, LedgerProjectionRole.ACCOUNT); + assertResolvedPlanTransaction(fixture.client(), plan, 0, AccountTransaction.Type.REMOVAL, + sourceProjectionUUID); + assertResolvedPlanTransaction(fixture.client(), plan, 1, AccountTransaction.Type.DEPOSIT, + targetProjectionUUID); + assertNoExecutionRefTargetsCashTransfer(fixture.client(), plan); + + var xmlClient = loadXml(saveXml(fixture.client())); + assertExecutionRefResolvesAfterRoundtrip(xmlClient, 0, removalEntry.getUUID(), sourceProjectionUUID, + AccountTransaction.Type.REMOVAL); + assertExecutionRefResolvesAfterRoundtrip(xmlClient, 1, depositEntry.getUUID(), targetProjectionUUID, + AccountTransaction.Type.DEPOSIT); + + var protobufClient = loadProtobuf(saveProtobuf(fixture.client())); + assertExecutionRefResolvesAfterRoundtrip(protobufClient, 0, removalEntry.getUUID(), sourceProjectionUUID, + AccountTransaction.Type.REMOVAL); + assertExecutionRefResolvesAfterRoundtrip(protobufClient, 1, depositEntry.getUUID(), targetProjectionUUID, + AccountTransaction.Type.DEPOSIT); + } + + /** + * Verifies that an entry-only plan reference blocks an account-transfer split. + * Without a source or target side, the generated booking cannot be continued as either removal or deposit. + */ + @Test + public void testEntryOnlyInvestmentPlanExecutionRefRejectsTransferSplitBeforeMutation() + { + var fixture = fixture(CurrencyUnit.EUR, CurrencyUnit.EUR); + var transfer = createTransfer(fixture, Values.Amount.factorize(123), CurrencyUnit.EUR, + Values.Amount.factorize(123), CurrencyUnit.EUR, null, null); + var entry = fixture.client().getLedger().getEntries().get(0); + var plan = newPlan(); + + plan.addLedgerExecutionRef(new InvestmentPlan.LedgerExecutionRef(entry.getUUID(), null, null)); + fixture.client().addPlan(plan); + + var exception = assertRejectsWithoutMutation(fixture, () -> converter(fixture).split(transfer), + UnsupportedOperationException.class); + assertThat(exception.getMessage(), is(LedgerDiagnosticCode.LEDGER_CONVERT_045 + .message("Ledger plan reference cannot be mapped to a split transfer side"))); + assertThat(plan.getLedgerExecutionRefs().get(0).getLedgerEntryUUID(), is(entry.getUUID())); + assertThat(plan.getLedgerExecutionRefs().get(0).getProjectionUUID(), is(nullValue())); + assertThat(plan.getLedgerExecutionRefs().get(0).getProjectionRole(), is(nullValue())); + } + + /** + * Verifies that a contradictory plan reference is rejected before mutation. + * A ref may not point to the source projection while asking to be treated as the target side. + */ + @Test + public void testConflictingInvestmentPlanExecutionRefRoleRejectsTransferSplitBeforeMutation() + { + var fixture = fixture(CurrencyUnit.EUR, CurrencyUnit.EUR); + var transfer = createTransfer(fixture, Values.Amount.factorize(123), CurrencyUnit.EUR, + Values.Amount.factorize(123), CurrencyUnit.EUR, null, null); + var entry = fixture.client().getLedger().getEntries().get(0); + var sourceProjectionUUID = projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT).getUUID(); + var plan = newPlan(); + + plan.addLedgerExecutionRef(new InvestmentPlan.LedgerExecutionRef(entry.getUUID(), sourceProjectionUUID, + LedgerProjectionRole.TARGET_ACCOUNT)); + fixture.client().addPlan(plan); + + var exception = assertRejectsWithoutMutation(fixture, () -> converter(fixture).split(transfer), + UnsupportedOperationException.class); + assertThat(exception.getMessage(), is(LedgerDiagnosticCode.LEDGER_CONVERT_045 + .message("Ledger plan reference cannot be mapped to a split transfer side"))); + assertThat(plan.getLedgerExecutionRefs().get(0).getLedgerEntryUUID(), is(entry.getUUID())); + assertThat(plan.getLedgerExecutionRefs().get(0).getProjectionUUID(), is(sourceProjectionUUID)); + assertThat(plan.getLedgerExecutionRefs().get(0).getProjectionRole(), is(LedgerProjectionRole.TARGET_ACCOUNT)); + } + + private InvestmentPlan planWithExecutionRef(Client client, LedgerBackedTransaction transaction) + { + var plan = newPlan(); + + plan.addLedgerExecutionRef(InvestmentPlan.LedgerExecutionRef.of(transaction)); + client.addPlan(plan); + + return plan; + } + + private InvestmentPlan newPlan() + { + var plan = new InvestmentPlan("Plan"); + + plan.setStart(DATE_TIME); + plan.setType(InvestmentPlan.Type.DEPOSIT); + + return plan; + } + + private void assertExecutionRef(InvestmentPlan plan, int index, String entryUUID, String projectionUUID, + LedgerProjectionRole role) + { + var ref = plan.getLedgerExecutionRefs().get(index); + + assertThat(ref.getLedgerEntryUUID(), is(entryUUID)); + assertThat(ref.getProjectionUUID(), is(projectionUUID)); + assertThat(ref.getProjectionRole(), is(role)); + } + + private void assertResolvedPlanTransaction(Client client, InvestmentPlan plan, int index, + AccountTransaction.Type type, String transactionUUID) + { + var transactions = plan.getTransactions(client); + var transaction = transactions.get(index).getTransaction(); + + assertThat(transactions.size(), is(plan.getLedgerExecutionRefs().size())); + assertThat(transaction, instanceOf(AccountTransaction.class)); + assertThat(((AccountTransaction) transaction).getType(), is(type)); + assertThat(transaction.getUUID(), is(transactionUUID)); + } + + private void assertNoExecutionRefTargetsCashTransfer(Client client, InvestmentPlan plan) + { + for (var pair : plan.getTransactions(client)) + { + var transaction = pair.getTransaction(); + + assertThat(transaction, instanceOf(AccountTransaction.class)); + assertThat(((AccountTransaction) transaction).getType() == AccountTransaction.Type.TRANSFER_IN + || ((AccountTransaction) transaction).getType() == AccountTransaction.Type.TRANSFER_OUT, + is(false)); + } + } + + private void assertExecutionRefResolvesAfterRoundtrip(Client client, int index, String entryUUID, + String projectionUUID, AccountTransaction.Type type) + { + var plan = client.getPlans().get(0); + + assertExecutionRef(plan, index, entryUUID, projectionUUID, LedgerProjectionRole.ACCOUNT); + assertResolvedPlanTransaction(client, plan, index, type, projectionUUID); + assertNoExecutionRefTargetsCashTransfer(client, plan); + assertValid(client); + } + + private void assertSplit(Fixture fixture, String transferEntryUUID, String sourcePostingUUID, + String targetPostingUUID, String sourceProjectionUUID, String targetProjectionUUID, + long sourceAmount, String sourceCurrency, long targetAmount, String targetCurrency) + { + assertThat(fixture.client().getLedger().getEntries().size(), is(2)); + + var removalEntry = removalEntry(fixture.client()); + var depositEntry = depositEntry(fixture.client()); + var sourcePosting = posting(removalEntry, fixture.source()); + var targetPosting = posting(depositEntry, fixture.target()); + var sourceProjection = projection(removalEntry, LedgerProjectionRole.ACCOUNT); + var targetProjection = projection(depositEntry, LedgerProjectionRole.ACCOUNT); + var removal = fixture.source().getTransactions().get(0); + var deposit = fixture.target().getTransactions().get(0); + + assertThat(removalEntry.getUUID(), is(transferEntryUUID)); + assertThat(removalEntry.getType(), is(LedgerEntryType.REMOVAL)); + assertThat(depositEntry.getType(), is(LedgerEntryType.DEPOSIT)); + assertThat(depositEntry.getUUID().equals(transferEntryUUID), is(false)); + assertThat(sourcePosting.getUUID(), is(sourcePostingUUID)); + assertThat(targetPosting.getUUID(), is(targetPostingUUID)); + assertThat(sourceProjection.getUUID(), is(sourceProjectionUUID)); + assertThat(targetProjection.getUUID(), is(targetProjectionUUID)); + assertThat(sourceProjection.getPrimaryPostingUUID(), is(sourcePostingUUID)); + assertThat(targetProjection.getPrimaryPostingUUID(), is(targetPostingUUID)); + assertSame(fixture.source(), sourceProjection.getAccount()); + assertSame(fixture.target(), targetProjection.getAccount()); + assertSame(fixture.source(), sourcePosting.getAccount()); + assertSame(fixture.target(), targetPosting.getAccount()); + assertThat(sourcePosting.getAmount(), is(sourceAmount)); + assertThat(sourcePosting.getCurrency(), is(sourceCurrency)); + assertThat(targetPosting.getAmount(), is(targetAmount)); + assertThat(targetPosting.getCurrency(), is(targetCurrency)); + + assertThat(fixture.source().getTransactions(), is(List.of(removal))); + assertThat(fixture.target().getTransactions(), is(List.of(deposit))); + assertThat(removal, instanceOf(LedgerBackedTransaction.class)); + assertThat(deposit, instanceOf(LedgerBackedTransaction.class)); + assertThat(removal.getUUID(), is(sourceProjectionUUID)); + assertThat(deposit.getUUID(), is(targetProjectionUUID)); + assertThat(removal.getType(), is(AccountTransaction.Type.REMOVAL)); + assertThat(deposit.getType(), is(AccountTransaction.Type.DEPOSIT)); + assertThat(removal.getCrossEntry(), is(nullValue())); + assertThat(deposit.getCrossEntry(), is(nullValue())); + assertThat(removal.getDateTime(), is(DATE_TIME)); + assertThat(deposit.getDateTime(), is(DATE_TIME)); + assertThat(removal.getNote(), is("note")); + assertThat(deposit.getNote(), is("note")); + assertThat(removal.getSource(), is("source")); + assertThat(deposit.getSource(), is("source")); + assertThat(removal.getAmount(), is(sourceAmount)); + assertThat(deposit.getAmount(), is(targetAmount)); + assertThat(removal.getCurrencyCode(), is(sourceCurrency)); + assertThat(deposit.getCurrencyCode(), is(targetCurrency)); + assertThat(fixture.client().getAllTransactions().size(), is(2)); + assertValid(fixture.client()); + } + + private void assertRoundtrip(Client client, String transferEntryUUID, String sourcePostingUUID, + String targetPostingUUID, String sourceProjectionUUID, String targetProjectionUUID, + String sourceCurrency, String targetCurrency, BigDecimal expectedSourceExchangeRate) + { + var source = client.getAccounts().get(0); + var target = client.getAccounts().get(1); + var removalEntry = removalEntry(client); + var depositEntry = depositEntry(client); + var sourcePosting = posting(removalEntry, source); + var targetPosting = posting(depositEntry, target); + + assertThat(client.getLedger().getEntries().size(), is(2)); + assertThat(source.getTransactions().size(), is(1)); + assertThat(target.getTransactions().size(), is(1)); + assertThat(removalEntry.getUUID(), is(transferEntryUUID)); + assertThat(sourcePosting.getUUID(), is(sourcePostingUUID)); + assertThat(targetPosting.getUUID(), is(targetPostingUUID)); + assertThat(projection(removalEntry, LedgerProjectionRole.ACCOUNT).getUUID(), is(sourceProjectionUUID)); + assertThat(projection(depositEntry, LedgerProjectionRole.ACCOUNT).getUUID(), is(targetProjectionUUID)); + assertThat(source.getTransactions().get(0).getType(), is(AccountTransaction.Type.REMOVAL)); + assertThat(target.getTransactions().get(0).getType(), is(AccountTransaction.Type.DEPOSIT)); + assertThat(sourcePosting.getCurrency(), is(sourceCurrency)); + assertThat(targetPosting.getCurrency(), is(targetCurrency)); + assertThat(sourcePosting.getExchangeRate(), is(expectedSourceExchangeRate)); + assertValid(client); + } + + private T assertRejectsWithoutMutation(Fixture fixture, ThrowingRunnable runnable, + Class expectedType) + { + var snapshot = Snapshot.capture(fixture.client()); + + var exception = assertThrows(expectedType, runnable::run); + assertThat(Snapshot.capture(fixture.client()), is(snapshot)); + return exception; + } + + private AccountTransferEntry createTransfer(Fixture fixture, long sourceAmount, String sourceCurrency, + long targetAmount, String targetCurrency, Money sourceForexAmount, BigDecimal sourceExchangeRate) + { + return new LedgerAccountTransferTransactionCreator(fixture.client()).create(fixture.source(), fixture.target(), + DATE_TIME, sourceAmount, sourceCurrency, targetAmount, targetCurrency, sourceForexAmount, + sourceExchangeRate, "note", "source"); + } + + private LedgerAccountTransferToDepositRemovalConverter converter(Fixture fixture) + { + return new LedgerAccountTransferToDepositRemovalConverter(fixture.client()); + } + + private LedgerEntry removalEntry(Client client) + { + return client.getLedger().getEntries().stream().filter(entry -> entry.getType() == LedgerEntryType.REMOVAL) + .findFirst().orElseThrow(); + } + + private LedgerEntry depositEntry(Client client) + { + return client.getLedger().getEntries().stream().filter(entry -> entry.getType() == LedgerEntryType.DEPOSIT) + .findFirst().orElseThrow(); + } + + private LedgerProjectionRef projection(LedgerEntry entry, LedgerProjectionRole role) + { + return entry.getProjectionRefs().stream().filter(projection -> projection.getRole() == role).findFirst() + .orElseThrow(); + } + + private LedgerPosting posting(LedgerEntry entry, Account account) + { + return entry.getPostings().stream().filter(posting -> posting.getType() == LedgerPostingType.CASH) + .filter(posting -> posting.getAccount() == account).findFirst().orElseThrow(); + } + + private String saveXml(Client client) throws IOException + { + var file = File.createTempFile("ledger-account-transfer-split", ".xml"); + try + { + ClientFactory.save(client, file); + return Files.readString(file.toPath(), StandardCharsets.UTF_8); + } + finally + { + Files.deleteIfExists(file.toPath()); + } + } + + private Client loadXml(String xml) throws IOException + { + return ClientFactory.load(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); + } + + private byte[] saveProtobuf(Client client) throws IOException + { + return ProtobufTestUtilities.save(client); + } + + private Client loadProtobuf(byte[] bytes) throws IOException + { + return ProtobufTestUtilities.load(bytes); + } + + private void assertValid(Client client) + { + var result = LedgerStructuralValidator.validate(client.getLedger()); + + assertThat(result.format(), result.isOK(), is(true)); + } + + private Fixture fixture(String sourceCurrency, String targetCurrency) + { + var client = new Client(); + var source = account("Source", sourceCurrency); + var target = account("Target", targetCurrency); + + client.addAccount(source); + client.addAccount(target); + + return new Fixture(client, source, target); + } + + private Account account(String name, String currency) + { + var account = new Account(name); + + account.setCurrencyCode(currency); + + return account; + } + + @FunctionalInterface + private interface ThrowingRunnable + { + void run() throws Exception; + } + + private record Fixture(Client client, Account source, Account target) + { + } + + private record Snapshot(List entries, List accountTransactions, + List allTransactions) + { + static Snapshot capture(Client client) + { + return new Snapshot(client.getLedger().getEntries().stream().map(EntrySnapshot::capture) + .sorted(Comparator.comparing(EntrySnapshot::uuid)).toList(), + client.getAccounts().stream().flatMap(account -> account.getTransactions().stream()) + .map(Transaction::getUUID).toList(), + client.getAllTransactions().stream().map(pair -> pair.getTransaction().getUUID()) + .toList()); + } + } + + private record EntrySnapshot(String uuid, LedgerEntryType type, List postings, + List projections) + { + static EntrySnapshot capture(LedgerEntry entry) + { + return new EntrySnapshot(entry.getUUID(), entry.getType(), + entry.getPostings().stream().map(PostingSnapshot::capture).toList(), + entry.getProjectionRefs().stream().map(ProjectionSnapshot::capture).toList()); + } + } + + private record PostingSnapshot(String uuid, LedgerPostingType type, long amount, String currency, Long forexAmount, + String forexCurrency, BigDecimal exchangeRate, Security security, long shares, Account account) + { + static PostingSnapshot capture(LedgerPosting posting) + { + return new PostingSnapshot(posting.getUUID(), posting.getType(), posting.getAmount(), posting.getCurrency(), + posting.getForexAmount(), posting.getForexCurrency(), posting.getExchangeRate(), + posting.getSecurity(), posting.getShares(), posting.getAccount()); + } + } + + private record ProjectionSnapshot(String uuid, LedgerProjectionRole role, Account account, + String primaryPostingUUID, String postingGroupUUID) + { + static ProjectionSnapshot capture(LedgerProjectionRef projection) + { + return new ProjectionSnapshot(projection.getUUID(), projection.getRole(), projection.getAccount(), + projection.getPrimaryPostingUUID(), projection.getPostingGroupUUID()); + } + } +} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTypeToggleConverterTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTypeToggleConverterTest.java new file mode 100644 index 0000000000..d546729da5 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTypeToggleConverterTest.java @@ -0,0 +1,474 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import static org.hamcrest.CoreMatchers.instanceOf; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.Test; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.ClientFactory; +import name.abuchen.portfolio.model.InvestmentPlan; +import name.abuchen.portfolio.model.ProtobufTestUtilities; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.Transaction; +import name.abuchen.portfolio.model.Transaction.Unit; +import name.abuchen.portfolio.model.TransactionPair; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerParameter; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; +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.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionSupport; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.Values; + +/** + * Tests ledger-aware type changes for account-only transactions. + * These tests make sure supported account booking changes keep the generated booking traceable and reject unsafe shapes. + */ +@SuppressWarnings("nls") +public class LedgerAccountTypeToggleConverterTest +{ + private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 6, 11, 12, 13); + private static final LocalDateTime EX_DATE = LocalDateTime.of(2026, 6, 10, 0, 0); + private static final BigDecimal EXCHANGE_RATE = new BigDecimal("0.5000"); + + /** + * Verifies that a ledger-backed deposit can be toggled into a removal. + * The same booking identity and owner-list projection must remain valid after save/load. + */ + @Test + public void testTogglesLedgerBackedDepositToRemovalPreservingIdentityAndTruth() throws Exception + { + assertTogglesCashType(AccountTransaction.Type.DEPOSIT, LedgerEntryType.REMOVAL, + AccountTransaction.Type.REMOVAL); + } + + /** + * Verifies that a ledger-backed removal can be toggled into a deposit. + * The same booking identity and owner-list projection must remain valid after save/load. + */ + @Test + public void testTogglesLedgerBackedRemovalToDepositPreservingIdentityAndTruth() throws Exception + { + assertTogglesCashType(AccountTransaction.Type.REMOVAL, LedgerEntryType.DEPOSIT, + AccountTransaction.Type.DEPOSIT); + } + + /** + * Verifies that a ledger-backed interest booking can be toggled into an interest charge. + * The converter must change the type without replacing the generated account booking. + */ + @Test + public void testTogglesLedgerBackedInterestToInterestChargePreservingIdentityAndTruth() throws Exception + { + assertTogglesInterestType(AccountTransaction.Type.INTEREST, LedgerEntryType.INTEREST_CHARGE, + AccountTransaction.Type.INTEREST_CHARGE); + } + + /** + * Verifies that a ledger-backed interest charge can be toggled into interest. + * The converter must change the type without replacing the generated account booking. + */ + @Test + public void testTogglesLedgerBackedInterestChargeToInterestPreservingIdentityAndTruth() throws Exception + { + assertTogglesInterestType(AccountTransaction.Type.INTEREST_CHARGE, LedgerEntryType.INTEREST, + AccountTransaction.Type.INTEREST); + } + + @Test + public void testTogglesLedgerBackedFeesToFeeRefundPreservingIdentityAndTruth() throws Exception + { + assertTogglesFeeTaxType(AccountTransaction.Type.FEES, LedgerEntryType.FEES_REFUND, + AccountTransaction.Type.FEES_REFUND); + } + + @Test + public void testTogglesLedgerBackedFeeRefundToFeesPreservingIdentityAndTruth() throws Exception + { + assertTogglesFeeTaxType(AccountTransaction.Type.FEES_REFUND, LedgerEntryType.FEES, + AccountTransaction.Type.FEES); + } + + @Test + public void testTogglesLedgerBackedTaxesToTaxRefundPreservingIdentityAndTruth() throws Exception + { + assertTogglesFeeTaxType(AccountTransaction.Type.TAXES, LedgerEntryType.TAX_REFUND, + AccountTransaction.Type.TAX_REFUND); + } + + @Test + public void testTogglesLedgerBackedTaxRefundToTaxesPreservingIdentityAndTruth() throws Exception + { + assertTogglesFeeTaxType(AccountTransaction.Type.TAX_REFUND, LedgerEntryType.TAXES, + AccountTransaction.Type.TAXES); + } + + /** + * Verifies that account-only type toggling rejects a missing projection before mutation. + * The converter must not recreate the account view from partial ledger facts. + */ + @Test + public void testMalformedAccountOnlyMissingProjectionRejectsBeforeMutation() + { + var fixture = fixture(); + var transaction = createCash(fixture, AccountTransaction.Type.DEPOSIT); + var entry = fixture.client().getLedger().getEntries().get(0); + + entry.removeProjectionRef(projection(entry)); + + assertRejectsWithoutMutation(fixture, () -> converter(fixture).toggle(pair(fixture, transaction)), + IllegalArgumentException.class); + } + + /** + * Verifies that account-only type toggling rejects a missing cash posting before mutation. + * The converter must not infer amount or currency facts from the projection. + */ + @Test + public void testMalformedAccountOnlyMissingCashPostingRejectsBeforeMutation() + { + var fixture = fixture(); + var transaction = createCash(fixture, AccountTransaction.Type.DEPOSIT); + var entry = fixture.client().getLedger().getEntries().get(0); + + entry.removePosting(posting(entry)); + + assertRejectsWithoutMutation(fixture, () -> converter(fixture).toggle(pair(fixture, transaction)), + IllegalArgumentException.class); + } + + /** + * Verifies that a plan-generated account-only booking can be toggled safely. + * The plan reference must still resolve to the same generated booking after save/load. + */ + @Test + public void testInvestmentPlanReferencedAccountOnlyTogglesAndKeepsPlanReference() throws Exception + { + var fixture = fixture(); + var transaction = createCash(fixture, AccountTransaction.Type.DEPOSIT); + var entry = fixture.client().getLedger().getEntries().get(0); + var plan = new InvestmentPlan("Plan"); + var projectionUUID = transaction.getUUID(); + + plan.addLedgerExecutionRef(InvestmentPlan.LedgerExecutionRef.of((LedgerBackedTransaction) transaction)); + fixture.client().addPlan(plan); + + converter(fixture).toggle(pair(fixture, transaction)); + + assertThat(plan.getLedgerExecutionRefs().size(), is(1)); + assertThat(plan.getLedgerExecutionRefs().get(0).getLedgerEntryUUID(), is(entry.getUUID())); + assertThat(plan.getLedgerExecutionRefs().get(0).getProjectionUUID(), is(projectionUUID)); + assertThat(plan.getLedgerExecutionRefs().get(0).getProjectionRole(), is(LedgerProjectionRole.ACCOUNT)); + assertThat(plan.getTransactions(fixture.client()).get(0).getTransaction().getUUID(), is(projectionUUID)); + + var loaded = loadXml(saveXml(fixture.client())); + assertThat(loaded.getPlans().get(0).getTransactions(loaded).get(0).getTransaction().getUUID(), + is(projectionUUID)); + } + + private void assertTogglesCashType(AccountTransaction.Type sourceType, LedgerEntryType targetEntryType, + AccountTransaction.Type targetTransactionType) throws Exception + { + var fixture = fixture(); + var transaction = createCash(fixture, sourceType); + var entry = fixture.client().getLedger().getEntries().get(0); + + assertToggles(fixture, transaction, entry, targetEntryType, targetTransactionType, null, List.of(), null); + } + + private void assertTogglesInterestType(AccountTransaction.Type sourceType, LedgerEntryType targetEntryType, + AccountTransaction.Type targetTransactionType) throws Exception + { + var fixture = fixture(); + var transaction = createInterest(fixture, sourceType); + var entry = fixture.client().getLedger().getEntries().get(0); + + posting(entry).addParameter(LedgerParameter.ofLocalDateTime(LedgerParameterType.EX_DATE, + EX_DATE)); + + assertToggles(fixture, transaction, entry, targetEntryType, targetTransactionType, fixture.security(), + unitSnapshots(entry), EX_DATE); + } + + private void assertTogglesFeeTaxType(AccountTransaction.Type sourceType, LedgerEntryType targetEntryType, + AccountTransaction.Type targetTransactionType) throws Exception + { + var fixture = fixture(); + var transaction = createFeeTax(fixture, sourceType); + var entry = fixture.client().getLedger().getEntries().get(0); + + assertToggles(fixture, transaction, entry, targetEntryType, targetTransactionType, fixture.security(), + List.of(), null); + } + + private void assertToggles(Fixture fixture, AccountTransaction transaction, LedgerEntry entry, + LedgerEntryType targetEntryType, AccountTransaction.Type targetTransactionType, Security security, + List expectedUnitPostings, LocalDateTime expectedExDate) throws Exception + { + var entryUUID = entry.getUUID(); + var cashPostingUUID = posting(entry).getUUID(); + var projectionUUID = projection(entry).getUUID(); + + var toggled = converter(fixture).toggle(pair(fixture, transaction)); + + assertThat(entry.getUUID(), is(entryUUID)); + assertThat(entry.getType(), is(targetEntryType)); + assertThat(posting(entry).getUUID(), is(cashPostingUUID)); + assertThat(projection(entry).getUUID(), is(projectionUUID)); + assertSame(fixture.account(), projection(entry).getAccount()); + assertThat(unitSnapshots(entry), is(expectedUnitPostings)); + + assertThat(fixture.account().getTransactions(), is(List.of(toggled))); + assertThat(fixture.client().getAllTransactions().size(), is(1)); + assertSame(toggled, fixture.client().getAllTransactions().get(0).getTransaction()); + assertThat(toggled.getUUID(), is(projectionUUID)); + assertThat(toggled, instanceOf(LedgerBackedTransaction.class)); + assertThat(toggled.getType(), is(targetTransactionType)); + assertThat(toggled.getDateTime(), is(DATE_TIME)); + assertThat(toggled.getNote(), is("note")); + assertThat(toggled.getSource(), is("source")); + assertThat(toggled.getAmount(), is(Values.Amount.factorize(123))); + assertThat(toggled.getCurrencyCode(), is(CurrencyUnit.EUR)); + assertSame(security, toggled.getSecurity()); + assertThat(toggled.getShares(), is(0L)); + assertThat(toggled.getExDate(), is(expectedExDate)); + assertValid(fixture.client()); + + assertRoundtrip(loadXml(saveXml(fixture.client())), entryUUID, cashPostingUUID, projectionUUID, targetEntryType, + targetTransactionType, security != null, expectedExDate); + assertRoundtrip(loadProtobuf(saveProtobuf(fixture.client())), entryUUID, cashPostingUUID, projectionUUID, + targetEntryType, targetTransactionType, security != null, expectedExDate); + } + + private void assertRoundtrip(Client client, String entryUUID, String cashPostingUUID, String projectionUUID, + LedgerEntryType entryType, AccountTransaction.Type transactionType, boolean hasSecurity, + LocalDateTime expectedExDate) + { + assertThat(client.getAccounts().get(0).getTransactions().size(), is(1)); + assertThat(client.getAllTransactions().size(), is(1)); + + var entry = client.getLedger().getEntries().get(0); + var transaction = client.getAccounts().get(0).getTransactions().get(0); + + assertThat(entry.getUUID(), is(entryUUID)); + assertThat(entry.getType(), is(entryType)); + assertThat(posting(entry).getUUID(), is(cashPostingUUID)); + assertThat(projection(entry).getUUID(), is(projectionUUID)); + assertThat(transaction.getUUID(), is(projectionUUID)); + assertThat(transaction.getType(), is(transactionType)); + assertThat(transaction.getAmount(), is(Values.Amount.factorize(123))); + assertThat(transaction.getSecurity() != null, is(hasSecurity)); + assertThat(transaction.getExDate(), is(expectedExDate)); + assertValid(client); + } + + private void assertRejectsWithoutMutation(Fixture fixture, ThrowingRunnable runnable, + Class expectedType) + { + var snapshot = Snapshot.capture(fixture.client()); + + assertThrows(expectedType, runnable::run); + assertThat(Snapshot.capture(fixture.client()), is(snapshot)); + } + + private AccountTransaction createCash(Fixture fixture, AccountTransaction.Type type) + { + return new LedgerAccountOnlyTransactionCreator(fixture.client()).create(fixture.account(), type, DATE_TIME, + Values.Amount.factorize(123), CurrencyUnit.EUR, null, List.of(), "note", "source"); + } + + private AccountTransaction createInterest(Fixture fixture, AccountTransaction.Type type) + { + return new LedgerAccountOnlyTransactionCreator(fixture.client()).create(fixture.account(), type, DATE_TIME, + Values.Amount.factorize(123), CurrencyUnit.EUR, fixture.security(), units(), "note", + "source"); + } + + private AccountTransaction createFeeTax(Fixture fixture, AccountTransaction.Type type) + { + return new LedgerAccountOnlyTransactionCreator(fixture.client()).create(fixture.account(), type, DATE_TIME, + Values.Amount.factorize(123), CurrencyUnit.EUR, fixture.security(), List.of(), "note", + "source"); + } + + private List units() + { + return List.of( + new Unit(Unit.Type.FEE, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(3)), + Money.of(CurrencyUnit.USD, Values.Amount.factorize(6)), EXCHANGE_RATE), + new Unit(Unit.Type.TAX, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(4))), + new Unit(Unit.Type.GROSS_VALUE, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(120)), + Money.of(CurrencyUnit.USD, Values.Amount.factorize(240)), EXCHANGE_RATE)); + } + + private LedgerAccountTypeToggleConverter converter(Fixture fixture) + { + return new LedgerAccountTypeToggleConverter(fixture.client()); + } + + private TransactionPair pair(Fixture fixture, AccountTransaction transaction) + { + return new TransactionPair<>(fixture.account(), transaction); + } + + private LedgerProjectionRef projection(LedgerEntry entry) + { + return entry.getProjectionRefs().stream().filter(projection -> projection.getRole() == LedgerProjectionRole.ACCOUNT) + .findFirst().orElseThrow(); + } + + private LedgerPosting posting(LedgerEntry entry) + { + return LedgerProjectionSupport.primaryPosting(entry, projection(entry)); + } + + private List unitSnapshots(LedgerEntry entry) + { + var primaryPosting = posting(entry); + + return entry.getPostings().stream().filter(posting -> posting.getType() == LedgerPostingType.FEE + || posting.getType() == LedgerPostingType.TAX + || posting.getType() == LedgerPostingType.GROSS_VALUE).filter(posting -> posting != primaryPosting) + .map(PostingSnapshot::capture).toList(); + } + + private String saveXml(Client client) throws IOException + { + var file = File.createTempFile("ledger-account-type-toggle-converter", ".xml"); + try + { + ClientFactory.save(client, file); + return Files.readString(file.toPath(), StandardCharsets.UTF_8); + } + finally + { + Files.deleteIfExists(file.toPath()); + } + } + + private Client loadXml(String xml) throws IOException + { + return ClientFactory.load(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); + } + + private byte[] saveProtobuf(Client client) throws IOException + { + return ProtobufTestUtilities.save(client); + } + + private Client loadProtobuf(byte[] bytes) throws IOException + { + return ProtobufTestUtilities.load(bytes); + } + + private void assertValid(Client client) + { + var result = LedgerStructuralValidator.validate(client.getLedger()); + + assertThat(result.format(), result.isOK(), is(true)); + } + + private Fixture fixture() + { + var client = new Client(); + var account = new Account("Account"); + var security = new Security("Security", CurrencyUnit.EUR); + + account.setCurrencyCode(CurrencyUnit.EUR); + account.setUpdatedAt(Instant.now()); + security.setUpdatedAt(Instant.now()); + client.addAccount(account); + client.addSecurity(security); + + return new Fixture(client, account, security); + } + + @FunctionalInterface + private interface ThrowingRunnable + { + void run() throws Exception; + } + + private record Fixture(Client client, Account account, Security security) + { + } + + private record Snapshot(List entries, List accountTransactions, + List allTransactions) + { + static Snapshot capture(Client client) + { + return new Snapshot(client.getLedger().getEntries().stream().map(EntrySnapshot::capture).toList(), + client.getAccounts().stream().flatMap(account -> account.getTransactions().stream()) + .map(Transaction::getUUID).toList(), + client.getAllTransactions().stream().map(pair -> pair.getTransaction().getUUID()) + .toList()); + } + } + + private record EntrySnapshot(String uuid, LedgerEntryType type, List postings, + List projections) + { + static EntrySnapshot capture(LedgerEntry entry) + { + return new EntrySnapshot(entry.getUUID(), entry.getType(), + entry.getPostings().stream().map(PostingSnapshot::capture).toList(), + entry.getProjectionRefs().stream().map(ProjectionSnapshot::capture).toList()); + } + } + + private record PostingSnapshot(String uuid, LedgerPostingType type, Long amount, String currency, Long forexAmount, + String forexCurrency, BigDecimal exchangeRate, Security security, Long shares, Account account, + List parameters) + { + static PostingSnapshot capture(LedgerPosting posting) + { + return new PostingSnapshot(posting.getUUID(), posting.getType(), posting.getAmount(), posting.getCurrency(), + posting.getForexAmount(), posting.getForexCurrency(), posting.getExchangeRate(), + posting.getSecurity(), posting.getShares(), posting.getAccount(), + posting.getParameters().stream().map(ParameterSnapshot::capture).toList()); + } + } + + private record ParameterSnapshot(LedgerParameterType type, + LedgerParameter.ValueKind valueKind, Object value) + { + static ParameterSnapshot capture(LedgerParameter parameter) + { + return new ParameterSnapshot(parameter.getType(), parameter.getValueKind(), parameter.getValue()); + } + } + + private record ProjectionSnapshot(String uuid, LedgerProjectionRole role, Account account, + String primaryPostingUUID, String postingGroupUUID) + { + static ProjectionSnapshot capture(LedgerProjectionRef projection) + { + return new ProjectionSnapshot(projection.getUUID(), projection.getRole(), projection.getAccount(), + projection.getPrimaryPostingUUID(), projection.getPostingGroupUUID()); + } + } +} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellDeliveryConverterTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellDeliveryConverterTest.java new file mode 100644 index 0000000000..40b219b669 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellDeliveryConverterTest.java @@ -0,0 +1,851 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import static org.hamcrest.CoreMatchers.instanceOf; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.Test; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.ClientFactory; +import name.abuchen.portfolio.model.InvestmentPlan; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.PortfolioTransaction; +import name.abuchen.portfolio.model.ProtobufTestUtilities; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.Transaction; +import name.abuchen.portfolio.model.Transaction.Unit; +import name.abuchen.portfolio.model.TransactionPair; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.Values; + +/** + * Tests ledger-aware conversion between buy/sell transactions and deliveries. + * These tests make sure supported master transitions keep ledger truth consistent and do not leave removed rows referenced. + */ +@SuppressWarnings("nls") +public class LedgerBuySellDeliveryConverterTest +{ + private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 6, 7, 8, 9); + private static final BigDecimal EXCHANGE_RATE = new BigDecimal("0.5000"); + + /** + * Verifies that a ledger-backed buy can become an inbound delivery. + * The portfolio-side booking remains the surviving projection and no account-side truth is left behind. + */ + @Test + public void testConvertsLedgerBackedBuyToInboundDeliveryPreservingIdentityAndTruth() throws Exception + { + assertConvertsBuySellToDelivery(PortfolioTransaction.Type.BUY, LedgerEntryType.DELIVERY_INBOUND, + PortfolioTransaction.Type.DELIVERY_INBOUND, LedgerProjectionRole.DELIVERY_INBOUND); + } + + /** + * Verifies that a ledger-backed sell can become an outbound delivery. + * The portfolio-side booking remains the surviving projection and no account-side truth is left behind. + */ + @Test + public void testConvertsLedgerBackedSellToOutboundDeliveryPreservingIdentityAndTruth() throws Exception + { + assertConvertsBuySellToDelivery(PortfolioTransaction.Type.SELL, LedgerEntryType.DELIVERY_OUTBOUND, + PortfolioTransaction.Type.DELIVERY_OUTBOUND, LedgerProjectionRole.DELIVERY_OUTBOUND); + } + + /** + * Verifies that an inbound delivery can become the matching buy booking. + * The converter must create the cash side while preserving the portfolio projection identity. + */ + @Test + public void testConvertsLedgerBackedInboundDeliveryToBuyPreservingIdentityAndTruth() throws Exception + { + assertConvertsDeliveryToBuySell(PortfolioTransaction.Type.DELIVERY_INBOUND, LedgerEntryType.BUY, + PortfolioTransaction.Type.BUY, LedgerProjectionRole.DELIVERY_INBOUND); + } + + /** + * Verifies that an outbound delivery can become the matching sell booking. + * The converter must create the cash side while preserving the portfolio projection identity. + */ + @Test + public void testConvertsLedgerBackedOutboundDeliveryToSellPreservingIdentityAndTruth() throws Exception + { + assertConvertsDeliveryToBuySell(PortfolioTransaction.Type.DELIVERY_OUTBOUND, LedgerEntryType.SELL, + PortfolioTransaction.Type.SELL, LedgerProjectionRole.DELIVERY_OUTBOUND); + } + + /** + * Verifies that the composite converter reverses direction and changes buy/sell shape in one mutation. + * The surviving portfolio projection must keep its UUID and no account-side projection may survive. + */ + @Test + public void testCompositeConvertsLedgerBackedBuySellToOppositeDeliveryAtomically() throws Exception + { + assertCompositeConvertsBuySellToDelivery(PortfolioTransaction.Type.BUY, LedgerEntryType.DELIVERY_OUTBOUND, + PortfolioTransaction.Type.DELIVERY_OUTBOUND, LedgerProjectionRole.DELIVERY_OUTBOUND, + Values.Amount.factorize(113)); + assertCompositeConvertsBuySellToDelivery(PortfolioTransaction.Type.SELL, LedgerEntryType.DELIVERY_INBOUND, + PortfolioTransaction.Type.DELIVERY_INBOUND, LedgerProjectionRole.DELIVERY_INBOUND, + Values.Amount.factorize(127)); + } + + /** + * Verifies that the composite converter reverses direction and creates the buy/sell shape in one mutation. + * The portfolio projection survives while the new account side is materialized consistently. + */ + @Test + public void testCompositeConvertsLedgerBackedDeliveryToOppositeBuySellAtomically() throws Exception + { + assertCompositeConvertsDeliveryToBuySell(PortfolioTransaction.Type.DELIVERY_INBOUND, LedgerEntryType.SELL, + PortfolioTransaction.Type.SELL, LedgerProjectionRole.DELIVERY_INBOUND, + Values.Amount.factorize(113)); + assertCompositeConvertsDeliveryToBuySell(PortfolioTransaction.Type.DELIVERY_OUTBOUND, LedgerEntryType.BUY, + PortfolioTransaction.Type.BUY, LedgerProjectionRole.DELIVERY_OUTBOUND, + Values.Amount.factorize(127)); + } + + /** + * Verifies that a plan-generated buy/sell can be converted to delivery when the portfolio projection survives. + * The execution ref must keep resolving to the same projected booking after save/load. + */ + @Test + public void testInvestmentPlanReferencedBuySellConvertsToDeliveryAndUpdatesPlanReference() throws Exception + { + var fixture = fixture(); + var portfolioTransaction = create(fixture, PortfolioTransaction.Type.BUY).getPortfolioTransaction(); + var entry = fixture.client().getLedger().getEntries().get(0); + var plan = new InvestmentPlan("Plan"); + var projectionUUID = portfolioTransaction.getUUID(); + + plan.addLedgerExecutionRef(InvestmentPlan.LedgerExecutionRef.of((LedgerBackedTransaction) portfolioTransaction)); + fixture.client().addPlan(plan); + + converter(fixture).convertBuySellToDelivery(pair(fixture, portfolioTransaction)); + + assertThat(plan.getLedgerExecutionRefs().size(), is(1)); + assertThat(plan.getLedgerExecutionRefs().get(0).getLedgerEntryUUID(), is(entry.getUUID())); + assertThat(plan.getLedgerExecutionRefs().get(0).getProjectionUUID(), is(projectionUUID)); + assertThat(plan.getLedgerExecutionRefs().get(0).getProjectionRole(), + is(LedgerProjectionRole.DELIVERY_INBOUND)); + assertThat(plan.getTransactions(fixture.client()).get(0).getTransaction().getUUID(), is(projectionUUID)); + + var loaded = loadXml(saveXml(fixture.client())); + assertThat(loaded.getPlans().get(0).getTransactions(loaded).get(0).getTransaction().getUUID(), + is(projectionUUID)); + } + + /** + * Verifies that a plan ref on the account side blocks a buy/sell to delivery conversion. + * That projection would be removed, so the converter must reject before changing the ledger. + */ + @Test + public void testInvestmentPlanReferencedBuySellAccountProjectionCannotConvertToDelivery() + { + var fixture = fixture(); + var buySell = create(fixture, PortfolioTransaction.Type.BUY); + var entry = fixture.client().getLedger().getEntries().get(0); + var plan = new InvestmentPlan("Plan"); + + plan.addLedgerExecutionRef(InvestmentPlan.LedgerExecutionRef + .of((LedgerBackedTransaction) buySell.getAccountTransaction())); + fixture.client().addPlan(plan); + var snapshot = Snapshot.capture(fixture.client()); + + var exception = assertThrows(UnsupportedOperationException.class, + () -> converter(fixture).convertBuySellToDelivery(pair(fixture, + buySell.getPortfolioTransaction()))); + + assertThat(exception.getMessage(), containsString("projection would be removed")); + assertThat(Snapshot.capture(fixture.client()), is(snapshot)); + assertThat(plan.getLedgerExecutionRefs().size(), is(1)); + assertThat(plan.getLedgerExecutionRefs().get(0).getLedgerEntryUUID(), is(entry.getUUID())); + } + + /** + * Verifies that buy/sell to delivery conversion rejects a malformed buy/sell entry before mutation. + * The converter must not continue when the cash side needed for removal is already inconsistent. + */ + @Test + public void testMalformedBuySellShapeRejectsBeforeMutation() + { + var fixture = fixture(); + var portfolioTransaction = create(fixture, PortfolioTransaction.Type.BUY).getPortfolioTransaction(); + var entry = fixture.client().getLedger().getEntries().get(0); + var cashPosting = posting(entry, LedgerPostingType.CASH); + + entry.removePosting(cashPosting); + var snapshot = Snapshot.capture(fixture.client()); + + assertThrows(IllegalArgumentException.class, + () -> converter(fixture).convertBuySellToDelivery(pair(fixture, portfolioTransaction))); + + assertThat(Snapshot.capture(fixture.client()), is(snapshot)); + } + + /** + * Verifies that delivery to buy/sell conversion needs a reference account for the cash side. + * Without that owner, the converter must reject before creating an incomplete account projection. + */ + @Test + public void testDeliveryWithoutReferenceAccountRejectsBeforeMutation() + { + var fixture = fixture(); + var delivery = createDelivery(fixture, PortfolioTransaction.Type.DELIVERY_INBOUND); + + fixture.portfolio().setReferenceAccount(null); + var snapshot = Snapshot.capture(fixture.client()); + + assertThrows(IllegalArgumentException.class, + () -> converter(fixture).convertDeliveryToBuySell(pair(fixture, delivery))); + + assertThat(Snapshot.capture(fixture.client()), is(snapshot)); + } + + /** + * Verifies that a delivery can become a buy/sell even when the reference account uses another currency. + * If no better rate exists, the new cash side records explicit forex facts with exchange rate one. + */ + @Test + public void testDeliveryReferenceAccountCurrencyMismatchConvertsWithDefaultForex() throws Exception + { + var fixture = fixture(); + var delivery = createDelivery(fixture, PortfolioTransaction.Type.DELIVERY_INBOUND); + + fixture.account().setCurrencyCode(CurrencyUnit.USD); + var converted = converter(fixture).convertDeliveryToBuySell(pair(fixture, delivery)); + var entry = fixture.client().getLedger().getEntries().get(0); + var cashPosting = posting(entry, LedgerPostingType.CASH); + + assertThat(converted.getAccountTransaction().getCurrencyCode(), is(CurrencyUnit.USD)); + assertThat(converted.getAccountTransaction().getAmount(), is(Values.Amount.factorize(123))); + assertThat(cashPosting.getCurrency(), is(CurrencyUnit.USD)); + assertThat(cashPosting.getAmount(), is(Values.Amount.factorize(123))); + assertThat(cashPosting.getForexCurrency(), is(CurrencyUnit.EUR)); + assertThat(cashPosting.getForexAmount(), is(Values.Amount.factorize(123))); + assertThat(cashPosting.getExchangeRate(), is(BigDecimal.ONE)); + assertValid(fixture.client()); + + var loaded = loadXml(saveXml(fixture.client())); + var loadedCashPosting = posting(loaded.getLedger().getEntries().get(0), LedgerPostingType.CASH); + assertThat(loadedCashPosting.getCurrency(), is(CurrencyUnit.USD)); + assertThat(loadedCashPosting.getForexCurrency(), is(CurrencyUnit.EUR)); + assertThat(loadedCashPosting.getExchangeRate(), is(BigDecimal.ONE)); + } + + /** + * Verifies that existing delivery forex metadata is used when creating the cash side. + * The converter must preserve the known relationship between delivery amount and account currency. + */ + @Test + public void testDeliveryWithForexMetadataConvertsToReferenceAccountCurrency() throws Exception + { + var fixture = fixture(); + var delivery = createDelivery(fixture, PortfolioTransaction.Type.DELIVERY_INBOUND, + Money.of(CurrencyUnit.USD, Values.Amount.factorize(246)), EXCHANGE_RATE); + + fixture.account().setCurrencyCode(CurrencyUnit.USD); + var converted = converter(fixture).convertDeliveryToBuySell(pair(fixture, delivery)); + var entry = fixture.client().getLedger().getEntries().get(0); + var cashPosting = posting(entry, LedgerPostingType.CASH); + + assertThat(converted.getAccountTransaction().getCurrencyCode(), is(CurrencyUnit.USD)); + assertThat(converted.getAccountTransaction().getAmount(), is(Values.Amount.factorize(246))); + assertThat(cashPosting.getCurrency(), is(CurrencyUnit.USD)); + assertThat(cashPosting.getAmount(), is(Values.Amount.factorize(246))); + assertThat(cashPosting.getForexCurrency(), is(CurrencyUnit.EUR)); + assertThat(cashPosting.getForexAmount(), is(Values.Amount.factorize(123))); + assertThat(cashPosting.getExchangeRate(), is(new BigDecimal("2.0000000000"))); + assertValid(fixture.client()); + + var loaded = loadXml(saveXml(fixture.client())); + var loadedCashPosting = posting(loaded.getLedger().getEntries().get(0), LedgerPostingType.CASH); + assertThat(loadedCashPosting.getCurrency(), is(CurrencyUnit.USD)); + assertThat(loadedCashPosting.getForexCurrency(), is(CurrencyUnit.EUR)); + assertThat(loadedCashPosting.getExchangeRate(), is(new BigDecimal("2.0000000000"))); + } + + /** + * Verifies that invalid delivery forex metadata reports the exact converter diagnostic. + */ + @Test + public void testDeliveryWithNonPositiveForexMetadataRejectsWithDiagnostic() + { + var fixture = fixture(); + var delivery = createDelivery(fixture, PortfolioTransaction.Type.DELIVERY_INBOUND, + Money.of(CurrencyUnit.USD, Values.Amount.factorize(246)), EXCHANGE_RATE); + var entry = fixture.client().getLedger().getEntries().get(0); + var securityPosting = posting(entry, LedgerPostingType.SECURITY); + + securityPosting.setExchangeRate(BigDecimal.ZERO); + fixture.account().setCurrencyCode(CurrencyUnit.USD); + var snapshot = Snapshot.capture(fixture.client()); + + var exception = assertThrows(IllegalArgumentException.class, + () -> converter(fixture).convertDeliveryToBuySell(pair(fixture, delivery))); + + assertThat(exception.getMessage(), containsString(LedgerDiagnosticCode.LEDGER_FOREX_003.prefix())); + assertThat(Snapshot.capture(fixture.client()), is(snapshot)); + } + + /** + * Verifies that the composite converter keeps its own delivery forex diagnostic. + */ + @Test + public void testCompositeDeliveryCashPostingWithNonPositiveForexMetadataUsesDistinctDiagnostic() throws Exception + { + var fixture = fixture(); + var securityPosting = new LedgerPosting("security-posting"); + var cashPosting = new LedgerPosting("cash-posting"); + var method = LedgerPortfolioCompositeTypeConverter.class.getDeclaredMethod("applyDeliveryCashPosting", + LedgerPosting.class, LedgerPosting.class, Account.class); + + securityPosting.setAmount(Values.Amount.factorize(123)); + securityPosting.setCurrency(CurrencyUnit.EUR); + securityPosting.setForexAmount(Values.Amount.factorize(246)); + securityPosting.setForexCurrency(CurrencyUnit.USD); + securityPosting.setExchangeRate(BigDecimal.ZERO); + fixture.account().setCurrencyCode(CurrencyUnit.USD); + method.setAccessible(true); + + var exception = assertThrows(InvocationTargetException.class, + () -> method.invoke(compositeConverter(fixture), securityPosting, cashPosting, + fixture.account())); + + assertThat(exception.getCause(), instanceOf(IllegalArgumentException.class)); + assertThat(exception.getCause().getMessage(), containsString(LedgerDiagnosticCode.LEDGER_FOREX_004.prefix())); + } + + /** + * Verifies that composite buy/sell conversion reports posting forex rejection with its FOREX code. + */ + @Test + public void testCompositeBuySellPostingForexRejectsWithDiagnostic() + { + var fixture = fixture(); + var buySell = create(fixture, PortfolioTransaction.Type.BUY); + var entry = fixture.client().getLedger().getEntries().get(0); + var cashPosting = posting(entry, LedgerPostingType.CASH); + + cashPosting.setForexAmount(Values.Amount.factorize(123)); + cashPosting.setForexCurrency(CurrencyUnit.USD); + cashPosting.setExchangeRate(EXCHANGE_RATE); + var snapshot = Snapshot.capture(fixture.client()); + + var exception = assertThrows(UnsupportedOperationException.class, + () -> compositeConverter(fixture).convert(pair(fixture, buySell.getPortfolioTransaction()))); + + assertThat(exception.getMessage(), containsString(LedgerDiagnosticCode.LEDGER_FOREX_005.prefix())); + assertThat(Snapshot.capture(fixture.client()), is(snapshot)); + } + + /** + * Verifies that a plan-generated delivery can become buy/sell without losing its plan reference. + * The execution ref must follow the surviving portfolio projection after save/load. + */ + @Test + public void testInvestmentPlanReferencedDeliveryConvertsToBuySellAndUpdatesPlanReference() throws Exception + { + var fixture = fixture(); + var delivery = createDelivery(fixture, PortfolioTransaction.Type.DELIVERY_INBOUND); + var entry = fixture.client().getLedger().getEntries().get(0); + var plan = new InvestmentPlan("Plan"); + var projectionUUID = delivery.getUUID(); + + plan.addLedgerExecutionRef(InvestmentPlan.LedgerExecutionRef.of((LedgerBackedTransaction) delivery)); + fixture.client().addPlan(plan); + + converter(fixture).convertDeliveryToBuySell(pair(fixture, delivery)); + + assertThat(plan.getLedgerExecutionRefs().size(), is(1)); + assertThat(plan.getLedgerExecutionRefs().get(0).getLedgerEntryUUID(), is(entry.getUUID())); + assertThat(plan.getLedgerExecutionRefs().get(0).getProjectionUUID(), is(projectionUUID)); + assertThat(plan.getLedgerExecutionRefs().get(0).getProjectionRole(), is(LedgerProjectionRole.PORTFOLIO)); + assertThat(plan.getTransactions(fixture.client()).get(0).getTransaction().getUUID(), is(projectionUUID)); + + var loaded = loadXml(saveXml(fixture.client())); + assertThat(loaded.getPlans().get(0).getTransactions(loaded).get(0).getTransaction().getUUID(), + is(projectionUUID)); + } + + /** + * Verifies that delivery to buy/sell conversion rejects a malformed delivery before mutation. + * The converter must not infer missing security facts from the runtime projection. + */ + @Test + public void testMalformedDeliveryShapeRejectsBeforeMutation() + { + var fixture = fixture(); + var delivery = createDelivery(fixture, PortfolioTransaction.Type.DELIVERY_INBOUND); + var entry = fixture.client().getLedger().getEntries().get(0); + var securityPosting = posting(entry, LedgerPostingType.SECURITY); + + entry.removePosting(securityPosting); + var snapshot = Snapshot.capture(fixture.client()); + + assertThrows(IllegalArgumentException.class, + () -> converter(fixture).convertDeliveryToBuySell(pair(fixture, delivery))); + + assertThat(Snapshot.capture(fixture.client()), is(snapshot)); + } + + private void assertConvertsBuySellToDelivery(PortfolioTransaction.Type sourceType, LedgerEntryType targetEntryType, + PortfolioTransaction.Type targetPortfolioType, LedgerProjectionRole targetProjectionRole) + throws Exception + { + var fixture = fixture(); + var buySell = create(fixture, sourceType); + var accountTransaction = buySell.getAccountTransaction(); + var portfolioTransaction = buySell.getPortfolioTransaction(); + var entry = fixture.client().getLedger().getEntries().get(0); + var entryUUID = entry.getUUID(); + var securityPosting = posting(entry, LedgerPostingType.SECURITY); + var cashPostingUUID = posting(entry, LedgerPostingType.CASH).getUUID(); + var securityPostingUUID = securityPosting.getUUID(); + var accountProjectionUUID = projection(entry, LedgerProjectionRole.ACCOUNT).getUUID(); + var portfolioProjectionUUID = projection(entry, LedgerProjectionRole.PORTFOLIO).getUUID(); + var unitPostingUUIDs = unitPostingUUIDs(entry); + + var converted = converter(fixture).convertBuySellToDelivery(pair(fixture, portfolioTransaction)); + + assertThat(entry.getUUID(), is(entryUUID)); + assertThat(entry.getType(), is(targetEntryType)); + assertThat(entry.getPostings().stream().noneMatch(posting -> posting.getUUID().equals(cashPostingUUID)), + is(true)); + assertThat(posting(entry, LedgerPostingType.SECURITY).getUUID(), is(securityPostingUUID)); + assertThat(unitPostingUUIDs(entry), is(unitPostingUUIDs)); + assertThat(entry.getProjectionRefs().stream() + .noneMatch(projection -> projection.getUUID().equals(accountProjectionUUID)), is(true)); + assertThat(projection(entry, targetProjectionRole).getUUID(), is(portfolioProjectionUUID)); + assertSame(fixture.portfolio(), projection(entry, targetProjectionRole).getPortfolio()); + + assertTrue(fixture.account().getTransactions().isEmpty()); + assertThat(fixture.portfolio().getTransactions(), is(List.of(converted))); + assertThat(converted.getUUID(), is(portfolioProjectionUUID)); + assertThat(converted, instanceOf(LedgerBackedTransaction.class)); + assertThat(converted.getType(), is(targetPortfolioType)); + assertThat(converted.getCrossEntry(), is(nullValue())); + assertThat(converted.getDateTime(), is(DATE_TIME)); + assertThat(converted.getNote(), is("note")); + assertThat(converted.getSource(), is("source")); + assertThat(converted.getAmount(), is(Values.Amount.factorize(123))); + assertThat(converted.getCurrencyCode(), is(CurrencyUnit.EUR)); + assertSame(fixture.security(), converted.getSecurity()); + assertThat(converted.getShares(), is(Values.Share.factorize(5))); + assertThat(converted.getUnits().count(), is(3L)); + assertThat(fixture.client().getAllTransactions().size(), is(1)); + assertSame(converted, fixture.client().getAllTransactions().get(0).getTransaction()); + assertThat(accountTransaction.getUUID(), is(accountProjectionUUID)); + assertThat(portfolioTransaction.getUUID(), is(portfolioProjectionUUID)); + assertValid(fixture.client()); + + assertConvertedRoundtrip(loadXml(saveXml(fixture.client())), entryUUID, securityPostingUUID, + portfolioProjectionUUID, targetEntryType, targetPortfolioType, targetProjectionRole); + assertConvertedRoundtrip(loadProtobuf(saveProtobuf(fixture.client())), entryUUID, securityPostingUUID, + portfolioProjectionUUID, targetEntryType, targetPortfolioType, targetProjectionRole); + } + + private void assertConvertsDeliveryToBuySell(PortfolioTransaction.Type sourceType, LedgerEntryType targetEntryType, + PortfolioTransaction.Type targetTransactionType, LedgerProjectionRole sourceProjectionRole) + throws Exception + { + var fixture = fixture(); + var delivery = createDelivery(fixture, sourceType); + var entry = fixture.client().getLedger().getEntries().get(0); + var entryUUID = entry.getUUID(); + var securityPosting = posting(entry, LedgerPostingType.SECURITY); + var securityPostingUUID = securityPosting.getUUID(); + var portfolioProjectionUUID = projection(entry, sourceProjectionRole).getUUID(); + var unitPostingUUIDs = unitPostingUUIDs(entry); + + var converted = converter(fixture).convertDeliveryToBuySell(pair(fixture, delivery)); + var accountTransaction = converted.getAccountTransaction(); + var portfolioTransaction = converted.getPortfolioTransaction(); + var cashPosting = posting(entry, LedgerPostingType.CASH); + var accountProjection = projection(entry, LedgerProjectionRole.ACCOUNT); + var portfolioProjection = projection(entry, LedgerProjectionRole.PORTFOLIO); + + assertThat(entry.getUUID(), is(entryUUID)); + assertThat(entry.getType(), is(targetEntryType)); + assertThat(posting(entry, LedgerPostingType.SECURITY).getUUID(), is(securityPostingUUID)); + assertThat(unitPostingUUIDs(entry), is(unitPostingUUIDs)); + assertThat(cashPosting.getUUID().equals(securityPostingUUID), is(false)); + assertSame(fixture.account(), cashPosting.getAccount()); + assertThat(cashPosting.getAmount(), is(Values.Amount.factorize(123))); + assertThat(cashPosting.getCurrency(), is(CurrencyUnit.EUR)); + assertThat(cashPosting.getForexAmount(), is(nullValue())); + assertThat(cashPosting.getForexCurrency(), is(nullValue())); + assertThat(cashPosting.getExchangeRate(), is(nullValue())); + assertThat(accountProjection.getUUID().equals(portfolioProjectionUUID), is(false)); + assertSame(fixture.account(), accountProjection.getAccount()); + assertThat(portfolioProjection.getUUID(), is(portfolioProjectionUUID)); + assertSame(fixture.portfolio(), portfolioProjection.getPortfolio()); + + assertThat(fixture.account().getTransactions(), is(List.of(accountTransaction))); + assertThat(fixture.portfolio().getTransactions(), is(List.of(portfolioTransaction))); + assertThat(accountTransaction.getUUID(), is(accountProjection.getUUID())); + assertThat(portfolioTransaction.getUUID(), is(portfolioProjectionUUID)); + assertThat(accountTransaction, instanceOf(LedgerBackedTransaction.class)); + assertThat(portfolioTransaction, instanceOf(LedgerBackedTransaction.class)); + assertThat(accountTransaction.getType().name(), is(targetTransactionType.name())); + assertThat(portfolioTransaction.getType(), is(targetTransactionType)); + assertSame(portfolioTransaction, accountTransaction.getCrossEntry().getCrossTransaction(accountTransaction)); + assertSame(accountTransaction, portfolioTransaction.getCrossEntry().getCrossTransaction(portfolioTransaction)); + assertSame(fixture.portfolio(), accountTransaction.getCrossEntry().getCrossOwner(accountTransaction)); + assertSame(fixture.account(), portfolioTransaction.getCrossEntry().getCrossOwner(portfolioTransaction)); + assertThat(accountTransaction.getDateTime(), is(DATE_TIME)); + assertThat(portfolioTransaction.getDateTime(), is(DATE_TIME)); + assertThat(accountTransaction.getNote(), is("note")); + assertThat(portfolioTransaction.getNote(), is("note")); + assertThat(accountTransaction.getSource(), is("source")); + assertThat(portfolioTransaction.getSource(), is("source")); + assertThat(accountTransaction.getAmount(), is(Values.Amount.factorize(123))); + assertThat(accountTransaction.getCurrencyCode(), is(CurrencyUnit.EUR)); + assertThat(portfolioTransaction.getAmount(), is(Values.Amount.factorize(123))); + assertThat(portfolioTransaction.getCurrencyCode(), is(CurrencyUnit.EUR)); + assertSame(fixture.security(), portfolioTransaction.getSecurity()); + assertThat(portfolioTransaction.getShares(), is(Values.Share.factorize(5))); + assertThat(portfolioTransaction.getUnits().count(), is(3L)); + assertThat(fixture.client().getAllTransactions().size(), is(1)); + assertSame(portfolioTransaction, fixture.client().getAllTransactions().get(0).getTransaction()); + assertValid(fixture.client()); + + assertDeliveryToBuySellRoundtrip(loadXml(saveXml(fixture.client())), entryUUID, securityPostingUUID, + cashPosting.getUUID(), portfolioProjectionUUID, accountProjection.getUUID(), targetEntryType, + targetTransactionType); + assertDeliveryToBuySellRoundtrip(loadProtobuf(saveProtobuf(fixture.client())), entryUUID, securityPostingUUID, + cashPosting.getUUID(), portfolioProjectionUUID, accountProjection.getUUID(), targetEntryType, + targetTransactionType); + } + + private void assertCompositeConvertsBuySellToDelivery(PortfolioTransaction.Type sourceType, + LedgerEntryType targetEntryType, PortfolioTransaction.Type targetPortfolioType, + LedgerProjectionRole targetProjectionRole, long targetAmount) throws Exception + { + var fixture = fixture(); + var buySell = create(fixture, sourceType); + var portfolioTransaction = buySell.getPortfolioTransaction(); + var entry = fixture.client().getLedger().getEntries().get(0); + var entryUUID = entry.getUUID(); + var cashPostingUUID = posting(entry, LedgerPostingType.CASH).getUUID(); + var securityPostingUUID = posting(entry, LedgerPostingType.SECURITY).getUUID(); + var accountProjectionUUID = projection(entry, LedgerProjectionRole.ACCOUNT).getUUID(); + var portfolioProjectionUUID = projection(entry, LedgerProjectionRole.PORTFOLIO).getUUID(); + var unitPostingUUIDs = unitPostingUUIDs(entry); + + var converted = compositeConverter(fixture).convert(pair(fixture, portfolioTransaction)); + + assertThat(entry.getUUID(), is(entryUUID)); + assertThat(entry.getType(), is(targetEntryType)); + assertThat(entry.getPostings().stream().noneMatch(posting -> posting.getUUID().equals(cashPostingUUID)), + is(true)); + assertThat(posting(entry, LedgerPostingType.SECURITY).getUUID(), is(securityPostingUUID)); + assertThat(posting(entry, LedgerPostingType.SECURITY).getAmount(), is(targetAmount)); + assertThat(unitPostingUUIDs(entry), is(unitPostingUUIDs)); + assertThat(entry.getProjectionRefs().stream() + .noneMatch(projection -> projection.getUUID().equals(accountProjectionUUID)), is(true)); + assertThat(projection(entry, targetProjectionRole).getUUID(), is(portfolioProjectionUUID)); + + assertTrue(fixture.account().getTransactions().isEmpty()); + assertThat(fixture.portfolio().getTransactions(), is(List.of(converted))); + assertThat(converted.getUUID(), is(portfolioProjectionUUID)); + assertThat(converted.getType(), is(targetPortfolioType)); + assertThat(converted.getAmount(), is(targetAmount)); + assertThat(converted.getCrossEntry(), is(nullValue())); + assertThat(fixture.client().getAllTransactions().size(), is(1)); + assertSame(converted, fixture.client().getAllTransactions().get(0).getTransaction()); + assertValid(fixture.client()); + } + + private void assertCompositeConvertsDeliveryToBuySell(PortfolioTransaction.Type sourceType, + LedgerEntryType targetEntryType, PortfolioTransaction.Type targetTransactionType, + LedgerProjectionRole sourceProjectionRole, long targetAmount) throws Exception + { + var fixture = fixture(); + var delivery = createDelivery(fixture, sourceType); + var entry = fixture.client().getLedger().getEntries().get(0); + var entryUUID = entry.getUUID(); + var securityPostingUUID = posting(entry, LedgerPostingType.SECURITY).getUUID(); + var portfolioProjectionUUID = projection(entry, sourceProjectionRole).getUUID(); + var unitPostingUUIDs = unitPostingUUIDs(entry); + + var converted = compositeConverter(fixture).convert(pair(fixture, delivery)); + var accountTransaction = fixture.account().getTransactions().get(0); + var cashPosting = posting(entry, LedgerPostingType.CASH); + var accountProjection = projection(entry, LedgerProjectionRole.ACCOUNT); + var portfolioProjection = projection(entry, LedgerProjectionRole.PORTFOLIO); + + assertThat(entry.getUUID(), is(entryUUID)); + assertThat(entry.getType(), is(targetEntryType)); + assertThat(posting(entry, LedgerPostingType.SECURITY).getUUID(), is(securityPostingUUID)); + assertThat(posting(entry, LedgerPostingType.SECURITY).getAmount(), is(targetAmount)); + assertThat(unitPostingUUIDs(entry), is(unitPostingUUIDs)); + assertThat(cashPosting.getAmount(), is(targetAmount)); + assertSame(fixture.account(), cashPosting.getAccount()); + assertThat(portfolioProjection.getUUID(), is(portfolioProjectionUUID)); + assertSame(fixture.portfolio(), portfolioProjection.getPortfolio()); + + assertThat(fixture.account().getTransactions(), is(List.of(accountTransaction))); + assertThat(fixture.portfolio().getTransactions(), is(List.of(converted))); + assertThat(accountTransaction.getUUID(), is(accountProjection.getUUID())); + assertThat(converted.getUUID(), is(portfolioProjectionUUID)); + assertThat(accountTransaction.getType().name(), is(targetTransactionType.name())); + assertThat(converted.getType(), is(targetTransactionType)); + assertSame(accountTransaction, converted.getCrossEntry().getCrossTransaction(converted)); + assertSame(converted, accountTransaction.getCrossEntry().getCrossTransaction(accountTransaction)); + assertThat(accountTransaction.getAmount(), is(targetAmount)); + assertThat(converted.getAmount(), is(targetAmount)); + assertThat(fixture.client().getAllTransactions().size(), is(1)); + assertSame(converted, fixture.client().getAllTransactions().get(0).getTransaction()); + assertValid(fixture.client()); + } + + private void assertConvertedRoundtrip(Client client, String entryUUID, String securityPostingUUID, + String projectionUUID, LedgerEntryType entryType, PortfolioTransaction.Type transactionType, + LedgerProjectionRole projectionRole) + { + assertThat(client.getAccounts().get(0).getTransactions().isEmpty(), is(true)); + assertThat(client.getPortfolios().get(0).getTransactions().size(), is(1)); + assertThat(client.getAllTransactions().size(), is(1)); + + var entry = client.getLedger().getEntries().get(0); + var transaction = client.getPortfolios().get(0).getTransactions().get(0); + + assertThat(entry.getUUID(), is(entryUUID)); + assertThat(entry.getType(), is(entryType)); + assertThat(posting(entry, LedgerPostingType.SECURITY).getUUID(), is(securityPostingUUID)); + assertThat(entry.getPostings().stream().noneMatch(posting -> posting.getType() == LedgerPostingType.CASH), + is(true)); + assertThat(entry.getProjectionRefs().size(), is(1)); + assertThat(projection(entry, projectionRole).getUUID(), is(projectionUUID)); + assertThat(transaction.getUUID(), is(projectionUUID)); + assertThat(transaction.getType(), is(transactionType)); + assertThat(transaction.getCrossEntry(), is(nullValue())); + assertThat(transaction.getUnits().count(), is(3L)); + assertValid(client); + } + + private void assertDeliveryToBuySellRoundtrip(Client client, String entryUUID, String securityPostingUUID, + String cashPostingUUID, String portfolioProjectionUUID, String accountProjectionUUID, + LedgerEntryType entryType, PortfolioTransaction.Type transactionType) + { + assertThat(client.getAccounts().get(0).getTransactions().size(), is(1)); + assertThat(client.getPortfolios().get(0).getTransactions().size(), is(1)); + assertThat(client.getAllTransactions().size(), is(1)); + + var entry = client.getLedger().getEntries().get(0); + var accountTransaction = client.getAccounts().get(0).getTransactions().get(0); + var portfolioTransaction = client.getPortfolios().get(0).getTransactions().get(0); + + assertThat(entry.getUUID(), is(entryUUID)); + assertThat(entry.getType(), is(entryType)); + assertThat(posting(entry, LedgerPostingType.SECURITY).getUUID(), is(securityPostingUUID)); + assertThat(posting(entry, LedgerPostingType.CASH).getUUID(), is(cashPostingUUID)); + assertThat(projection(entry, LedgerProjectionRole.ACCOUNT).getUUID(), is(accountProjectionUUID)); + assertThat(projection(entry, LedgerProjectionRole.PORTFOLIO).getUUID(), is(portfolioProjectionUUID)); + assertThat(accountTransaction.getUUID(), is(accountProjectionUUID)); + assertThat(portfolioTransaction.getUUID(), is(portfolioProjectionUUID)); + assertThat(accountTransaction.getType().name(), is(transactionType.name())); + assertThat(portfolioTransaction.getType(), is(transactionType)); + assertSame(portfolioTransaction, accountTransaction.getCrossEntry().getCrossTransaction(accountTransaction)); + assertSame(accountTransaction, portfolioTransaction.getCrossEntry().getCrossTransaction(portfolioTransaction)); + assertThat(portfolioTransaction.getUnits().count(), is(3L)); + assertValid(client); + } + + private name.abuchen.portfolio.model.BuySellEntry create(Fixture fixture, PortfolioTransaction.Type type) + { + return new LedgerBuySellTransactionCreator(fixture.client()).create(fixture.portfolio(), fixture.account(), + type, DATE_TIME, Values.Amount.factorize(123), CurrencyUnit.EUR, fixture.security(), + Values.Share.factorize(5), units(), "note", "source"); + } + + private PortfolioTransaction createDelivery(Fixture fixture, PortfolioTransaction.Type type) + { + return createDelivery(fixture, type, null, null); + } + + private PortfolioTransaction createDelivery(Fixture fixture, PortfolioTransaction.Type type, Money forexAmount, + BigDecimal exchangeRate) + { + return new LedgerDeliveryTransactionCreator(fixture.client()).create(fixture.portfolio(), type, DATE_TIME, + Values.Amount.factorize(123), CurrencyUnit.EUR, fixture.security(), Values.Share.factorize(5), + forexAmount, exchangeRate, units(), "note", "source"); + } + + private List units() + { + return List.of( + new Unit(Unit.Type.FEE, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(3)), + Money.of(CurrencyUnit.USD, Values.Amount.factorize(6)), EXCHANGE_RATE), + new Unit(Unit.Type.TAX, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(4))), + new Unit(Unit.Type.GROSS_VALUE, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(120)), + Money.of(CurrencyUnit.USD, Values.Amount.factorize(240)), EXCHANGE_RATE)); + } + + private LedgerBuySellDeliveryConverter converter(Fixture fixture) + { + return new LedgerBuySellDeliveryConverter(fixture.client()); + } + + private LedgerPortfolioCompositeTypeConverter compositeConverter(Fixture fixture) + { + return new LedgerPortfolioCompositeTypeConverter(fixture.client()); + } + + private TransactionPair pair(Fixture fixture, PortfolioTransaction transaction) + { + return new TransactionPair<>(fixture.portfolio(), transaction); + } + + private LedgerProjectionRef projection(LedgerEntry entry, LedgerProjectionRole role) + { + return entry.getProjectionRefs().stream().filter(projection -> projection.getRole() == role).findFirst() + .orElseThrow(); + } + + private LedgerPosting posting(LedgerEntry entry, LedgerPostingType type) + { + return entry.getPostings().stream().filter(posting -> posting.getType() == type).findFirst().orElseThrow(); + } + + private List unitPostingUUIDs(LedgerEntry entry) + { + return entry.getPostings().stream().filter(posting -> posting.getType() == LedgerPostingType.FEE + || posting.getType() == LedgerPostingType.TAX + || posting.getType() == LedgerPostingType.GROSS_VALUE).map(LedgerPosting::getUUID).toList(); + } + + private String saveXml(Client client) throws IOException + { + var file = File.createTempFile("ledger-buy-sell-delivery-converter", ".xml"); + try + { + ClientFactory.save(client, file); + return Files.readString(file.toPath(), StandardCharsets.UTF_8); + } + finally + { + Files.deleteIfExists(file.toPath()); + } + } + + private Client loadXml(String xml) throws IOException + { + return ClientFactory.load(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); + } + + private byte[] saveProtobuf(Client client) throws IOException + { + return ProtobufTestUtilities.save(client); + } + + private Client loadProtobuf(byte[] bytes) throws IOException + { + return ProtobufTestUtilities.load(bytes); + } + + private void assertValid(Client client) + { + var result = LedgerStructuralValidator.validate(client.getLedger()); + + assertThat(result.format(), result.isOK(), is(true)); + } + + private Fixture fixture() + { + var client = new Client(); + var account = new Account("Account"); + var portfolio = new Portfolio("Portfolio"); + var security = new Security("Security", CurrencyUnit.EUR); + + account.setCurrencyCode(CurrencyUnit.EUR); + portfolio.setReferenceAccount(account); + account.setUpdatedAt(Instant.now()); + portfolio.setUpdatedAt(Instant.now()); + security.setUpdatedAt(Instant.now()); + client.addAccount(account); + client.addPortfolio(portfolio); + client.addSecurity(security); + + return new Fixture(client, account, portfolio, security); + } + + private record Fixture(Client client, Account account, Portfolio portfolio, Security security) + { + } + + private record Snapshot(List entries, List accountTransactions, + List portfolioTransactions, List allTransactions) + { + static Snapshot capture(Client client) + { + return new Snapshot(client.getLedger().getEntries().stream().map(EntrySnapshot::capture).toList(), + client.getAccounts().stream().flatMap(account -> account.getTransactions().stream()) + .map(Transaction::getUUID).toList(), + client.getPortfolios().stream().flatMap(portfolio -> portfolio.getTransactions().stream()) + .map(Transaction::getUUID).toList(), + client.getAllTransactions().stream().map(pair -> pair.getTransaction().getUUID()) + .toList()); + } + } + + private record EntrySnapshot(String uuid, LedgerEntryType type, List postings, + List projections) + { + static EntrySnapshot capture(LedgerEntry entry) + { + return new EntrySnapshot(entry.getUUID(), entry.getType(), + entry.getPostings().stream().map(PostingSnapshot::capture).toList(), + entry.getProjectionRefs().stream().map(ProjectionSnapshot::capture).toList()); + } + } + + private record PostingSnapshot(String uuid, LedgerPostingType type, Long amount, String currency, Long forexAmount, + String forexCurrency, BigDecimal exchangeRate, Security security, Long shares, Account account, + Portfolio portfolio) + { + static PostingSnapshot capture(LedgerPosting posting) + { + return new PostingSnapshot(posting.getUUID(), posting.getType(), posting.getAmount(), posting.getCurrency(), + posting.getForexAmount(), posting.getForexCurrency(), posting.getExchangeRate(), + posting.getSecurity(), posting.getShares(), posting.getAccount(), posting.getPortfolio()); + } + } + + private record ProjectionSnapshot(String uuid, LedgerProjectionRole role, Account account, Portfolio portfolio, + String primaryPostingUUID, String postingGroupUUID) + { + static ProjectionSnapshot capture(LedgerProjectionRef projection) + { + return new ProjectionSnapshot(projection.getUUID(), projection.getRole(), projection.getAccount(), + projection.getPortfolio(), projection.getPrimaryPostingUUID(), + projection.getPostingGroupUUID()); + } + } +} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellReversalConverterTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellReversalConverterTest.java new file mode 100644 index 0000000000..243a8044fc --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellReversalConverterTest.java @@ -0,0 +1,472 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import static org.hamcrest.CoreMatchers.instanceOf; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.Test; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.BuySellEntry; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.ClientFactory; +import name.abuchen.portfolio.model.InvestmentPlan; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.PortfolioTransaction; +import name.abuchen.portfolio.model.ProtobufTestUtilities; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.Transaction; +import name.abuchen.portfolio.model.Transaction.Unit; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.Values; + +/** + * Tests ledger-aware reversal between buy and sell transactions. + * These tests make sure the same booking changes direction without replacing projections or plan references incorrectly. + */ +@SuppressWarnings("nls") +public class LedgerBuySellReversalConverterTest +{ + private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 6, 8, 9, 10); + private static final BigDecimal EXCHANGE_RATE = new BigDecimal("0.5000"); + + /** + * Verifies that a ledger-backed buy can be reversed into a sell through the converter. + * The booking identity, projections, units, and owner lists must stay consistent after save/load. + */ + @Test + public void testReversesLedgerBackedBuyToSellPreservingIdentityAndTruth() throws Exception + { + assertReversesBuySell(PortfolioTransaction.Type.BUY, LedgerEntryType.SELL, PortfolioTransaction.Type.SELL, + Values.Amount.factorize(113)); + } + + /** + * Verifies that a ledger-backed sell can be reversed into a buy through the converter. + * The booking identity, projections, units, and owner lists must stay consistent after save/load. + */ + @Test + public void testReversesLedgerBackedSellToBuyPreservingIdentityAndTruth() throws Exception + { + assertReversesBuySell(PortfolioTransaction.Type.SELL, LedgerEntryType.BUY, PortfolioTransaction.Type.BUY, + Values.Amount.factorize(127)); + } + + /** + * Verifies that reversal stops before mutation when the account-side projection is missing. + * The converter must not rebuild a missing runtime view from guesses. + */ + @Test + public void testMalformedBuySellMissingAccountProjectionRejectsBeforeMutation() + { + var fixture = fixture(); + var buySell = create(fixture, PortfolioTransaction.Type.BUY); + var entry = fixture.client().getLedger().getEntries().get(0); + + entry.removeProjectionRef(projection(entry, LedgerProjectionRole.ACCOUNT)); + assertRejectsWithoutMutation(fixture, () -> converter(fixture).reverse(buySell), IllegalArgumentException.class); + } + + /** + * Verifies that reversal stops before mutation when the portfolio-side projection is missing. + * The ledger entry and owner lists must remain unchanged instead of creating a second truth. + */ + @Test + public void testMalformedBuySellMissingPortfolioProjectionRejectsBeforeMutation() + { + var fixture = fixture(); + var buySell = create(fixture, PortfolioTransaction.Type.BUY); + var entry = fixture.client().getLedger().getEntries().get(0); + + entry.removeProjectionRef(projection(entry, LedgerProjectionRole.PORTFOLIO)); + assertRejectsWithoutMutation(fixture, () -> converter(fixture).reverse(buySell), IllegalArgumentException.class); + } + + /** + * Verifies that reversal stops before mutation when the cash posting is missing. + * A buy/sell direction change cannot infer the missing account-side cash facts safely. + */ + @Test + public void testMalformedBuySellMissingCashPostingRejectsBeforeMutation() + { + var fixture = fixture(); + var buySell = create(fixture, PortfolioTransaction.Type.BUY); + var entry = fixture.client().getLedger().getEntries().get(0); + + entry.removePosting(posting(entry, LedgerPostingType.CASH)); + assertRejectsWithoutMutation(fixture, () -> converter(fixture).reverse(buySell), IllegalArgumentException.class); + } + + /** + * Verifies that reversal stops before mutation when the security posting is missing. + * The converter must not infer security facts from the projected portfolio transaction. + */ + @Test + public void testMalformedBuySellMissingSecurityPostingRejectsBeforeMutation() + { + var fixture = fixture(); + var buySell = create(fixture, PortfolioTransaction.Type.BUY); + var entry = fixture.client().getLedger().getEntries().get(0); + + entry.removePosting(posting(entry, LedgerPostingType.SECURITY)); + assertRejectsWithoutMutation(fixture, () -> converter(fixture).reverse(buySell), IllegalArgumentException.class); + } + + /** + * Verifies that buy/sell reversal does not reinterpret unsupported forex cash facts. + * The converter must reject before mutation when it cannot preserve those facts safely. + */ + @Test + public void testUnsupportedCashForexRejectsBeforeMutation() + { + var fixture = fixture(); + var buySell = create(fixture, PortfolioTransaction.Type.BUY); + var cashPosting = posting(fixture.client().getLedger().getEntries().get(0), LedgerPostingType.CASH); + + cashPosting.setForexAmount(Values.Amount.factorize(246)); + cashPosting.setForexCurrency(CurrencyUnit.USD); + cashPosting.setExchangeRate(EXCHANGE_RATE); + + assertThat(converter(fixture).canReverseSafely(buySell), is(false)); + assertRejectsWithoutMutation(fixture, () -> converter(fixture).reverse(buySell), + UnsupportedOperationException.class); + } + + /** + * Verifies that a plan-generated buy/sell booking can be reversed without losing the plan reference. + * The execution ref must still resolve to the same projected booking after save/load. + */ + @Test + public void testInvestmentPlanReferencedBuySellReversesAndKeepsPlanReference() throws Exception + { + var fixture = fixture(); + var buySell = create(fixture, PortfolioTransaction.Type.BUY); + var entry = fixture.client().getLedger().getEntries().get(0); + var plan = new InvestmentPlan("Plan"); + var projectionUUID = buySell.getPortfolioTransaction().getUUID(); + + plan.addLedgerExecutionRef(InvestmentPlan.LedgerExecutionRef + .of((LedgerBackedTransaction) buySell.getPortfolioTransaction())); + fixture.client().addPlan(plan); + + converter(fixture).reverse(buySell); + + assertThat(plan.getLedgerExecutionRefs().size(), is(1)); + assertThat(plan.getLedgerExecutionRefs().get(0).getLedgerEntryUUID(), is(entry.getUUID())); + assertThat(plan.getLedgerExecutionRefs().get(0).getProjectionUUID(), is(projectionUUID)); + assertThat(plan.getLedgerExecutionRefs().get(0).getProjectionRole(), is(LedgerProjectionRole.PORTFOLIO)); + assertThat(plan.getTransactions(fixture.client()).get(0).getTransaction().getUUID(), is(projectionUUID)); + + var loaded = loadXml(saveXml(fixture.client())); + assertThat(loaded.getPlans().get(0).getTransactions(loaded).get(0).getTransaction().getUUID(), + is(projectionUUID)); + } + + /** + * Verifies that an entry-only plan reference blocks buy/sell reversal. + * The converter must not guess which projection the plan intended to follow. + */ + @Test + public void testEntryOnlyInvestmentPlanExecutionRefRejectsBuySellReversalBeforeMutation() + { + var fixture = fixture(); + var buySell = create(fixture, PortfolioTransaction.Type.BUY); + var entry = fixture.client().getLedger().getEntries().get(0); + var plan = new InvestmentPlan("Plan"); + + plan.addLedgerExecutionRef(new InvestmentPlan.LedgerExecutionRef(entry.getUUID(), null, null)); + fixture.client().addPlan(plan); + + var exception = assertRejectsWithoutMutation(fixture, () -> converter(fixture).reverse(buySell), + UnsupportedOperationException.class); + assertThat(exception.getMessage(), containsString("ambiguous")); + assertThat(plan.getLedgerExecutionRefs().size(), is(1)); + assertThat(plan.getLedgerExecutionRefs().get(0).getLedgerEntryUUID(), is(entry.getUUID())); + } + + private void assertReversesBuySell(PortfolioTransaction.Type sourceType, LedgerEntryType targetEntryType, + PortfolioTransaction.Type targetTransactionType, long expectedAmount) throws Exception + { + var fixture = fixture(); + var buySell = create(fixture, sourceType); + var accountTransaction = buySell.getAccountTransaction(); + var portfolioTransaction = buySell.getPortfolioTransaction(); + var entry = fixture.client().getLedger().getEntries().get(0); + var entryUUID = entry.getUUID(); + var cashPostingUUID = posting(entry, LedgerPostingType.CASH).getUUID(); + var securityPostingUUID = posting(entry, LedgerPostingType.SECURITY).getUUID(); + var accountProjectionUUID = projection(entry, LedgerProjectionRole.ACCOUNT).getUUID(); + var portfolioProjectionUUID = projection(entry, LedgerProjectionRole.PORTFOLIO).getUUID(); + var unitSnapshots = unitSnapshots(entry); + + var reversed = converter(fixture).reverse(buySell); + var reversedAccount = reversed.getAccountTransaction(); + var reversedPortfolio = reversed.getPortfolioTransaction(); + + assertThat(entry.getUUID(), is(entryUUID)); + assertThat(entry.getType(), is(targetEntryType)); + assertThat(posting(entry, LedgerPostingType.CASH).getUUID(), is(cashPostingUUID)); + assertThat(posting(entry, LedgerPostingType.SECURITY).getUUID(), is(securityPostingUUID)); + assertThat(posting(entry, LedgerPostingType.CASH).getAmount(), is(expectedAmount)); + assertThat(posting(entry, LedgerPostingType.SECURITY).getAmount(), is(expectedAmount)); + assertThat(unitSnapshots(entry), is(unitSnapshots)); + assertThat(projection(entry, LedgerProjectionRole.ACCOUNT).getUUID(), is(accountProjectionUUID)); + assertThat(projection(entry, LedgerProjectionRole.PORTFOLIO).getUUID(), is(portfolioProjectionUUID)); + + assertThat(fixture.account().getTransactions(), is(List.of(reversedAccount))); + assertThat(fixture.portfolio().getTransactions(), is(List.of(reversedPortfolio))); + assertThat(fixture.client().getAllTransactions().size(), is(1)); + assertSame(reversedPortfolio, fixture.client().getAllTransactions().get(0).getTransaction()); + assertThat(reversedAccount.getUUID(), is(accountProjectionUUID)); + assertThat(reversedPortfolio.getUUID(), is(portfolioProjectionUUID)); + assertThat(reversedAccount, instanceOf(LedgerBackedTransaction.class)); + assertThat(reversedPortfolio, instanceOf(LedgerBackedTransaction.class)); + assertThat(reversedAccount.getType().name(), is(targetTransactionType.name())); + assertThat(reversedPortfolio.getType(), is(targetTransactionType)); + assertSame(reversedPortfolio, reversedAccount.getCrossEntry().getCrossTransaction(reversedAccount)); + assertSame(reversedAccount, reversedPortfolio.getCrossEntry().getCrossTransaction(reversedPortfolio)); + assertThat(reversedAccount.getDateTime(), is(DATE_TIME)); + assertThat(reversedPortfolio.getDateTime(), is(DATE_TIME)); + assertThat(reversedAccount.getNote(), is("note")); + assertThat(reversedPortfolio.getNote(), is("note")); + assertThat(reversedAccount.getSource(), is("source")); + assertThat(reversedPortfolio.getSource(), is("source")); + assertSame(fixture.security(), reversedPortfolio.getSecurity()); + assertThat(reversedPortfolio.getShares(), is(Values.Share.factorize(5))); + assertThat(reversedAccount.getAmount(), is(expectedAmount)); + assertThat(reversedPortfolio.getAmount(), is(expectedAmount)); + assertThat(reversedPortfolio.getGrossValue(), is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(120)))); + assertThat(reversedPortfolio.getUnit(Unit.Type.GROSS_VALUE).orElseThrow().getAmount(), + is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(120)))); + assertThat(reversedPortfolio.getUnitSum(Unit.Type.FEE), is(Money.of(CurrencyUnit.EUR, + Values.Amount.factorize(3)))); + assertThat(reversedPortfolio.getUnitSum(Unit.Type.TAX), is(Money.of(CurrencyUnit.EUR, + Values.Amount.factorize(4)))); + assertThat(accountTransaction.getUUID(), is(accountProjectionUUID)); + assertThat(portfolioTransaction.getUUID(), is(portfolioProjectionUUID)); + assertValid(fixture.client()); + + assertRoundtrip(loadXml(saveXml(fixture.client())), entryUUID, cashPostingUUID, securityPostingUUID, + accountProjectionUUID, portfolioProjectionUUID, targetEntryType, targetTransactionType, + expectedAmount); + assertRoundtrip(loadProtobuf(saveProtobuf(fixture.client())), entryUUID, cashPostingUUID, securityPostingUUID, + accountProjectionUUID, portfolioProjectionUUID, targetEntryType, targetTransactionType, + expectedAmount); + } + + private void assertRoundtrip(Client client, String entryUUID, String cashPostingUUID, String securityPostingUUID, + String accountProjectionUUID, String portfolioProjectionUUID, LedgerEntryType entryType, + PortfolioTransaction.Type transactionType, long expectedAmount) + { + assertThat(client.getAccounts().get(0).getTransactions().size(), is(1)); + assertThat(client.getPortfolios().get(0).getTransactions().size(), is(1)); + assertThat(client.getAllTransactions().size(), is(1)); + + var entry = client.getLedger().getEntries().get(0); + var accountTransaction = client.getAccounts().get(0).getTransactions().get(0); + var portfolioTransaction = client.getPortfolios().get(0).getTransactions().get(0); + + assertThat(entry.getUUID(), is(entryUUID)); + assertThat(entry.getType(), is(entryType)); + assertThat(posting(entry, LedgerPostingType.CASH).getUUID(), is(cashPostingUUID)); + assertThat(posting(entry, LedgerPostingType.SECURITY).getUUID(), is(securityPostingUUID)); + assertThat(posting(entry, LedgerPostingType.CASH).getAmount(), is(expectedAmount)); + assertThat(posting(entry, LedgerPostingType.SECURITY).getAmount(), is(expectedAmount)); + assertThat(projection(entry, LedgerProjectionRole.ACCOUNT).getUUID(), is(accountProjectionUUID)); + assertThat(projection(entry, LedgerProjectionRole.PORTFOLIO).getUUID(), is(portfolioProjectionUUID)); + assertThat(accountTransaction.getType().name(), is(transactionType.name())); + assertThat(portfolioTransaction.getType(), is(transactionType)); + assertThat(accountTransaction.getUUID(), is(accountProjectionUUID)); + assertThat(portfolioTransaction.getUUID(), is(portfolioProjectionUUID)); + assertSame(portfolioTransaction, accountTransaction.getCrossEntry().getCrossTransaction(accountTransaction)); + assertSame(accountTransaction, portfolioTransaction.getCrossEntry().getCrossTransaction(portfolioTransaction)); + assertThat(portfolioTransaction.getGrossValue(), is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(120)))); + assertValid(client); + } + + private T assertRejectsWithoutMutation(Fixture fixture, ThrowingRunnable runnable, + Class expectedType) + { + var snapshot = Snapshot.capture(fixture.client()); + + var exception = assertThrows(expectedType, runnable::run); + assertThat(Snapshot.capture(fixture.client()), is(snapshot)); + return exception; + } + + private BuySellEntry create(Fixture fixture, PortfolioTransaction.Type type) + { + return new LedgerBuySellTransactionCreator(fixture.client()).create(fixture.portfolio(), fixture.account(), + type, DATE_TIME, Values.Amount.factorize(123), CurrencyUnit.EUR, fixture.security(), + Values.Share.factorize(5), units(), "note", "source"); + } + + private List units() + { + return List.of( + new Unit(Unit.Type.FEE, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(3)), + Money.of(CurrencyUnit.USD, Values.Amount.factorize(6)), EXCHANGE_RATE), + new Unit(Unit.Type.TAX, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(4))), + new Unit(Unit.Type.GROSS_VALUE, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(120)), + Money.of(CurrencyUnit.USD, Values.Amount.factorize(240)), EXCHANGE_RATE)); + } + + private LedgerBuySellReversalConverter converter(Fixture fixture) + { + return new LedgerBuySellReversalConverter(fixture.client()); + } + + private LedgerProjectionRef projection(LedgerEntry entry, LedgerProjectionRole role) + { + return entry.getProjectionRefs().stream().filter(projection -> projection.getRole() == role).findFirst() + .orElseThrow(); + } + + private LedgerPosting posting(LedgerEntry entry, LedgerPostingType type) + { + return entry.getPostings().stream().filter(posting -> posting.getType() == type).findFirst().orElseThrow(); + } + + private List unitSnapshots(LedgerEntry entry) + { + return entry.getPostings().stream().filter(posting -> posting.getType() == LedgerPostingType.FEE + || posting.getType() == LedgerPostingType.TAX + || posting.getType() == LedgerPostingType.GROSS_VALUE).map(PostingSnapshot::capture).toList(); + } + + private String saveXml(Client client) throws IOException + { + var file = File.createTempFile("ledger-buy-sell-reversal-converter", ".xml"); + try + { + ClientFactory.save(client, file); + return Files.readString(file.toPath(), StandardCharsets.UTF_8); + } + finally + { + Files.deleteIfExists(file.toPath()); + } + } + + private Client loadXml(String xml) throws IOException + { + return ClientFactory.load(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); + } + + private byte[] saveProtobuf(Client client) throws IOException + { + return ProtobufTestUtilities.save(client); + } + + private Client loadProtobuf(byte[] bytes) throws IOException + { + return ProtobufTestUtilities.load(bytes); + } + + private void assertValid(Client client) + { + var result = LedgerStructuralValidator.validate(client.getLedger()); + + assertThat(result.format(), result.isOK(), is(true)); + } + + private Fixture fixture() + { + var client = new Client(); + var account = new Account("Account"); + var portfolio = new Portfolio("Portfolio"); + var security = new Security("Security", CurrencyUnit.EUR); + + account.setCurrencyCode(CurrencyUnit.EUR); + portfolio.setReferenceAccount(account); + account.setUpdatedAt(Instant.now()); + portfolio.setUpdatedAt(Instant.now()); + security.setUpdatedAt(Instant.now()); + client.addAccount(account); + client.addPortfolio(portfolio); + client.addSecurity(security); + + return new Fixture(client, account, portfolio, security); + } + + @FunctionalInterface + private interface ThrowingRunnable + { + void run() throws Exception; + } + + private record Fixture(Client client, Account account, Portfolio portfolio, Security security) + { + } + + private record Snapshot(List entries, List accountTransactions, + List portfolioTransactions, List allTransactions) + { + static Snapshot capture(Client client) + { + return new Snapshot(client.getLedger().getEntries().stream().map(EntrySnapshot::capture).toList(), + client.getAccounts().stream().flatMap(account -> account.getTransactions().stream()) + .map(Transaction::getUUID).toList(), + client.getPortfolios().stream().flatMap(portfolio -> portfolio.getTransactions().stream()) + .map(Transaction::getUUID).toList(), + client.getAllTransactions().stream().map(pair -> pair.getTransaction().getUUID()) + .toList()); + } + } + + private record EntrySnapshot(String uuid, LedgerEntryType type, List postings, + List projections) + { + static EntrySnapshot capture(LedgerEntry entry) + { + return new EntrySnapshot(entry.getUUID(), entry.getType(), + entry.getPostings().stream().map(PostingSnapshot::capture).toList(), + entry.getProjectionRefs().stream().map(ProjectionSnapshot::capture).toList()); + } + } + + private record PostingSnapshot(String uuid, LedgerPostingType type, Long amount, String currency, Long forexAmount, + String forexCurrency, BigDecimal exchangeRate, Security security, Long shares, Account account, + Portfolio portfolio) + { + static PostingSnapshot capture(LedgerPosting posting) + { + return new PostingSnapshot(posting.getUUID(), posting.getType(), posting.getAmount(), posting.getCurrency(), + posting.getForexAmount(), posting.getForexCurrency(), posting.getExchangeRate(), + posting.getSecurity(), posting.getShares(), posting.getAccount(), posting.getPortfolio()); + } + } + + private record ProjectionSnapshot(String uuid, LedgerProjectionRole role, Account account, Portfolio portfolio, + String primaryPostingUUID, String postingGroupUUID) + { + static ProjectionSnapshot capture(LedgerProjectionRef projection) + { + return new ProjectionSnapshot(projection.getUUID(), projection.getRole(), projection.getAccount(), + projection.getPortfolio(), projection.getPrimaryPostingUUID(), + projection.getPostingGroupUUID()); + } + } +} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryDirectionConverterTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryDirectionConverterTest.java new file mode 100644 index 0000000000..d5773affc4 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryDirectionConverterTest.java @@ -0,0 +1,407 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import static org.hamcrest.CoreMatchers.instanceOf; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.Test; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.ClientFactory; +import name.abuchen.portfolio.model.InvestmentPlan; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.PortfolioTransaction; +import name.abuchen.portfolio.model.ProtobufTestUtilities; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.Transaction; +import name.abuchen.portfolio.model.Transaction.Unit; +import name.abuchen.portfolio.model.TransactionPair; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.Values; + +/** + * Tests ledger-aware reversal between inbound and outbound deliveries. + * These tests make sure a delivery changes direction without creating duplicate transaction truth. + */ +@SuppressWarnings("nls") +public class LedgerDeliveryDirectionConverterTest +{ + private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 6, 8, 9, 10); + private static final BigDecimal EXCHANGE_RATE = new BigDecimal("0.5000"); + + /** + * Verifies that an inbound delivery can be reversed into an outbound delivery. + * The same ledger entry and projection must continue to represent the booking after save/load. + */ + @Test + public void testReversesLedgerBackedInboundDeliveryToOutboundPreservingIdentityAndTruth() throws Exception + { + assertReversesDelivery(PortfolioTransaction.Type.DELIVERY_INBOUND, LedgerEntryType.DELIVERY_OUTBOUND, + PortfolioTransaction.Type.DELIVERY_OUTBOUND, LedgerProjectionRole.DELIVERY_INBOUND, + LedgerProjectionRole.DELIVERY_OUTBOUND, Values.Amount.factorize(113)); + } + + /** + * Verifies that an outbound delivery can be reversed into an inbound delivery. + * The same ledger entry and projection must continue to represent the booking after save/load. + */ + @Test + public void testReversesLedgerBackedOutboundDeliveryToInboundPreservingIdentityAndTruth() throws Exception + { + assertReversesDelivery(PortfolioTransaction.Type.DELIVERY_OUTBOUND, LedgerEntryType.DELIVERY_INBOUND, + PortfolioTransaction.Type.DELIVERY_INBOUND, LedgerProjectionRole.DELIVERY_OUTBOUND, + LedgerProjectionRole.DELIVERY_INBOUND, Values.Amount.factorize(127)); + } + + /** + * Verifies that delivery reversal rejects a missing portfolio projection before mutation. + * The converter must not rebuild the runtime view as a second booking truth. + */ + @Test + public void testMalformedDeliveryMissingProjectionRejectsBeforeMutation() + { + var fixture = fixture(); + var delivery = create(fixture, PortfolioTransaction.Type.DELIVERY_INBOUND); + var entry = fixture.client().getLedger().getEntries().get(0); + + entry.removeProjectionRef(projection(entry, LedgerProjectionRole.DELIVERY_INBOUND)); + + assertRejectsWithoutMutation(fixture, () -> converter(fixture).reverse(pair(fixture, delivery)), + IllegalArgumentException.class); + } + + /** + * Verifies that delivery reversal rejects a missing security posting before mutation. + * Missing security and share facts must not be inferred from the projection. + */ + @Test + public void testMalformedDeliveryMissingSecurityPostingRejectsBeforeMutation() + { + var fixture = fixture(); + var delivery = create(fixture, PortfolioTransaction.Type.DELIVERY_INBOUND); + var entry = fixture.client().getLedger().getEntries().get(0); + + entry.removePosting(posting(entry, LedgerPostingType.SECURITY)); + + assertRejectsWithoutMutation(fixture, () -> converter(fixture).reverse(pair(fixture, delivery)), + IllegalArgumentException.class); + } + + /** + * Verifies that delivery reversal does not reinterpret unsupported forex posting facts. + * The converter must reject before mutation when those facts cannot be preserved safely. + */ + @Test + public void testUnsupportedDeliveryPostingForexRejectsBeforeMutation() + { + var fixture = fixture(); + var delivery = create(fixture, PortfolioTransaction.Type.DELIVERY_INBOUND, + Money.of(CurrencyUnit.USD, Values.Amount.factorize(246)), EXCHANGE_RATE); + + assertRejectsWithoutMutation(fixture, () -> converter(fixture).reverse(pair(fixture, delivery)), + UnsupportedOperationException.class); + } + + /** + * Verifies that a plan-generated delivery can be reversed safely. + * The plan reference must still resolve to the same generated booking after save/load. + */ + @Test + public void testInvestmentPlanReferencedDeliveryReversesAndUpdatesPlanReference() throws Exception + { + var fixture = fixture(); + var delivery = create(fixture, PortfolioTransaction.Type.DELIVERY_INBOUND); + var entry = fixture.client().getLedger().getEntries().get(0); + var plan = new InvestmentPlan("Plan"); + var projectionUUID = delivery.getUUID(); + + plan.addLedgerExecutionRef(InvestmentPlan.LedgerExecutionRef.of((LedgerBackedTransaction) delivery)); + fixture.client().addPlan(plan); + + converter(fixture).reverse(pair(fixture, delivery)); + + assertThat(plan.getLedgerExecutionRefs().size(), is(1)); + assertThat(plan.getLedgerExecutionRefs().get(0).getLedgerEntryUUID(), is(entry.getUUID())); + assertThat(plan.getLedgerExecutionRefs().get(0).getProjectionUUID(), is(projectionUUID)); + assertThat(plan.getLedgerExecutionRefs().get(0).getProjectionRole(), + is(LedgerProjectionRole.DELIVERY_OUTBOUND)); + assertThat(plan.getTransactions(fixture.client()).get(0).getTransaction().getUUID(), is(projectionUUID)); + + var loaded = loadXml(saveXml(fixture.client())); + assertThat(loaded.getPlans().get(0).getTransactions(loaded).get(0).getTransaction().getUUID(), + is(projectionUUID)); + } + + private void assertReversesDelivery(PortfolioTransaction.Type sourceType, LedgerEntryType targetEntryType, + PortfolioTransaction.Type targetTransactionType, LedgerProjectionRole sourceProjectionRole, + LedgerProjectionRole targetProjectionRole, long expectedAmount) throws Exception + { + var fixture = fixture(); + var delivery = create(fixture, sourceType); + var entry = fixture.client().getLedger().getEntries().get(0); + var entryUUID = entry.getUUID(); + var securityPostingUUID = posting(entry, LedgerPostingType.SECURITY).getUUID(); + var projectionUUID = projection(entry, sourceProjectionRole).getUUID(); + var unitSnapshots = unitSnapshots(entry); + + var reversed = converter(fixture).reverse(pair(fixture, delivery)); + + assertThat(entry.getUUID(), is(entryUUID)); + assertThat(entry.getType(), is(targetEntryType)); + assertThat(posting(entry, LedgerPostingType.SECURITY).getUUID(), is(securityPostingUUID)); + assertThat(posting(entry, LedgerPostingType.SECURITY).getAmount(), is(expectedAmount)); + assertThat(posting(entry, LedgerPostingType.SECURITY).getCurrency(), is(CurrencyUnit.EUR)); + assertThat(unitSnapshots(entry), is(unitSnapshots)); + assertThat(projection(entry, targetProjectionRole).getUUID(), is(projectionUUID)); + assertSame(fixture.portfolio(), projection(entry, targetProjectionRole).getPortfolio()); + + assertThat(fixture.portfolio().getTransactions(), is(List.of(reversed))); + assertThat(fixture.client().getAllTransactions().size(), is(1)); + assertSame(reversed, fixture.client().getAllTransactions().get(0).getTransaction()); + assertThat(reversed.getUUID(), is(projectionUUID)); + assertThat(reversed, instanceOf(LedgerBackedTransaction.class)); + assertThat(reversed.getType(), is(targetTransactionType)); + assertThat(reversed.getCrossEntry(), is(nullValue())); + assertThat(reversed.getDateTime(), is(DATE_TIME)); + assertThat(reversed.getNote(), is("note")); + assertThat(reversed.getSource(), is("source")); + assertSame(fixture.security(), reversed.getSecurity()); + assertThat(reversed.getShares(), is(Values.Share.factorize(5))); + assertThat(reversed.getAmount(), is(expectedAmount)); + assertThat(reversed.getCurrencyCode(), is(CurrencyUnit.EUR)); + assertThat(reversed.getGrossValue(), is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(120)))); + assertThat(reversed.getUnit(Unit.Type.GROSS_VALUE).orElseThrow().getAmount(), + is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(120)))); + assertThat(reversed.getUnitSum(Unit.Type.FEE), is(Money.of(CurrencyUnit.EUR, + Values.Amount.factorize(3)))); + assertThat(reversed.getUnitSum(Unit.Type.TAX), is(Money.of(CurrencyUnit.EUR, + Values.Amount.factorize(4)))); + assertValid(fixture.client()); + + assertRoundtrip(loadXml(saveXml(fixture.client())), entryUUID, securityPostingUUID, projectionUUID, + targetEntryType, targetTransactionType, targetProjectionRole, expectedAmount); + assertRoundtrip(loadProtobuf(saveProtobuf(fixture.client())), entryUUID, securityPostingUUID, projectionUUID, + targetEntryType, targetTransactionType, targetProjectionRole, expectedAmount); + } + + private void assertRoundtrip(Client client, String entryUUID, String securityPostingUUID, String projectionUUID, + LedgerEntryType entryType, PortfolioTransaction.Type transactionType, + LedgerProjectionRole projectionRole, long expectedAmount) + { + assertThat(client.getAccounts().get(0).getTransactions().isEmpty(), is(true)); + assertThat(client.getPortfolios().get(0).getTransactions().size(), is(1)); + assertThat(client.getAllTransactions().size(), is(1)); + + var entry = client.getLedger().getEntries().get(0); + var transaction = client.getPortfolios().get(0).getTransactions().get(0); + + assertThat(entry.getUUID(), is(entryUUID)); + assertThat(entry.getType(), is(entryType)); + assertThat(posting(entry, LedgerPostingType.SECURITY).getUUID(), is(securityPostingUUID)); + assertThat(posting(entry, LedgerPostingType.SECURITY).getAmount(), is(expectedAmount)); + assertThat(projection(entry, projectionRole).getUUID(), is(projectionUUID)); + assertThat(transaction.getUUID(), is(projectionUUID)); + assertThat(transaction.getType(), is(transactionType)); + assertThat(transaction.getCrossEntry(), is(nullValue())); + assertThat(transaction.getAmount(), is(expectedAmount)); + assertThat(transaction.getGrossValue(), is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(120)))); + assertValid(client); + } + + private void assertRejectsWithoutMutation(Fixture fixture, ThrowingRunnable runnable, + Class expectedType) + { + var snapshot = Snapshot.capture(fixture.client()); + + assertThrows(expectedType, runnable::run); + assertThat(Snapshot.capture(fixture.client()), is(snapshot)); + } + + private PortfolioTransaction create(Fixture fixture, PortfolioTransaction.Type type) + { + return create(fixture, type, null, null); + } + + private PortfolioTransaction create(Fixture fixture, PortfolioTransaction.Type type, Money forexAmount, + BigDecimal exchangeRate) + { + return new LedgerDeliveryTransactionCreator(fixture.client()).create(fixture.portfolio(), type, DATE_TIME, + Values.Amount.factorize(123), CurrencyUnit.EUR, fixture.security(), Values.Share.factorize(5), + forexAmount, exchangeRate, units(), "note", "source"); + } + + private List units() + { + return List.of( + new Unit(Unit.Type.FEE, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(3)), + Money.of(CurrencyUnit.USD, Values.Amount.factorize(6)), EXCHANGE_RATE), + new Unit(Unit.Type.TAX, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(4))), + new Unit(Unit.Type.GROSS_VALUE, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(120)), + Money.of(CurrencyUnit.USD, Values.Amount.factorize(240)), EXCHANGE_RATE)); + } + + private LedgerDeliveryDirectionConverter converter(Fixture fixture) + { + return new LedgerDeliveryDirectionConverter(fixture.client()); + } + + private TransactionPair pair(Fixture fixture, PortfolioTransaction transaction) + { + return new TransactionPair<>(fixture.portfolio(), transaction); + } + + private LedgerProjectionRef projection(LedgerEntry entry, LedgerProjectionRole role) + { + return entry.getProjectionRefs().stream().filter(projection -> projection.getRole() == role).findFirst() + .orElseThrow(); + } + + private LedgerPosting posting(LedgerEntry entry, LedgerPostingType type) + { + return entry.getPostings().stream().filter(posting -> posting.getType() == type).findFirst().orElseThrow(); + } + + private List unitSnapshots(LedgerEntry entry) + { + return entry.getPostings().stream().filter(posting -> posting.getType() == LedgerPostingType.FEE + || posting.getType() == LedgerPostingType.TAX + || posting.getType() == LedgerPostingType.GROSS_VALUE).map(PostingSnapshot::capture).toList(); + } + + private String saveXml(Client client) throws IOException + { + var file = File.createTempFile("ledger-delivery-direction-converter", ".xml"); + try + { + ClientFactory.save(client, file); + return Files.readString(file.toPath(), StandardCharsets.UTF_8); + } + finally + { + Files.deleteIfExists(file.toPath()); + } + } + + private Client loadXml(String xml) throws IOException + { + return ClientFactory.load(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); + } + + private byte[] saveProtobuf(Client client) throws IOException + { + return ProtobufTestUtilities.save(client); + } + + private Client loadProtobuf(byte[] bytes) throws IOException + { + return ProtobufTestUtilities.load(bytes); + } + + private void assertValid(Client client) + { + var result = LedgerStructuralValidator.validate(client.getLedger()); + + assertThat(result.format(), result.isOK(), is(true)); + } + + private Fixture fixture() + { + var client = new Client(); + var account = new Account("Account"); + var portfolio = new Portfolio("Portfolio"); + var security = new Security("Security", CurrencyUnit.EUR); + + account.setCurrencyCode(CurrencyUnit.EUR); + portfolio.setReferenceAccount(account); + account.setUpdatedAt(Instant.now()); + portfolio.setUpdatedAt(Instant.now()); + security.setUpdatedAt(Instant.now()); + client.addAccount(account); + client.addPortfolio(portfolio); + client.addSecurity(security); + + return new Fixture(client, account, portfolio, security); + } + + @FunctionalInterface + private interface ThrowingRunnable + { + void run() throws Exception; + } + + private record Fixture(Client client, Account account, Portfolio portfolio, Security security) + { + } + + private record Snapshot(List entries, List accountTransactions, + List portfolioTransactions, List allTransactions) + { + static Snapshot capture(Client client) + { + return new Snapshot(client.getLedger().getEntries().stream().map(EntrySnapshot::capture).toList(), + client.getAccounts().stream().flatMap(account -> account.getTransactions().stream()) + .map(Transaction::getUUID).toList(), + client.getPortfolios().stream().flatMap(portfolio -> portfolio.getTransactions().stream()) + .map(Transaction::getUUID).toList(), + client.getAllTransactions().stream().map(pair -> pair.getTransaction().getUUID()) + .toList()); + } + } + + private record EntrySnapshot(String uuid, LedgerEntryType type, List postings, + List projections) + { + static EntrySnapshot capture(LedgerEntry entry) + { + return new EntrySnapshot(entry.getUUID(), entry.getType(), + entry.getPostings().stream().map(PostingSnapshot::capture).toList(), + entry.getProjectionRefs().stream().map(ProjectionSnapshot::capture).toList()); + } + } + + private record PostingSnapshot(String uuid, LedgerPostingType type, Long amount, String currency, Long forexAmount, + String forexCurrency, BigDecimal exchangeRate, Security security, Long shares, Account account, + Portfolio portfolio) + { + static PostingSnapshot capture(LedgerPosting posting) + { + return new PostingSnapshot(posting.getUUID(), posting.getType(), posting.getAmount(), posting.getCurrency(), + posting.getForexAmount(), posting.getForexCurrency(), posting.getExchangeRate(), + posting.getSecurity(), posting.getShares(), posting.getAccount(), posting.getPortfolio()); + } + } + + private record ProjectionSnapshot(String uuid, LedgerProjectionRole role, Account account, Portfolio portfolio, + String primaryPostingUUID, String postingGroupUUID) + { + static ProjectionSnapshot capture(LedgerProjectionRef projection) + { + return new ProjectionSnapshot(projection.getUUID(), projection.getRole(), projection.getAccount(), + projection.getPortfolio(), projection.getPrimaryPostingUUID(), + projection.getPostingGroupUUID()); + } + } +} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerTransferDirectionConverterTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerTransferDirectionConverterTest.java new file mode 100644 index 0000000000..330a5a96ea --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerTransferDirectionConverterTest.java @@ -0,0 +1,560 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import static org.hamcrest.CoreMatchers.instanceOf; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.Test; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.AccountTransferEntry; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.ClientFactory; +import name.abuchen.portfolio.model.InvestmentPlan; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.PortfolioTransaction; +import name.abuchen.portfolio.model.PortfolioTransferEntry; +import name.abuchen.portfolio.model.ProtobufTestUtilities; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.Transaction; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.ExchangeRate; +import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.Values; + +/** + * Tests ledger-aware reversal of account and portfolio transfers. + * These tests make sure transfer sides swap without guessing missing facts or leaving stale owner rows. + */ +@SuppressWarnings("nls") +public class LedgerTransferDirectionConverterTest +{ + private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 6, 7, 8, 9); + private static final BigDecimal EXCHANGE_RATE = BigDecimal.valueOf(0.5); + + /** + * Verifies that an account transfer can be reversed without creating a new booking. + * Source and target sides must swap while the ledger entry and projections stay consistent after save/load. + */ + @Test + public void testReversesLedgerBackedAccountTransferPreservingIdentityAndTruth() throws Exception + { + var fixture = accountFixture(); + var transfer = createAccountTransfer(fixture); + var entry = fixture.client().getLedger().getEntries().get(0); + var entryUUID = entry.getUUID(); + var sourcePostingUUID = posting(entry, fixture.source()).getUUID(); + var targetPostingUUID = posting(entry, fixture.target()).getUUID(); + var sourceProjectionUUID = projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT).getUUID(); + var targetProjectionUUID = projection(entry, LedgerProjectionRole.TARGET_ACCOUNT).getUUID(); + + var reversed = converter(fixture.client()).reverse(transfer); + var reversedSource = reversed.getSourceTransaction(); + var reversedTarget = reversed.getTargetTransaction(); + + assertThat(entry.getUUID(), is(entryUUID)); + assertThat(entry.getType(), is(LedgerEntryType.CASH_TRANSFER)); + assertThat(posting(entry, fixture.source()).getUUID(), is(sourcePostingUUID)); + assertThat(posting(entry, fixture.target()).getUUID(), is(targetPostingUUID)); + assertThat(projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT).getUUID(), is(targetProjectionUUID)); + assertThat(projection(entry, LedgerProjectionRole.TARGET_ACCOUNT).getUUID(), is(sourceProjectionUUID)); + assertSame(fixture.target(), projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT).getAccount()); + assertSame(fixture.source(), projection(entry, LedgerProjectionRole.TARGET_ACCOUNT).getAccount()); + assertSame(fixture.source(), posting(entry, fixture.source()).getAccount()); + assertSame(fixture.target(), posting(entry, fixture.target()).getAccount()); + assertThat(posting(entry, fixture.source()).getAmount(), is(Values.Amount.factorize(100))); + assertThat(posting(entry, fixture.source()).getCurrency(), is(CurrencyUnit.EUR)); + assertThat(posting(entry, fixture.source()).getForexAmount(), is((Long) null)); + assertThat(posting(entry, fixture.source()).getForexCurrency(), is((String) null)); + assertThat(posting(entry, fixture.source()).getExchangeRate(), is((BigDecimal) null)); + assertThat(posting(entry, fixture.target()).getAmount(), is(Values.Amount.factorize(200))); + assertThat(posting(entry, fixture.target()).getCurrency(), is(CurrencyUnit.USD)); + assertThat(posting(entry, fixture.target()).getForexAmount(), is(Values.Amount.factorize(100))); + assertThat(posting(entry, fixture.target()).getForexCurrency(), is(CurrencyUnit.EUR)); + assertThat(posting(entry, fixture.target()).getExchangeRate().compareTo(ExchangeRate.inverse(EXCHANGE_RATE)), + is(0)); + + assertThat(fixture.source().getTransactions(), is(List.of(reversedTarget))); + assertThat(fixture.target().getTransactions(), is(List.of(reversedSource))); + assertThat(reversedSource.getUUID(), is(targetProjectionUUID)); + assertThat(reversedTarget.getUUID(), is(sourceProjectionUUID)); + assertThat(reversedSource.getType(), is(AccountTransaction.Type.TRANSFER_OUT)); + assertThat(reversedTarget.getType(), is(AccountTransaction.Type.TRANSFER_IN)); + assertThat(reversedSource.getAmount(), is(Values.Amount.factorize(200))); + assertThat(reversedSource.getCurrencyCode(), is(CurrencyUnit.USD)); + assertThat(reversedTarget.getAmount(), is(Values.Amount.factorize(100))); + assertThat(reversedTarget.getCurrencyCode(), is(CurrencyUnit.EUR)); + assertThat(reversedSource.getDateTime(), is(DATE_TIME)); + assertThat(reversedSource.getNote(), is("note")); + assertThat(reversedSource.getSource(), is("source")); + assertThat(fixture.client().getAllTransactions().size(), is(1)); + assertSame(reversedSource, fixture.client().getAllTransactions().get(0).getTransaction()); + assertAccountCrossEntry(reversedSource, reversedTarget, fixture.target(), fixture.source()); + assertThat(new LedgerAccountTransferTransactionCreator(fixture.client()).getSourceExchangeRate(reversed) + .orElseThrow().compareTo(EXCHANGE_RATE), is(0)); + assertValid(fixture.client()); + + assertAccountTransferRoundtrip(loadXml(saveXml(fixture.client())), entryUUID, sourcePostingUUID, + targetPostingUUID, sourceProjectionUUID, targetProjectionUUID); + assertAccountTransferRoundtrip(loadProtobuf(saveProtobuf(fixture.client())), entryUUID, sourcePostingUUID, + targetPostingUUID, sourceProjectionUUID, targetProjectionUUID); + } + + /** + * Verifies that a portfolio transfer can be reversed without creating a new booking. + * Source and target depots must swap while shares, security, and projections remain consistent after save/load. + */ + @Test + public void testReversesLedgerBackedPortfolioTransferPreservingIdentityAndTruth() throws Exception + { + var fixture = portfolioFixture(); + var transfer = createPortfolioTransfer(fixture); + var entry = fixture.client().getLedger().getEntries().get(0); + var entryUUID = entry.getUUID(); + var sourcePostingUUID = posting(entry, fixture.source()).getUUID(); + var targetPostingUUID = posting(entry, fixture.target()).getUUID(); + var sourceProjectionUUID = projection(entry, LedgerProjectionRole.SOURCE_PORTFOLIO).getUUID(); + var targetProjectionUUID = projection(entry, LedgerProjectionRole.TARGET_PORTFOLIO).getUUID(); + + var reversed = converter(fixture.client()).reverse(transfer); + var reversedSource = reversed.getSourceTransaction(); + var reversedTarget = reversed.getTargetTransaction(); + + assertThat(entry.getUUID(), is(entryUUID)); + assertThat(entry.getType(), is(LedgerEntryType.SECURITY_TRANSFER)); + assertThat(posting(entry, fixture.source()).getUUID(), is(sourcePostingUUID)); + assertThat(posting(entry, fixture.target()).getUUID(), is(targetPostingUUID)); + assertThat(projection(entry, LedgerProjectionRole.SOURCE_PORTFOLIO).getUUID(), is(targetProjectionUUID)); + assertThat(projection(entry, LedgerProjectionRole.TARGET_PORTFOLIO).getUUID(), is(sourceProjectionUUID)); + assertSame(fixture.target(), projection(entry, LedgerProjectionRole.SOURCE_PORTFOLIO).getPortfolio()); + assertSame(fixture.source(), projection(entry, LedgerProjectionRole.TARGET_PORTFOLIO).getPortfolio()); + assertSame(fixture.source(), posting(entry, fixture.source()).getPortfolio()); + assertSame(fixture.target(), posting(entry, fixture.target()).getPortfolio()); + assertSame(fixture.security(), posting(entry, fixture.source()).getSecurity()); + assertSame(fixture.security(), posting(entry, fixture.target()).getSecurity()); + assertThat(posting(entry, fixture.source()).getShares(), is(Values.Share.factorize(5))); + assertThat(posting(entry, fixture.target()).getShares(), is(Values.Share.factorize(5))); + assertThat(posting(entry, fixture.source()).getAmount(), is(Values.Amount.factorize(100))); + assertThat(posting(entry, fixture.target()).getAmount(), is(Values.Amount.factorize(100))); + assertThat(posting(entry, fixture.source()).getCurrency(), is(CurrencyUnit.EUR)); + assertThat(posting(entry, fixture.target()).getCurrency(), is(CurrencyUnit.EUR)); + + assertThat(fixture.source().getTransactions(), is(List.of(reversedTarget))); + assertThat(fixture.target().getTransactions(), is(List.of(reversedSource))); + assertThat(reversedSource.getUUID(), is(targetProjectionUUID)); + assertThat(reversedTarget.getUUID(), is(sourceProjectionUUID)); + assertThat(reversedSource.getType(), is(PortfolioTransaction.Type.TRANSFER_OUT)); + assertThat(reversedTarget.getType(), is(PortfolioTransaction.Type.TRANSFER_IN)); + assertSame(fixture.security(), reversedSource.getSecurity()); + assertSame(fixture.security(), reversedTarget.getSecurity()); + assertThat(reversedSource.getShares(), is(Values.Share.factorize(5))); + assertThat(reversedTarget.getShares(), is(Values.Share.factorize(5))); + assertThat(reversedSource.getDateTime(), is(DATE_TIME)); + assertThat(reversedSource.getNote(), is("note")); + assertThat(reversedSource.getSource(), is("source")); + assertThat(fixture.client().getAllTransactions().size(), is(1)); + assertSame(reversedSource, fixture.client().getAllTransactions().get(0).getTransaction()); + assertPortfolioCrossEntry(reversedSource, reversedTarget, fixture.target(), fixture.source()); + assertValid(fixture.client()); + + assertPortfolioTransferRoundtrip(loadXml(saveXml(fixture.client())), entryUUID, sourcePostingUUID, + targetPostingUUID, sourceProjectionUUID, targetProjectionUUID); + assertPortfolioTransferRoundtrip(loadProtobuf(saveProtobuf(fixture.client())), entryUUID, sourcePostingUUID, + targetPostingUUID, sourceProjectionUUID, targetProjectionUUID); + } + + /** + * Verifies that a malformed account transfer is rejected before reversal. + * The converter must not guess a missing transfer side from the remaining projection. + */ + @Test + public void testMalformedAccountTransferRejectsBeforeMutation() + { + var fixture = accountFixture(); + var transfer = createAccountTransfer(fixture); + var entry = fixture.client().getLedger().getEntries().get(0); + var projection = projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT); + + entry.removeProjectionRef(projection); + var snapshot = Snapshot.capture(fixture.client()); + + assertThrows(IllegalArgumentException.class, () -> converter(fixture.client()).reverse(transfer)); + + assertThat(Snapshot.capture(fixture.client()), is(snapshot)); + } + + /** + * Verifies that an account transfer without its posting is rejected before reversal. + * Missing cash facts must not be reconstructed from owner-list projections. + */ + @Test + public void testAccountTransferMissingPostingRejectsBeforeMutation() + { + var fixture = accountFixture(); + var transfer = createAccountTransfer(fixture); + var entry = fixture.client().getLedger().getEntries().get(0); + var posting = posting(entry, fixture.source()); + + entry.removePosting(posting); + var snapshot = Snapshot.capture(fixture.client()); + + assertThrows(IllegalArgumentException.class, () -> converter(fixture.client()).reverse(transfer)); + + assertThat(Snapshot.capture(fixture.client()), is(snapshot)); + } + + /** + * Verifies that a plan-generated account transfer can be reversed safely. + * The plan reference must follow the same generated booking after the source and target sides swap. + */ + @Test + public void testInvestmentPlanReferencedAccountTransferReversesAndUpdatesPlanReference() throws Exception + { + var fixture = accountFixture(); + var transfer = createAccountTransfer(fixture); + var plan = new InvestmentPlan("Plan"); + var entry = fixture.client().getLedger().getEntries().get(0); + var projectionUUID = transfer.getSourceTransaction().getUUID(); + + plan.addLedgerExecutionRef(InvestmentPlan.LedgerExecutionRef.of( + (LedgerBackedTransaction) transfer.getSourceTransaction())); + fixture.client().addPlan(plan); + + converter(fixture.client()).reverse(transfer); + + assertThat(plan.getLedgerExecutionRefs().get(0).getLedgerEntryUUID(), is(entry.getUUID())); + assertThat(plan.getLedgerExecutionRefs().get(0).getProjectionUUID(), is(projectionUUID)); + assertThat(plan.getLedgerExecutionRefs().get(0).getProjectionRole(), is(LedgerProjectionRole.TARGET_ACCOUNT)); + assertThat(plan.getTransactions(fixture.client()).get(0).getTransaction().getUUID(), is(projectionUUID)); + + var loaded = loadXml(saveXml(fixture.client())); + assertThat(loaded.getPlans().get(0).getTransactions(loaded).get(0).getTransaction().getUUID(), + is(projectionUUID)); + } + + /** + * Verifies that a malformed portfolio transfer is rejected before reversal. + * The converter must not guess a missing depot side from the remaining projection. + */ + @Test + public void testMalformedPortfolioTransferRejectsBeforeMutation() + { + var fixture = portfolioFixture(); + var transfer = createPortfolioTransfer(fixture); + var entry = fixture.client().getLedger().getEntries().get(0); + var projection = projection(entry, LedgerProjectionRole.TARGET_PORTFOLIO); + + entry.removeProjectionRef(projection); + var snapshot = Snapshot.capture(fixture.client()); + + assertThrows(IllegalArgumentException.class, () -> converter(fixture.client()).reverse(transfer)); + + assertThat(Snapshot.capture(fixture.client()), is(snapshot)); + } + + /** + * Verifies that a portfolio transfer without its security posting is rejected before reversal. + * Missing share and security facts must not be reconstructed from owner-list projections. + */ + @Test + public void testPortfolioTransferMissingPostingRejectsBeforeMutation() + { + var fixture = portfolioFixture(); + var transfer = createPortfolioTransfer(fixture); + var entry = fixture.client().getLedger().getEntries().get(0); + var posting = posting(entry, fixture.target()); + + entry.removePosting(posting); + var snapshot = Snapshot.capture(fixture.client()); + + assertThrows(IllegalArgumentException.class, () -> converter(fixture.client()).reverse(transfer)); + + assertThat(Snapshot.capture(fixture.client()), is(snapshot)); + } + + /** + * Verifies that a plan-generated portfolio transfer can be reversed safely. + * The plan reference must follow the same generated booking after the depot sides swap. + */ + @Test + public void testInvestmentPlanReferencedPortfolioTransferReversesAndUpdatesPlanReference() throws Exception + { + var fixture = portfolioFixture(); + var transfer = createPortfolioTransfer(fixture); + var plan = new InvestmentPlan("Plan"); + var entry = fixture.client().getLedger().getEntries().get(0); + var projectionUUID = transfer.getSourceTransaction().getUUID(); + + plan.addLedgerExecutionRef(InvestmentPlan.LedgerExecutionRef.of( + (LedgerBackedTransaction) transfer.getSourceTransaction())); + fixture.client().addPlan(plan); + + converter(fixture.client()).reverse(transfer); + + assertThat(plan.getLedgerExecutionRefs().get(0).getLedgerEntryUUID(), is(entry.getUUID())); + assertThat(plan.getLedgerExecutionRefs().get(0).getProjectionUUID(), is(projectionUUID)); + assertThat(plan.getLedgerExecutionRefs().get(0).getProjectionRole(), + is(LedgerProjectionRole.TARGET_PORTFOLIO)); + assertThat(plan.getTransactions(fixture.client()).get(0).getTransaction().getUUID(), is(projectionUUID)); + + var loaded = loadXml(saveXml(fixture.client())); + assertThat(loaded.getPlans().get(0).getTransactions(loaded).get(0).getTransaction().getUUID(), + is(projectionUUID)); + } + + private void assertAccountTransferRoundtrip(Client client, String entryUUID, String sourcePostingUUID, + String targetPostingUUID, String sourceProjectionUUID, String targetProjectionUUID) + { + var source = client.getAccounts().get(0); + var target = client.getAccounts().get(1); + var entry = client.getLedger().getEntries().get(0); + var sourceTransaction = target.getTransactions().get(0); + var targetTransaction = source.getTransactions().get(0); + + assertThat(entry.getUUID(), is(entryUUID)); + assertThat(posting(entry, source).getUUID(), is(sourcePostingUUID)); + assertThat(posting(entry, target).getUUID(), is(targetPostingUUID)); + assertThat(projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT).getUUID(), is(targetProjectionUUID)); + assertThat(projection(entry, LedgerProjectionRole.TARGET_ACCOUNT).getUUID(), is(sourceProjectionUUID)); + assertThat(sourceTransaction.getUUID(), is(targetProjectionUUID)); + assertThat(targetTransaction.getUUID(), is(sourceProjectionUUID)); + assertThat(sourceTransaction.getType(), is(AccountTransaction.Type.TRANSFER_OUT)); + assertThat(targetTransaction.getType(), is(AccountTransaction.Type.TRANSFER_IN)); + assertAccountCrossEntry(sourceTransaction, targetTransaction, target, source); + assertValid(client); + } + + private void assertPortfolioTransferRoundtrip(Client client, String entryUUID, String sourcePostingUUID, + String targetPostingUUID, String sourceProjectionUUID, String targetProjectionUUID) + { + var source = client.getPortfolios().get(0); + var target = client.getPortfolios().get(1); + var entry = client.getLedger().getEntries().get(0); + var sourceTransaction = target.getTransactions().get(0); + var targetTransaction = source.getTransactions().get(0); + + assertThat(entry.getUUID(), is(entryUUID)); + assertThat(posting(entry, source).getUUID(), is(sourcePostingUUID)); + assertThat(posting(entry, target).getUUID(), is(targetPostingUUID)); + assertThat(projection(entry, LedgerProjectionRole.SOURCE_PORTFOLIO).getUUID(), is(targetProjectionUUID)); + assertThat(projection(entry, LedgerProjectionRole.TARGET_PORTFOLIO).getUUID(), is(sourceProjectionUUID)); + assertThat(sourceTransaction.getUUID(), is(targetProjectionUUID)); + assertThat(targetTransaction.getUUID(), is(sourceProjectionUUID)); + assertThat(sourceTransaction.getType(), is(PortfolioTransaction.Type.TRANSFER_OUT)); + assertThat(targetTransaction.getType(), is(PortfolioTransaction.Type.TRANSFER_IN)); + assertPortfolioCrossEntry(sourceTransaction, targetTransaction, target, source); + assertValid(client); + } + + private void assertAccountCrossEntry(AccountTransaction sourceTransaction, AccountTransaction targetTransaction, + Account source, Account target) + { + assertThat(sourceTransaction.getCrossEntry(), instanceOf(AccountTransferEntry.class)); + assertThat(targetTransaction.getCrossEntry(), instanceOf(AccountTransferEntry.class)); + assertSame(targetTransaction, sourceTransaction.getCrossEntry().getCrossTransaction(sourceTransaction)); + assertSame(sourceTransaction, targetTransaction.getCrossEntry().getCrossTransaction(targetTransaction)); + assertSame(target, sourceTransaction.getCrossEntry().getCrossOwner(sourceTransaction)); + assertSame(source, targetTransaction.getCrossEntry().getCrossOwner(targetTransaction)); + } + + private void assertPortfolioCrossEntry(PortfolioTransaction sourceTransaction, + PortfolioTransaction targetTransaction, Portfolio source, Portfolio target) + { + assertThat(sourceTransaction.getCrossEntry(), instanceOf(PortfolioTransferEntry.class)); + assertThat(targetTransaction.getCrossEntry(), instanceOf(PortfolioTransferEntry.class)); + assertSame(targetTransaction, sourceTransaction.getCrossEntry().getCrossTransaction(sourceTransaction)); + assertSame(sourceTransaction, targetTransaction.getCrossEntry().getCrossTransaction(targetTransaction)); + assertSame(target, sourceTransaction.getCrossEntry().getCrossOwner(sourceTransaction)); + assertSame(source, targetTransaction.getCrossEntry().getCrossOwner(targetTransaction)); + } + + private LedgerTransferDirectionConverter converter(Client client) + { + return new LedgerTransferDirectionConverter(client); + } + + private AccountTransferEntry createAccountTransfer(AccountFixture fixture) + { + return new LedgerAccountTransferTransactionCreator(fixture.client()).create(fixture.source(), fixture.target(), + DATE_TIME, Values.Amount.factorize(100), CurrencyUnit.EUR, Values.Amount.factorize(200), + CurrencyUnit.USD, Money.of(CurrencyUnit.USD, Values.Amount.factorize(200)), EXCHANGE_RATE, + "note", "source"); + } + + private PortfolioTransferEntry createPortfolioTransfer(PortfolioFixture fixture) + { + return new LedgerPortfolioTransferTransactionCreator(fixture.client()).create(fixture.source(), + fixture.target(), fixture.security(), DATE_TIME, Values.Share.factorize(5), + Values.Amount.factorize(100), CurrencyUnit.EUR, "note", "source"); + } + + private LedgerProjectionRef projection(LedgerEntry entry, LedgerProjectionRole role) + { + return entry.getProjectionRefs().stream().filter(projection -> projection.getRole() == role).findFirst() + .orElseThrow(); + } + + private LedgerPosting posting(LedgerEntry entry, Account account) + { + return entry.getPostings().stream().filter(posting -> posting.getType() == LedgerPostingType.CASH) + .filter(posting -> posting.getAccount() == account).findFirst().orElseThrow(); + } + + private LedgerPosting posting(LedgerEntry entry, Portfolio portfolio) + { + return entry.getPostings().stream().filter(posting -> posting.getType() == LedgerPostingType.SECURITY) + .filter(posting -> posting.getPortfolio() == portfolio).findFirst().orElseThrow(); + } + + private String saveXml(Client client) throws IOException + { + var file = File.createTempFile("ledger-transfer-direction", ".xml"); + try + { + ClientFactory.save(client, file); + return Files.readString(file.toPath(), StandardCharsets.UTF_8); + } + finally + { + Files.deleteIfExists(file.toPath()); + } + } + + private Client loadXml(String xml) throws IOException + { + return ClientFactory.load(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); + } + + private byte[] saveProtobuf(Client client) throws IOException + { + return ProtobufTestUtilities.save(client); + } + + private Client loadProtobuf(byte[] bytes) throws IOException + { + return ProtobufTestUtilities.load(bytes); + } + + private void assertValid(Client client) + { + var result = LedgerStructuralValidator.validate(client.getLedger()); + + assertThat(result.format(), result.isOK(), is(true)); + } + + private AccountFixture accountFixture() + { + var client = new Client(); + var source = account("Source", CurrencyUnit.EUR); + var target = account("Target", CurrencyUnit.USD); + + client.addAccount(source); + client.addAccount(target); + + return new AccountFixture(client, source, target); + } + + private PortfolioFixture portfolioFixture() + { + var client = new Client(); + var source = new Portfolio("Source"); + var target = new Portfolio("Target"); + var security = new Security("Security", CurrencyUnit.EUR); + + source.setUpdatedAt(Instant.now()); + target.setUpdatedAt(Instant.now()); + security.setUpdatedAt(Instant.now()); + client.addPortfolio(source); + client.addPortfolio(target); + client.addSecurity(security); + + return new PortfolioFixture(client, source, target, security); + } + + private Account account(String name, String currency) + { + var account = new Account(name); + + account.setCurrencyCode(currency); + + return account; + } + + private record AccountFixture(Client client, Account source, Account target) + { + } + + private record PortfolioFixture(Client client, Portfolio source, Portfolio target, Security security) + { + } + + private record Snapshot(List entries, List accountTransactions, + List portfolioTransactions, List allTransactions) + { + static Snapshot capture(Client client) + { + return new Snapshot(client.getLedger().getEntries().stream().map(EntrySnapshot::capture).toList(), + client.getAccounts().stream().flatMap(account -> account.getTransactions().stream()) + .map(Transaction::getUUID).toList(), + client.getPortfolios().stream().flatMap(portfolio -> portfolio.getTransactions().stream()) + .map(Transaction::getUUID).toList(), + client.getAllTransactions().stream().map(pair -> pair.getTransaction().getUUID()) + .toList()); + } + } + + private record EntrySnapshot(String uuid, LedgerEntryType type, List postings, + List projections) + { + static EntrySnapshot capture(LedgerEntry entry) + { + return new EntrySnapshot(entry.getUUID(), entry.getType(), + entry.getPostings().stream().map(PostingSnapshot::capture).toList(), + entry.getProjectionRefs().stream().map(ProjectionSnapshot::capture).toList()); + } + } + + private record PostingSnapshot(String uuid, LedgerPostingType type, Long amount, String currency, Long forexAmount, + String forexCurrency, BigDecimal exchangeRate, Security security, Long shares, Account account, + Portfolio portfolio) + { + static PostingSnapshot capture(LedgerPosting posting) + { + return new PostingSnapshot(posting.getUUID(), posting.getType(), posting.getAmount(), posting.getCurrency(), + posting.getForexAmount(), posting.getForexCurrency(), posting.getExchangeRate(), + posting.getSecurity(), posting.getShares(), posting.getAccount(), posting.getPortfolio()); + } + } + + private record ProjectionSnapshot(String uuid, LedgerProjectionRole role, Account account, Portfolio portfolio, + String primaryPostingUUID, String postingGroupUUID) + { + static ProjectionSnapshot capture(LedgerProjectionRef projection) + { + return new ProjectionSnapshot(projection.getUUID(), projection.getRole(), projection.getAccount(), + projection.getPortfolio(), projection.getPrimaryPostingUUID(), + projection.getPostingGroupUUID()); + } + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerAccountTransferToDepositRemovalConverter.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerAccountTransferToDepositRemovalConverter.java new file mode 100644 index 0000000000..0b2add456a --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerAccountTransferToDepositRemovalConverter.java @@ -0,0 +1,38 @@ +package name.abuchen.portfolio.model; + +import java.util.Objects; + +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; + +/** + * Exposes a model-level facade for ledger-backed account transfer splitting. + * This class keeps existing model action code independent from the internal compatibility + * package. The actual mutation is delegated to a Ledger compatibility converter. + */ +public final class LedgerAccountTransferToDepositRemovalConverter +{ + private final name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountTransferToDepositRemovalConverter delegate; + + public LedgerAccountTransferToDepositRemovalConverter(Client client) + { + this.delegate = new name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountTransferToDepositRemovalConverter( + Objects.requireNonNull(client)); + } + + public boolean canSplit(AccountTransferEntry transfer) + { + Objects.requireNonNull(transfer); + + return transfer.getSourceTransaction() instanceof LedgerBackedTransaction + && transfer.getTargetTransaction() instanceof LedgerBackedTransaction; + } + + public SplitResult split(AccountTransferEntry transfer) + { + return delegate.split(transfer); + } + + public record SplitResult(AccountTransaction removal, AccountTransaction deposit) + { + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerAccountTypeToggleConverter.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerAccountTypeToggleConverter.java new file mode 100644 index 0000000000..a5b003b491 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerAccountTypeToggleConverter.java @@ -0,0 +1,51 @@ +package name.abuchen.portfolio.model; + +import java.util.Objects; + +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedAccountTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; + +/** + * Exposes a model-level facade for ledger-backed account type toggles. + * This class keeps existing model action code independent from the internal compatibility + * package. The actual mutation is delegated to a Ledger compatibility converter. + */ +public final class LedgerAccountTypeToggleConverter +{ + private final Client client; + private final name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountTypeToggleConverter delegate; + + public LedgerAccountTypeToggleConverter(Client client) + { + this.client = Objects.requireNonNull(client); + this.delegate = new name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountTypeToggleConverter( + this.client); + } + + public boolean canToggle(TransactionPair transaction) + { + Objects.requireNonNull(transaction); + + if (!(transaction.getTransaction() instanceof LedgerBackedTransaction)) + return false; + + return switch (transaction.getTransaction().getType()) + { + case DEPOSIT, REMOVAL, INTEREST, INTEREST_CHARGE, FEES, FEES_REFUND, TAXES, TAX_REFUND -> true; + default -> false; + }; + } + + public boolean canToggleSafely(TransactionPair transaction) + { + Objects.requireNonNull(transaction); + + return canToggle(transaction) && transaction.getTransaction() instanceof LedgerBackedAccountTransaction ledgerTransaction + && LedgerPlanReferenceSupport.currentRefsResolveUniquely(client, ledgerTransaction.getLedgerEntry()); + } + + public AccountTransaction toggle(TransactionPair transaction) + { + return delegate.toggle(transaction); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerBuySellDeliveryConverter.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerBuySellDeliveryConverter.java new file mode 100644 index 0000000000..c5af56ff60 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerBuySellDeliveryConverter.java @@ -0,0 +1,86 @@ +package name.abuchen.portfolio.model; + +import java.util.Objects; + +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedPortfolioTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; + +/** + * Exposes a model-level facade for ledger-backed buy/sell delivery conversion. + * This class keeps existing model action code independent from the internal compatibility + * package. The actual mutation is delegated to a Ledger compatibility converter. + */ +public final class LedgerBuySellDeliveryConverter +{ + private final Client client; + private final name.abuchen.portfolio.model.ledger.compatibility.LedgerBuySellDeliveryConverter delegate; + + public LedgerBuySellDeliveryConverter(Client client) + { + this.client = Objects.requireNonNull(client); + this.delegate = new name.abuchen.portfolio.model.ledger.compatibility.LedgerBuySellDeliveryConverter( + this.client); + } + + public boolean canConvert(TransactionPair transaction) + { + Objects.requireNonNull(transaction); + + return transaction.getTransaction() instanceof LedgerBackedTransaction + && (transaction.getTransaction().getType() == PortfolioTransaction.Type.BUY + || transaction.getTransaction().getType() == PortfolioTransaction.Type.SELL); + } + + public boolean canConvertDeliveryToBuySell(TransactionPair transaction) + { + Objects.requireNonNull(transaction); + + return transaction.getTransaction() instanceof LedgerBackedTransaction + && (transaction.getTransaction().getType() == PortfolioTransaction.Type.DELIVERY_INBOUND + || transaction.getTransaction() + .getType() == PortfolioTransaction.Type.DELIVERY_OUTBOUND); + } + + public boolean canConvertSafely(TransactionPair transaction) + { + Objects.requireNonNull(transaction); + + if (!canConvert(transaction) + || !(transaction.getTransaction() instanceof LedgerBackedPortfolioTransaction ledgerTransaction)) + return false; + + var entry = ledgerTransaction.getLedgerEntry(); + var targetRole = transaction.getTransaction().getType() == PortfolioTransaction.Type.BUY + ? LedgerProjectionRole.DELIVERY_INBOUND + : LedgerProjectionRole.DELIVERY_OUTBOUND; + + return LedgerPlanReferenceSupport.refsFollowRoleChanges(client, entry, + LedgerPlanReferenceSupport.roleChange(ledgerTransaction.getLedgerProjectionRef().getUUID(), + LedgerProjectionRole.PORTFOLIO, targetRole)); + } + + public boolean canConvertDeliveryToBuySellSafely(TransactionPair transaction) + { + Objects.requireNonNull(transaction); + + if (!canConvertDeliveryToBuySell(transaction) + || !(transaction.getTransaction() instanceof LedgerBackedPortfolioTransaction ledgerTransaction)) + return false; + + return LedgerPlanReferenceSupport.refsFollowRoleChanges(client, ledgerTransaction.getLedgerEntry(), + LedgerPlanReferenceSupport.roleChange(ledgerTransaction.getLedgerProjectionRef().getUUID(), + ledgerTransaction.getLedgerProjectionRef().getRole(), + LedgerProjectionRole.PORTFOLIO)); + } + + public PortfolioTransaction convertBuySellToDelivery(TransactionPair transaction) + { + return delegate.convertBuySellToDelivery(transaction); + } + + public BuySellEntry convertDeliveryToBuySell(TransactionPair transaction) + { + return delegate.convertDeliveryToBuySell(transaction); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerBuySellReversalConverter.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerBuySellReversalConverter.java new file mode 100644 index 0000000000..a47fea8a80 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerBuySellReversalConverter.java @@ -0,0 +1,44 @@ +package name.abuchen.portfolio.model; + +import java.util.Objects; + +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; + +/** + * Exposes a model-level facade for ledger-backed buy/sell reversal. + * This class keeps existing model action code independent from the internal compatibility + * package. The actual mutation is delegated to a Ledger compatibility converter. + */ +public final class LedgerBuySellReversalConverter +{ + private final Client client; + private final name.abuchen.portfolio.model.ledger.compatibility.LedgerBuySellReversalConverter delegate; + + public LedgerBuySellReversalConverter(Client client) + { + this.client = Objects.requireNonNull(client); + this.delegate = new name.abuchen.portfolio.model.ledger.compatibility.LedgerBuySellReversalConverter( + this.client); + } + + public boolean canReverse(BuySellEntry entry) + { + Objects.requireNonNull(entry); + + return entry.getAccountTransaction() instanceof LedgerBackedTransaction + && entry.getPortfolioTransaction() instanceof LedgerBackedTransaction; + } + + public boolean canReverseSafely(BuySellEntry entry) + { + Objects.requireNonNull(entry); + + return delegate.canReverseSafely(entry); + } + + public BuySellEntry reverse(BuySellEntry entry) + { + return delegate.reverse(entry); + } + +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerDeliveryDirectionConverter.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerDeliveryDirectionConverter.java new file mode 100644 index 0000000000..11914da42a --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerDeliveryDirectionConverter.java @@ -0,0 +1,58 @@ +package name.abuchen.portfolio.model; + +import java.util.Objects; + +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedPortfolioTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; + +/** + * Exposes a model-level facade for ledger-backed delivery direction changes. + * This class keeps existing model action code independent from the internal compatibility + * package. The actual mutation is delegated to a Ledger compatibility converter. + */ +public final class LedgerDeliveryDirectionConverter +{ + private final Client client; + private final name.abuchen.portfolio.model.ledger.compatibility.LedgerDeliveryDirectionConverter delegate; + + public LedgerDeliveryDirectionConverter(Client client) + { + this.client = Objects.requireNonNull(client); + this.delegate = new name.abuchen.portfolio.model.ledger.compatibility.LedgerDeliveryDirectionConverter( + this.client); + } + + public boolean canReverse(TransactionPair transaction) + { + Objects.requireNonNull(transaction); + + return transaction.getTransaction() instanceof LedgerBackedTransaction + && (transaction.getTransaction().getType() == PortfolioTransaction.Type.DELIVERY_INBOUND + || transaction.getTransaction() + .getType() == PortfolioTransaction.Type.DELIVERY_OUTBOUND); + } + + public boolean canReverseSafely(TransactionPair transaction) + { + Objects.requireNonNull(transaction); + + if (!canReverse(transaction) + || !(transaction.getTransaction() instanceof LedgerBackedPortfolioTransaction ledgerTransaction)) + return false; + + var sourceRole = ledgerTransaction.getLedgerProjectionRef().getRole(); + var targetRole = transaction.getTransaction().getType() == PortfolioTransaction.Type.DELIVERY_INBOUND + ? LedgerProjectionRole.DELIVERY_OUTBOUND + : LedgerProjectionRole.DELIVERY_INBOUND; + + return LedgerPlanReferenceSupport.refsFollowRoleChanges(client, ledgerTransaction.getLedgerEntry(), + LedgerPlanReferenceSupport.roleChange(ledgerTransaction.getLedgerProjectionRef().getUUID(), + sourceRole, targetRole)); + } + + public PortfolioTransaction reverse(TransactionPair transaction) + { + return delegate.reverse(transaction); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerPortfolioCompositeTypeConverter.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerPortfolioCompositeTypeConverter.java new file mode 100644 index 0000000000..3533fbe5c5 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerPortfolioCompositeTypeConverter.java @@ -0,0 +1,37 @@ +package name.abuchen.portfolio.model; + +import java.util.Objects; + +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedPortfolioTransaction; + +/** + * Exposes a model-level facade for composite ledger-backed portfolio type conversions. + * The actual mutation is delegated to the Ledger compatibility converter. + */ +public final class LedgerPortfolioCompositeTypeConverter +{ + private final name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioCompositeTypeConverter delegate; + + public LedgerPortfolioCompositeTypeConverter(Client client) + { + this.delegate = new name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioCompositeTypeConverter( + Objects.requireNonNull(client)); + } + + public boolean isLedgerBacked(TransactionPair transaction) + { + Objects.requireNonNull(transaction); + + return transaction.getTransaction() instanceof LedgerBackedPortfolioTransaction; + } + + public boolean canConvertSafely(TransactionPair transaction) + { + return delegate.canConvertSafely(transaction); + } + + public PortfolioTransaction convert(TransactionPair transaction) + { + return delegate.convert(transaction); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerTransferDirectionConverter.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerTransferDirectionConverter.java new file mode 100644 index 0000000000..10ba4d3b4f --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerTransferDirectionConverter.java @@ -0,0 +1,94 @@ +package name.abuchen.portfolio.model; + +import java.util.Objects; + +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedAccountTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedPortfolioTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; + +/** + * Exposes a model-level facade for ledger-backed transfer direction changes. + * This class keeps existing model action code independent from the internal compatibility + * package. The actual mutation is delegated to a Ledger compatibility converter. + */ +public final class LedgerTransferDirectionConverter +{ + private final Client client; + private final name.abuchen.portfolio.model.ledger.compatibility.LedgerTransferDirectionConverter delegate; + + public LedgerTransferDirectionConverter(Client client) + { + this.client = Objects.requireNonNull(client); + this.delegate = new name.abuchen.portfolio.model.ledger.compatibility.LedgerTransferDirectionConverter( + this.client); + } + + public boolean canReverse(AccountTransferEntry transfer) + { + Objects.requireNonNull(transfer); + + return transfer.getSourceTransaction() instanceof LedgerBackedTransaction + && transfer.getTargetTransaction() instanceof LedgerBackedTransaction; + } + + public boolean canReverse(PortfolioTransferEntry transfer) + { + Objects.requireNonNull(transfer); + + return transfer.getSourceTransaction() instanceof LedgerBackedTransaction + && transfer.getTargetTransaction() instanceof LedgerBackedTransaction; + } + + public boolean canReverseSafely(AccountTransferEntry transfer) + { + Objects.requireNonNull(transfer); + + if (!(transfer.getSourceTransaction() instanceof LedgerBackedAccountTransaction sourceTransaction) + || !(transfer.getTargetTransaction() instanceof LedgerBackedAccountTransaction targetTransaction) + || sourceTransaction.getLedgerEntry() != targetTransaction.getLedgerEntry()) + return false; + + var entry = sourceTransaction.getLedgerEntry(); + return LedgerPlanReferenceSupport.refsFollowRoleChanges(client, entry, + LedgerPlanReferenceSupport.roleChange( + LedgerPlanReferenceSupport.projectionUUID(entry, + LedgerProjectionRole.SOURCE_ACCOUNT), + LedgerProjectionRole.SOURCE_ACCOUNT, LedgerProjectionRole.TARGET_ACCOUNT), + LedgerPlanReferenceSupport.roleChange( + LedgerPlanReferenceSupport.projectionUUID(entry, + LedgerProjectionRole.TARGET_ACCOUNT), + LedgerProjectionRole.TARGET_ACCOUNT, LedgerProjectionRole.SOURCE_ACCOUNT)); + } + + public boolean canReverseSafely(PortfolioTransferEntry transfer) + { + Objects.requireNonNull(transfer); + + if (!(transfer.getSourceTransaction() instanceof LedgerBackedPortfolioTransaction sourceTransaction) + || !(transfer.getTargetTransaction() instanceof LedgerBackedPortfolioTransaction targetTransaction) + || sourceTransaction.getLedgerEntry() != targetTransaction.getLedgerEntry()) + return false; + + var entry = sourceTransaction.getLedgerEntry(); + return LedgerPlanReferenceSupport.refsFollowRoleChanges(client, entry, + LedgerPlanReferenceSupport.roleChange( + LedgerPlanReferenceSupport.projectionUUID(entry, + LedgerProjectionRole.SOURCE_PORTFOLIO), + LedgerProjectionRole.SOURCE_PORTFOLIO, LedgerProjectionRole.TARGET_PORTFOLIO), + LedgerPlanReferenceSupport.roleChange( + LedgerPlanReferenceSupport.projectionUUID(entry, + LedgerProjectionRole.TARGET_PORTFOLIO), + LedgerProjectionRole.TARGET_PORTFOLIO, LedgerProjectionRole.SOURCE_PORTFOLIO)); + } + + public AccountTransferEntry reverse(AccountTransferEntry transfer) + { + return delegate.reverse(transfer); + } + + public PortfolioTransferEntry reverse(PortfolioTransferEntry transfer) + { + return delegate.reverse(transfer); + } +} 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..8cb6d47baf --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerEntryEditSupport.java @@ -0,0 +1,68 @@ +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 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..a1e3931d38 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerGraphWriter.java @@ -0,0 +1,59 @@ +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); + + for (var projectionRef : List.copyOf(target.getProjectionRefs())) + target.removeProjectionRef(projectionRef); + + copy.getParameters().forEach(target::addParameter); + copy.getPostings().forEach(target::addPosting); + copy.getProjectionRefs().forEach(target::addProjectionRef); + 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..15e540b456 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerModelCopy.java @@ -0,0 +1,86 @@ +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); + source.getProjectionRefs().stream().map(LedgerModelCopy::copyProjectionRef).forEach(copy::addProjectionRef); + 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()); + source.getParameters().stream().map(LedgerModelCopy::copyParameter).forEach(copy::addParameter); + + return copy; + } + + static LedgerProjectionRef copyProjectionRef(LedgerProjectionRef source) + { + var copy = new LedgerProjectionRef(Objects.requireNonNull(source).getUUID()); + + copy.setRole(source.getRole()); + copy.setAccount(source.getAccount()); + copy.setPortfolio(source.getPortfolio()); + copy.setPrimaryPostingUUID(source.getPrimaryPostingUUID()); + copy.setPostingGroupUUID(source.getPostingGroupUUID()); + source.getMemberships().stream().map(LedgerModelCopy::copyProjectionMembership).forEach(copy::addMembership); + + return copy; + } + + static ProjectionMembership copyProjectionMembership(ProjectionMembership source) + { + Objects.requireNonNull(source); + return new ProjectionMembership(source.getPostingUUID(), source.getRole()); + } + + 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/LedgerMutationContext.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerMutationContext.java new file mode 100644 index 0000000000..6750c86665 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerMutationContext.java @@ -0,0 +1,277 @@ +package name.abuchen.portfolio.model.ledger; + +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.PortfolioTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionService; + +/** + * Applies atomic changes to Ledger entries and their runtime projections. + * This is internal Ledger mutation infrastructure. Contributors should normally use + * higher-level creators, editors, converters, or deleters instead of calling it directly. + */ +public final class LedgerMutationContext +{ + private final Client client; + private final Consumer materializer; + + public LedgerMutationContext(Client client) + { + this(client, LedgerProjectionService::materialize); + } + + LedgerMutationContext(Client client, Consumer materializer) + { + this.client = Objects.requireNonNull(client); + this.materializer = Objects.requireNonNull(materializer); + } + + public void mutateEntry(LedgerEntry entry, Consumer mutation) + { + Objects.requireNonNull(entry); + Objects.requireNonNull(mutation); + + mutateEntries(List.of(entry.getUUID()), ledger -> mutation.accept(entryByUUID(ledger, entry.getUUID()))); + } + + public void mutateEntries(Collection entryUUIDs, Consumer mutation) + { + mutateEntries(entryUUIDs, mutation, true); + } + + private void mutateEntries(Collection entryUUIDs, Consumer mutation, boolean refreshProjections) + { + Objects.requireNonNull(entryUUIDs); + Objects.requireNonNull(mutation); + + var affectedEntryUUIDs = Set.copyOf(entryUUIDs); + var staleProjectionUUIDs = projectionUUIDs(client.getLedger(), affectedEntryUUIDs); + var candidate = LedgerModelCopy.copyLedger(client.getLedger()); + + mutation.accept(candidate); + validate(candidate); + staleProjectionUUIDs.addAll(projectionUUIDs(candidate, affectedEntryUUIDs)); + + synchronizeLiveLedger(candidate, affectedEntryUUIDs); + + if (refreshProjections) + { + removeMaterializedProjections(staleProjectionUUIDs); + materializer.accept(client); + } + } + + public void refresh() + { + materializer.accept(client); + } + + public LedgerEntry attachEntry(LedgerEntry entry) + { + Objects.requireNonNull(entry); + + var entryUUID = entry.getUUID(); + + mutateEntries(List.of(entryUUID), + ledger -> LedgerGraphWriter.addEntry(ledger, LedgerModelCopy.copyEntry(entry)), false); + + return entryByUUID(client.getLedger(), entryUUID); + } + + public void removeEntry(LedgerEntry entry) + { + Objects.requireNonNull(entry); + + var entryUUID = entry.getUUID(); + + mutateEntries(List.of(entryUUID), ledger -> { + var current = entryByUUID(ledger, entryUUID); + + LedgerGraphWriter.removeEntry(ledger, current); + }); + } + + public void replaceSameShapeEntry(LedgerEntry currentEntry, LedgerEntry replacement) + { + Objects.requireNonNull(currentEntry); + Objects.requireNonNull(replacement); + + var currentUUID = currentEntry.getUUID(); + var preparedReplacement = prepareSameShapeReplacement(currentEntry, replacement); + + mutateEntries(List.of(currentUUID), ledger -> replaceEntry(ledger, currentUUID, + LedgerModelCopy.copyEntry(preparedReplacement))); + } + + public void splitEntry(LedgerEntry entry, List replacementEntries) + { + Objects.requireNonNull(entry); + Objects.requireNonNull(replacementEntries); + + if (replacementEntries.isEmpty()) + throw new IllegalArgumentException( + LedgerDiagnosticCode.LEDGER_CORE_007.message("Ledger entry split requires replacement entries")); //$NON-NLS-1$ + + var replacementUUIDs = replacementEntries.stream().map(LedgerEntry::getUUID).collect(Collectors.toSet()); + + if (replacementUUIDs.size() != replacementEntries.size()) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CORE_008 + .message("Ledger entry split replacement UUIDs must be unique")); //$NON-NLS-1$ + + if (!replacementUUIDs.contains(entry.getUUID())) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CORE_009 + .message("Ledger entry split must preserve the original entry UUID on one side")); //$NON-NLS-1$ + + var affectedEntryUUIDs = new HashSet<>(replacementUUIDs); + + affectedEntryUUIDs.add(entry.getUUID()); + + mutateEntries(affectedEntryUUIDs, ledger -> { + var current = entryByUUID(ledger, entry.getUUID()); + + LedgerGraphWriter.removeEntry(ledger, current); + replacementEntries.stream().map(LedgerModelCopy::copyEntry) + .forEach(replacement -> LedgerGraphWriter.addEntry(ledger, replacement)); + }); + } + + Ledger getLedger() + { + return client.getLedger(); + } + + private LedgerEntry prepareSameShapeReplacement(LedgerEntry currentEntry, LedgerEntry replacement) + { + if (currentEntry.getType() != replacement.getType()) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CORE_010 + .message("Entry replacement must keep the same LedgerEntryType")); //$NON-NLS-1$ + + var currentByRole = projectionsByUniqueRole(currentEntry); + var replacementByRole = projectionsByUniqueRole(replacement); + + if (!currentByRole.keySet().equals(replacementByRole.keySet())) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_002 + .message("Entry replacement projection roles do not match")); //$NON-NLS-1$ + + var prepared = LedgerModelCopy.copyEntry(replacement); + + prepared.setUUID(currentEntry.getUUID()); + + for (var projection : prepared.getProjectionRefs()) + { + var currentProjection = currentByRole.get(projection.getRole()); + + projection.setUUID(currentProjection.getUUID()); + } + + return prepared; + } + + private java.util.Map projectionsByUniqueRole(LedgerEntry entry) + { + var result = new java.util.EnumMap(LedgerProjectionRole.class); + + for (var projection : entry.getProjectionRefs()) + { + if (result.put(projection.getRole(), projection) != null) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_003 + .message("Projection role is ambiguous: " + projection.getRole())); //$NON-NLS-1$ + } + + return result; + } + + private void replaceEntry(Ledger ledger, String currentUUID, LedgerEntry replacement) + { + var current = entryByUUID(ledger, currentUUID); + + LedgerGraphWriter.replaceEntry(ledger, current, replacement); + } + + private void synchronizeLiveLedger(Ledger candidate, Set affectedEntryUUIDs) + { + for (var entryUUID : affectedEntryUUIDs) + { + var liveEntry = findEntry(client.getLedger(), entryUUID); + var candidateEntry = findEntry(candidate, entryUUID); + + if (candidateEntry.isPresent()) + { + if (liveEntry.isPresent()) + LedgerGraphWriter.replaceEntryContents(liveEntry.get(), candidateEntry.get()); + else + LedgerGraphWriter.addEntry(client.getLedger(), LedgerModelCopy.copyEntry(candidateEntry.get())); + } + else + { + liveEntry.ifPresent(entry -> LedgerGraphWriter.removeEntry(client.getLedger(), entry)); + } + } + } + + private void validate(Ledger ledger) + { + var result = LedgerStructuralValidator.validate(ledger); + + if (!result.isOK()) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CORE_011 + .message("Invalid ledger structural mutation: " + result.format())); //$NON-NLS-1$ + } + + static LedgerEntry entryByUUID(Ledger ledger, String uuid) + { + return findEntry(ledger, uuid) + .orElseThrow(() -> new IllegalArgumentException("Ledger entry not found: " + uuid)); //$NON-NLS-1$ + } + + private static Optional findEntry(Ledger ledger, String uuid) + { + return ledger.getEntries().stream() // + .filter(entry -> entry.getUUID().equals(uuid)) // + .findFirst(); + } + + private Set projectionUUIDs(Ledger ledger, Set affectedEntryUUIDs) + { + return ledger.getEntries().stream() // + .filter(entry -> affectedEntryUUIDs.contains(entry.getUUID())) // + .flatMap(entry -> entry.getProjectionRefs().stream()) // + .map(LedgerProjectionRef::getUUID) // + .collect(Collectors.toCollection(HashSet::new)); + } + + private void removeMaterializedProjections(Set projectionUUIDs) + { + if (projectionUUIDs.isEmpty()) + return; + + for (var account : client.getAccounts()) + removeAccountProjections(account.getTransactions(), projectionUUIDs); + + for (var portfolio : client.getPortfolios()) + removePortfolioProjections(portfolio.getTransactions(), projectionUUIDs); + } + + private void removeAccountProjections(List transactions, Set projectionUUIDs) + { + transactions.removeIf(transaction -> transaction instanceof LedgerBackedTransaction + && projectionUUIDs.contains(transaction.getUUID())); + } + + private void removePortfolioProjections(List transactions, Set projectionUUIDs) + { + transactions.removeIf(transaction -> transaction instanceof LedgerBackedTransaction + && projectionUUIDs.contains(transaction.getUUID())); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferToDepositRemovalConverter.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferToDepositRemovalConverter.java new file mode 100644 index 0000000000..fe89afb413 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferToDepositRemovalConverter.java @@ -0,0 +1,241 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Objects; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.AccountTransferEntry; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.LedgerAccountTransferToDepositRemovalConverter.SplitResult; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerMutationContext; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.ProjectionMembership; +import name.abuchen.portfolio.model.ledger.ProjectionMembershipRole; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedAccountTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionSupport; + +/** + * Splits a ledger-backed account transfer into separate deposit and removal transactions. + * This class is part of the Ledger compatibility layer for existing action code. It keeps + * generated transaction references mapped to the correct replacement booking. + */ +public final class LedgerAccountTransferToDepositRemovalConverter +{ + private static final BigDecimal FALLBACK_EXCHANGE_RATE = BigDecimal.ONE; + + private final Client client; + + public LedgerAccountTransferToDepositRemovalConverter(Client client) + { + this.client = Objects.requireNonNull(client); + } + + public SplitResult split(AccountTransferEntry transfer) + { + Objects.requireNonNull(transfer); + + if (!(transfer.getSourceTransaction() instanceof LedgerBackedAccountTransaction sourceTransaction) + || !(transfer.getTargetTransaction() instanceof LedgerBackedAccountTransaction targetTransaction)) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_CONVERT_011.message("Only ledger-backed account transfers can be split")); //$NON-NLS-1$ + + var sourceEntry = sourceTransaction.getLedgerEntry(); + var targetEntry = targetTransaction.getLedgerEntry(); + + if (sourceEntry != targetEntry) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_006 + .message("Account transfer projections do not belong to the same ledger entry")); //$NON-NLS-1$ + + var entry = sourceEntry; + var sourceProjectionUUID = sourceTransaction.getLedgerProjectionRef().getUUID(); + var targetProjectionUUID = targetTransaction.getLedgerProjectionRef().getUUID(); + var sourceAccount = sourceTransaction.getLedgerProjectionRef().getAccount(); + var targetAccount = targetTransaction.getLedgerProjectionRef().getAccount(); + + preflight(entry, sourceTransaction, targetTransaction); + + var sourcePosting = LedgerProjectionSupport.primaryPosting(entry, sourceTransaction.getLedgerProjectionRef()); + var targetPosting = LedgerProjectionSupport.primaryPosting(entry, targetTransaction.getLedgerProjectionRef()); + var removal = removalEntry(entry, sourceTransaction.getLedgerProjectionRef(), sourcePosting, targetPosting); + var deposit = depositEntry(entry, targetTransaction.getLedgerProjectionRef(), + targetPosting); + var executionRefUpdates = LedgerInvestmentPlanRefSupport.prepareAccountTransferSplitExecutionRefUpdates(client, + entry, sourceTransaction.getLedgerProjectionRef(), targetTransaction.getLedgerProjectionRef(), + removal, deposit); + + new LedgerMutationContext(client).splitEntry(entry, List.of(removal, deposit)); + executionRefUpdates.apply(); + + return new SplitResult(find(sourceAccount, sourceProjectionUUID), find(targetAccount, targetProjectionUUID)); + } + + private void preflight(LedgerEntry entry, LedgerBackedAccountTransaction sourceTransaction, + LedgerBackedAccountTransaction targetTransaction) + { + if (entry.getType() != LedgerEntryType.CASH_TRANSFER) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_012.message("Ledger entry is not an account transfer")); //$NON-NLS-1$ + + var sourceProjection = sourceTransaction.getLedgerProjectionRef(); + var targetProjection = targetTransaction.getLedgerProjectionRef(); + + if (sourceProjection.getRole() != LedgerProjectionRole.SOURCE_ACCOUNT + || targetProjection.getRole() != LedgerProjectionRole.TARGET_ACCOUNT) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_007 + .message("Account transfer projection roles are malformed")); //$NON-NLS-1$ + + var sourceProjectionInEntry = uniqueProjection(entry, LedgerProjectionRole.SOURCE_ACCOUNT); + var targetProjectionInEntry = uniqueProjection(entry, LedgerProjectionRole.TARGET_ACCOUNT); + + if (sourceProjectionInEntry != sourceProjection || targetProjectionInEntry != targetProjection) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_008 + .message("Selected projections do not match the ledger entry")); //$NON-NLS-1$ + + var sourcePosting = LedgerProjectionSupport.primaryPosting(entry, sourceProjection); + var targetPosting = LedgerProjectionSupport.primaryPosting(entry, targetProjection); + + if (sourcePosting == targetPosting) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_013.message("Account transfer source and target postings are ambiguous")); //$NON-NLS-1$ + + validateTransferPosting(sourcePosting, sourceProjection.getAccount()); + validateTransferPosting(targetPosting, targetProjection.getAccount()); + + for (var posting : entry.getPostings()) + { + if (posting != sourcePosting && posting != targetPosting) + throw new UnsupportedOperationException( + LedgerDiagnosticCode.LEDGER_CONVERT_014.message("Ledger account transfers with unit postings cannot be split")); //$NON-NLS-1$ + } + } + + private void validateTransferPosting(LedgerPosting posting, Account account) + { + if (posting.getType() != LedgerPostingType.CASH) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_015.message("Account transfer posting is not a cash posting")); //$NON-NLS-1$ + + if (posting.getAccount() != account) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_009 + .message("Account transfer posting owner does not match projection owner")); //$NON-NLS-1$ + + if (posting.getSecurity() != null || posting.getShares() != 0L) + throw new UnsupportedOperationException( + LedgerDiagnosticCode.LEDGER_CONVERT_016.message("Ledger account transfers with security or shares cannot be split")); //$NON-NLS-1$ + + if (!posting.getParameters().isEmpty()) + throw new UnsupportedOperationException( + LedgerDiagnosticCode.LEDGER_CONVERT_017.message("Ledger account transfers with posting parameters cannot be split")); //$NON-NLS-1$ + } + + private LedgerEntry removalEntry(LedgerEntry source, LedgerProjectionRef sourceProjection, + LedgerPosting sourcePosting, LedgerPosting targetPosting) + { + var entry = baseEntry(source, source.getUUID(), LedgerEntryType.REMOVAL); + var posting = cashPosting(sourcePosting, sourceProjection.getAccount(), targetPosting); + var projection = accountProjection(sourceProjection, posting); + + entry.addPosting(posting); + entry.addProjectionRef(projection); + + return entry; + } + + private LedgerEntry depositEntry(LedgerEntry source, LedgerProjectionRef targetProjection, + LedgerPosting targetPosting) + { + var entry = baseEntry(source, null, LedgerEntryType.DEPOSIT); + var posting = cashPosting(targetPosting, targetProjection.getAccount(), null); + var projection = accountProjection(targetProjection, posting); + + entry.addPosting(posting); + entry.addProjectionRef(projection); + + return entry; + } + + private LedgerEntry baseEntry(LedgerEntry source, String uuid, LedgerEntryType type) + { + var entry = uuid != null ? new LedgerEntry(uuid) : new LedgerEntry(); + + entry.setType(type); + entry.setDateTime(source.getDateTime()); + entry.setNote(source.getNote()); + entry.setSource(source.getSource()); + entry.setUpdatedAt(source.getUpdatedAt()); + + return entry; + } + + private LedgerPosting cashPosting(LedgerPosting source, Account account, LedgerPosting fallbackForexPosting) + { + var posting = new LedgerPosting(source.getUUID()); + + posting.setType(LedgerPostingType.CASH); + posting.setAccount(account); + posting.setAmount(source.getAmount()); + posting.setCurrency(source.getCurrency()); + + if (source.getForexAmount() != null && source.getForexCurrency() != null + && source.getExchangeRate() != null) + { + posting.setForexAmount(source.getForexAmount()); + posting.setForexCurrency(source.getForexCurrency()); + posting.setExchangeRate(source.getExchangeRate()); + } + else if (fallbackForexPosting != null && !Objects.equals(source.getCurrency(), fallbackForexPosting.getCurrency())) + { + posting.setForexAmount(fallbackForexPosting.getAmount()); + posting.setForexCurrency(fallbackForexPosting.getCurrency()); + posting.setExchangeRate(FALLBACK_EXCHANGE_RATE); + } + else if (!Objects.equals(source.getCurrency(), account.getCurrencyCode())) + { + posting.setForexAmount(source.getAmount()); + posting.setForexCurrency(account.getCurrencyCode()); + posting.setExchangeRate(FALLBACK_EXCHANGE_RATE); + } + + return posting; + } + + private LedgerProjectionRef accountProjection(LedgerProjectionRef source, LedgerPosting posting) + { + var projection = new LedgerProjectionRef(source.getUUID()); + + projection.setRole(LedgerProjectionRole.ACCOUNT); + projection.setAccount(source.getAccount()); + projection.setPrimaryPosting(posting); + projection.setPostingGroupTargetUUID(source.getMembershipsByRole(ProjectionMembershipRole.GROUP_ANCHOR).stream() + .findFirst().map(ProjectionMembership::getPostingUUID).orElse(source.getPostingGroupUUID())); + + return projection; + } + + private LedgerProjectionRef uniqueProjection(LedgerEntry entry, LedgerProjectionRole role) + { + var projections = entry.getProjectionRefs().stream().filter(projection -> projection.getRole() == role) + .toList(); + + if (projections.size() != 1) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_010 + .message("Ledger account transfer must have exactly one " + role + " projection")); //$NON-NLS-1$ //$NON-NLS-2$ + + return projections.get(0); + } + + private AccountTransaction find(Account account, String projectionUUID) + { + return account.getTransactions().stream() // + .filter(LedgerBackedTransaction.class::isInstance) // + .filter(transaction -> projectionUUID.equals(transaction.getUUID())) // + .findFirst() + .orElseThrow(() -> new IllegalStateException( + "Split ledger account projection was not materialized: " + projectionUUID)); //$NON-NLS-1$ + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTypeToggleConverter.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTypeToggleConverter.java new file mode 100644 index 0000000000..cb55316aea --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTypeToggleConverter.java @@ -0,0 +1,147 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.util.Objects; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.TransactionPair; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerMutationContext; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedAccountTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionSupport; + +/** + * Toggles ledger-backed account-only transactions between matching account transaction types. + * This class is part of the Ledger compatibility layer for existing UI and action code. It + * updates the Ledger entry instead of changing the projected legacy transaction directly. + */ +public final class LedgerAccountTypeToggleConverter +{ + private final Client client; + + public LedgerAccountTypeToggleConverter(Client client) + { + this.client = Objects.requireNonNull(client); + } + + public AccountTransaction toggle(TransactionPair transaction) + { + Objects.requireNonNull(transaction); + + if (!(transaction.getTransaction() instanceof LedgerBackedAccountTransaction ledgerTransaction)) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_CONVERT_019.message("Only ledger-backed account transactions can be toggled")); //$NON-NLS-1$ + + var entry = ledgerTransaction.getLedgerEntry(); + var projectionRef = ledgerTransaction.getLedgerProjectionRef(); + var account = projectionRef.getAccount(); + var projectionUUID = projectionRef.getUUID(); + + preflight(entry, projectionRef, transaction, account); + LedgerInvestmentPlanRefSupport.requireCurrentRefsResolveUniquely(client, entry); + + new LedgerMutationContext(client).mutateEntry(entry, this::toggle); + + return find(account, projectionUUID); + } + + private void preflight(LedgerEntry entry, LedgerProjectionRef projectionRef, + TransactionPair transaction, Account account) + { + if (targetType(entry.getType()) == null) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PROJ_011.message("Only ledger-backed deposit/removal and interest entries can be toggled")); //$NON-NLS-1$ + + if (projectionRef.getRole() != LedgerProjectionRole.ACCOUNT) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PROJ_012.message("Only account projections can be toggled")); //$NON-NLS-1$ + + if (transaction.getOwner() != account) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_013.message("Selected account does not own the ledger projection")); //$NON-NLS-1$ + + var projection = requireOneProjection(entry); + var primaryPosting = requirePrimaryPosting(entry, projection); + + if (projection != projectionRef) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_014.message("Selected projection is not the unique account projection")); //$NON-NLS-1$ + + if (primaryPosting.getAccount() != projection.getAccount()) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_015.message("Account projection and posting account do not match")); //$NON-NLS-1$ + } + + private void toggle(LedgerEntry entry) + { + entry.setType(targetType(entry.getType())); + } + + private LedgerEntryType targetType(LedgerEntryType currentType) + { + if (currentType == null) + return null; + + return switch (currentType) + { + case DEPOSIT -> LedgerEntryType.REMOVAL; + case REMOVAL -> LedgerEntryType.DEPOSIT; + case INTEREST -> LedgerEntryType.INTEREST_CHARGE; + case INTEREST_CHARGE -> LedgerEntryType.INTEREST; + case FEES -> LedgerEntryType.FEES_REFUND; + case FEES_REFUND -> LedgerEntryType.FEES; + case TAXES -> LedgerEntryType.TAX_REFUND; + case TAX_REFUND -> LedgerEntryType.TAXES; + default -> null; + }; + } + + private LedgerPosting requirePrimaryPosting(LedgerEntry entry, LedgerProjectionRef projection) + { + var primaryPosting = LedgerProjectionSupport.primaryPosting(entry, projection); + + if (primaryPosting == null) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_016.message("Account projection primary posting is ambiguous")); //$NON-NLS-1$ + + var expectedPostingType = postingType(entry.getType()); + + if (primaryPosting.getType() != expectedPostingType) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_017.message("Account projection primary posting has unexpected type")); //$NON-NLS-1$ + + return primaryPosting; + } + + private LedgerPostingType postingType(LedgerEntryType entryType) + { + return switch (entryType) + { + case DEPOSIT, REMOVAL, INTEREST, INTEREST_CHARGE -> LedgerPostingType.CASH; + case FEES, FEES_REFUND -> LedgerPostingType.FEE; + case TAXES, TAX_REFUND -> LedgerPostingType.TAX; + default -> null; + }; + } + + private LedgerProjectionRef requireOneProjection(LedgerEntry entry) + { + var projections = entry.getProjectionRefs().stream() + .filter(projection -> projection.getRole() == LedgerProjectionRole.ACCOUNT).toList(); + + if (projections.size() != 1) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_018.message("Ledger account-only entry must have exactly one account projection")); //$NON-NLS-1$ + + return projections.get(0); + } + + private AccountTransaction find(Account account, String projectionUUID) + { + return account.getTransactions().stream() // + .filter(LedgerBackedTransaction.class::isInstance) // + .filter(transaction -> projectionUUID.equals(transaction.getUUID())) // + .findFirst() + .orElseThrow(() -> new IllegalStateException( + "Toggled ledger account projection was not materialized: " + projectionUUID)); //$NON-NLS-1$ + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellDeliveryConverter.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellDeliveryConverter.java new file mode 100644 index 0000000000..cd46d9aee2 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellDeliveryConverter.java @@ -0,0 +1,293 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.BuySellEntry; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.PortfolioTransaction; +import name.abuchen.portfolio.model.TransactionPair; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerMutationContext; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedPortfolioTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.money.ExchangeRate; + +/** + * Converts ledger-backed buy/sell transactions to deliveries and back. + * This class is part of the Ledger compatibility layer for existing UI and action code. It + * validates the conversion before replacing the Ledger entry shape. + */ +public final class LedgerBuySellDeliveryConverter +{ + private static final BigDecimal DEFAULT_EXCHANGE_RATE = BigDecimal.ONE; + + private final Client client; + + public LedgerBuySellDeliveryConverter(Client client) + { + this.client = Objects.requireNonNull(client); + } + + public PortfolioTransaction convertBuySellToDelivery(TransactionPair transaction) + { + Objects.requireNonNull(transaction); + + if (!(transaction.getTransaction() instanceof LedgerBackedPortfolioTransaction ledgerTransaction)) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_CONVERT_020.message("Only ledger-backed buy/sell transactions can be converted")); //$NON-NLS-1$ + + var entry = ledgerTransaction.getLedgerEntry(); + var projectionRef = ledgerTransaction.getLedgerProjectionRef(); + var portfolio = projectionRef.getPortfolio(); + var projectionUUID = projectionRef.getUUID(); + + preflightBuySell(entry, projectionRef, transaction, portfolio); + var targetRole = entry.getType() == LedgerEntryType.BUY ? LedgerProjectionRole.DELIVERY_INBOUND + : LedgerProjectionRole.DELIVERY_OUTBOUND; + var roleChange = LedgerInvestmentPlanRefSupport.roleChange(projectionUUID, LedgerProjectionRole.PORTFOLIO, + targetRole); + LedgerInvestmentPlanRefSupport.requireRefsFollowRoleChanges(client, entry, roleChange); + + new LedgerMutationContext(client).mutateEntry(entry, editedEntry -> convert(editedEntry, projectionUUID)); + LedgerInvestmentPlanRefSupport.updateProjectionRoles(client, entry, roleChange); + + return find(portfolio, projectionUUID); + } + + public BuySellEntry convertDeliveryToBuySell(TransactionPair transaction) + { + Objects.requireNonNull(transaction); + + if (!(transaction.getTransaction() instanceof LedgerBackedPortfolioTransaction ledgerTransaction)) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_CONVERT_021.message("Only ledger-backed deliveries can be converted")); //$NON-NLS-1$ + + var entry = ledgerTransaction.getLedgerEntry(); + var projectionRef = ledgerTransaction.getLedgerProjectionRef(); + var portfolio = projectionRef.getPortfolio(); + var account = requireReferenceAccount(portfolio); + var portfolioProjectionUUID = projectionRef.getUUID(); + var accountProjectionUUID = UUID.randomUUID().toString(); + var cashPostingUUID = UUID.randomUUID().toString(); + + preflightDelivery(entry, projectionRef, transaction, portfolio, account); + var roleChange = LedgerInvestmentPlanRefSupport.roleChange(portfolioProjectionUUID, projectionRef.getRole(), + LedgerProjectionRole.PORTFOLIO); + LedgerInvestmentPlanRefSupport.requireRefsFollowRoleChanges(client, entry, roleChange); + + new LedgerMutationContext(client).mutateEntry(entry, editedEntry -> convertDeliveryToBuySell(editedEntry, + portfolioProjectionUUID, account, accountProjectionUUID, cashPostingUUID)); + LedgerInvestmentPlanRefSupport.updateProjectionRoles(client, entry, roleChange); + + var portfolioTransaction = find(portfolio, portfolioProjectionUUID); + var accountTransaction = find(account, accountProjectionUUID); + + return BuySellEntry.readOnly(portfolio, portfolioTransaction, account, accountTransaction); + } + + private void preflightBuySell(LedgerEntry entry, LedgerProjectionRef projectionRef, + TransactionPair transaction, Portfolio portfolio) + { + if (entry.getType() != LedgerEntryType.BUY && entry.getType() != LedgerEntryType.SELL) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PROJ_019.message("Only ledger-backed buy/sell entries can be converted")); //$NON-NLS-1$ + + if (projectionRef.getRole() != LedgerProjectionRole.PORTFOLIO) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PROJ_020.message("Only the portfolio side of a buy/sell entry can be converted")); //$NON-NLS-1$ + + if (transaction.getOwner() != portfolio) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_021.message("Selected portfolio does not own the ledger projection")); //$NON-NLS-1$ + + requireOnePosting(entry, LedgerPostingType.CASH); + requireOnePosting(entry, LedgerPostingType.SECURITY); + requireOneProjection(entry, LedgerProjectionRole.ACCOUNT); + requireOneProjection(entry, LedgerProjectionRole.PORTFOLIO); + } + + private void preflightDelivery(LedgerEntry entry, LedgerProjectionRef projectionRef, + TransactionPair transaction, Portfolio portfolio, Account account) + { + if (entry.getType() != LedgerEntryType.DELIVERY_INBOUND && entry.getType() != LedgerEntryType.DELIVERY_OUTBOUND) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PROJ_022.message("Only ledger-backed delivery entries can be converted")); //$NON-NLS-1$ + + var expectedRole = entry.getType() == LedgerEntryType.DELIVERY_INBOUND ? LedgerProjectionRole.DELIVERY_INBOUND + : LedgerProjectionRole.DELIVERY_OUTBOUND; + + if (projectionRef.getRole() != expectedRole) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PROJ_023.message("Only the delivery projection can be converted")); //$NON-NLS-1$ + + if (transaction.getOwner() != portfolio) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_024.message("Selected portfolio does not own the ledger projection")); //$NON-NLS-1$ + + if (entry.getPostings().stream().anyMatch(posting -> posting.getType() == LedgerPostingType.CASH)) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_022.message("Ledger delivery entry must not already have a cash posting")); //$NON-NLS-1$ + + if (entry.getProjectionRefs().stream().anyMatch(projection -> projection.getRole() == LedgerProjectionRole.ACCOUNT)) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_025.message("Ledger delivery entry must not already have an account projection")); //$NON-NLS-1$ + + requireOnePosting(entry, LedgerPostingType.SECURITY); + requireOneProjection(entry, expectedRole); + } + + private Account requireReferenceAccount(Portfolio portfolio) + { + var account = portfolio.getReferenceAccount(); + + if (account == null) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_023.message("Delivery portfolio has no reference account")); //$NON-NLS-1$ + + return account; + } + + private void convert(LedgerEntry entry, String projectionUUID) + { + var targetType = entry.getType() == LedgerEntryType.BUY ? LedgerEntryType.DELIVERY_INBOUND + : LedgerEntryType.DELIVERY_OUTBOUND; + var targetRole = targetType == LedgerEntryType.DELIVERY_INBOUND ? LedgerProjectionRole.DELIVERY_INBOUND + : LedgerProjectionRole.DELIVERY_OUTBOUND; + + List.copyOf(entry.getPostings()).stream() // + .filter(posting -> posting.getType() == LedgerPostingType.CASH) // + .forEach(entry::removePosting); + + List.copyOf(entry.getProjectionRefs()).stream() // + .filter(projection -> projection.getRole() == LedgerProjectionRole.ACCOUNT) // + .forEach(entry::removeProjectionRef); + + var portfolioProjection = entry.getProjectionRefs().stream() // + .filter(projection -> projectionUUID.equals(projection.getUUID())) // + .findFirst() + .orElseThrow(() -> new IllegalArgumentException( + "Ledger portfolio projection not found: " + projectionUUID)); //$NON-NLS-1$ + + entry.setType(targetType); + portfolioProjection.setRole(targetRole); + } + + private void convertDeliveryToBuySell(LedgerEntry entry, String portfolioProjectionUUID, Account account, + String accountProjectionUUID, String cashPostingUUID) + { + var targetType = entry.getType() == LedgerEntryType.DELIVERY_INBOUND ? LedgerEntryType.BUY + : LedgerEntryType.SELL; + var securityPosting = requireOnePosting(entry, LedgerPostingType.SECURITY); + var cashPosting = new LedgerPosting(cashPostingUUID); + var accountProjection = new LedgerProjectionRef(accountProjectionUUID); + var portfolioProjection = entry.getProjectionRefs().stream() // + .filter(projection -> portfolioProjectionUUID.equals(projection.getUUID())) // + .findFirst() + .orElseThrow(() -> new IllegalArgumentException( + "Ledger delivery projection not found: " + portfolioProjectionUUID)); //$NON-NLS-1$ + var unitPostings = entry.getPostings().stream() // + .filter(posting -> posting != securityPosting) // + .toList(); + + cashPosting.setType(LedgerPostingType.CASH); + cashPosting.setAccount(account); + applyDeliveryCashPosting(securityPosting, cashPosting, account); + + accountProjection.setRole(LedgerProjectionRole.ACCOUNT); + accountProjection.setAccount(account); + accountProjection.setPrimaryPosting(cashPosting); + + portfolioProjection.setRole(LedgerProjectionRole.PORTFOLIO); + + List.copyOf(entry.getPostings()).forEach(entry::removePosting); + entry.addPosting(cashPosting); + entry.addPosting(securityPosting); + unitPostings.forEach(entry::addPosting); + + List.copyOf(entry.getProjectionRefs()).forEach(entry::removeProjectionRef); + entry.addProjectionRef(accountProjection); + entry.addProjectionRef(portfolioProjection); + + entry.setType(targetType); + } + + private void applyDeliveryCashPosting(LedgerPosting securityPosting, LedgerPosting cashPosting, Account account) + { + var accountCurrency = account.getCurrencyCode(); + + if (Objects.equals(securityPosting.getCurrency(), accountCurrency)) + { + cashPosting.setAmount(securityPosting.getAmount()); + cashPosting.setCurrency(accountCurrency); + return; + } + + if (hasCompleteForex(securityPosting) && Objects.equals(securityPosting.getForexCurrency(), accountCurrency)) + { + if (securityPosting.getExchangeRate().signum() <= 0) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_FOREX_003 + .message("Delivery forex exchange rate is not positive")); //$NON-NLS-1$ + + cashPosting.setAmount(securityPosting.getForexAmount()); + cashPosting.setCurrency(accountCurrency); + cashPosting.setForexAmount(securityPosting.getAmount()); + cashPosting.setForexCurrency(securityPosting.getCurrency()); + cashPosting.setExchangeRate(ExchangeRate.inverse(securityPosting.getExchangeRate())); + return; + } + + cashPosting.setAmount(securityPosting.getAmount()); + cashPosting.setCurrency(accountCurrency); + cashPosting.setForexAmount(securityPosting.getAmount()); + cashPosting.setForexCurrency(securityPosting.getCurrency()); + cashPosting.setExchangeRate(DEFAULT_EXCHANGE_RATE); + } + + private boolean hasCompleteForex(LedgerPosting posting) + { + return posting.getForexAmount() != null && posting.getForexCurrency() != null + && posting.getExchangeRate() != null; + } + + private LedgerPosting requireOnePosting(LedgerEntry entry, LedgerPostingType type) + { + var postings = entry.getPostings().stream().filter(posting -> posting.getType() == type).toList(); + + if (postings.size() != 1) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_024.message("Ledger buy/sell entry must have exactly one " + type + " posting")); //$NON-NLS-1$ //$NON-NLS-2$ + + return postings.get(0); + } + + private void requireOneProjection(LedgerEntry entry, LedgerProjectionRole role) + { + var count = entry.getProjectionRefs().stream().filter(projection -> projection.getRole() == role).count(); + + if (count != 1) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_026 + .message("Ledger buy/sell entry must have exactly one " + role + " projection")); //$NON-NLS-1$ //$NON-NLS-2$ + } + + private PortfolioTransaction find(Portfolio portfolio, String projectionUUID) + { + return portfolio.getTransactions().stream() // + .filter(LedgerBackedTransaction.class::isInstance) // + .filter(transaction -> projectionUUID.equals(transaction.getUUID())) // + .findFirst() + .orElseThrow(() -> new IllegalStateException( + "Converted ledger delivery projection was not materialized: " //$NON-NLS-1$ + + projectionUUID)); + } + + private AccountTransaction find(Account account, String projectionUUID) + { + return account.getTransactions().stream() // + .filter(LedgerBackedTransaction.class::isInstance) // + .filter(transaction -> projectionUUID.equals(transaction.getUUID())) // + .findFirst() + .orElseThrow(() -> new IllegalStateException( + "Converted ledger account projection was not materialized: " //$NON-NLS-1$ + + projectionUUID)); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellReversalConverter.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellReversalConverter.java new file mode 100644 index 0000000000..57c5af2240 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellReversalConverter.java @@ -0,0 +1,212 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.util.Objects; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.BuySellEntry; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.PortfolioTransaction; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerMutationContext; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedAccountTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedPortfolioTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionSupport; +import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.MoneyCollectors; + +/** + * Reverses ledger-backed buy and sell transactions. + * This class is part of the Ledger compatibility layer for existing UI and action code. It + * rewrites the Ledger entry while preserving consistent runtime projections. + */ +public final class LedgerBuySellReversalConverter +{ + private final Client client; + + public LedgerBuySellReversalConverter(Client client) + { + this.client = Objects.requireNonNull(client); + } + + public BuySellEntry reverse(BuySellEntry entry) + { + Objects.requireNonNull(entry); + + if (!(entry.getAccountTransaction() instanceof LedgerBackedAccountTransaction accountTransaction) + || !(entry.getPortfolioTransaction() instanceof LedgerBackedPortfolioTransaction portfolioTransaction)) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_CONVERT_027.message("Only ledger-backed buy/sell transactions can be reversed")); //$NON-NLS-1$ + + var ledgerEntry = accountTransaction.getLedgerEntry(); + var accountProjectionUUID = accountTransaction.getLedgerProjectionRef().getUUID(); + var portfolioProjectionUUID = portfolioTransaction.getLedgerProjectionRef().getUUID(); + var account = accountTransaction.getLedgerProjectionRef().getAccount(); + var portfolio = portfolioTransaction.getLedgerProjectionRef().getPortfolio(); + + preflight(ledgerEntry, accountTransaction, portfolioTransaction); + LedgerInvestmentPlanRefSupport.requireCurrentRefsResolveUniquely(client, ledgerEntry); + + new LedgerMutationContext(client).mutateEntry(ledgerEntry, this::reverse); + + return BuySellEntry.readOnly(portfolio, find(portfolio, portfolioProjectionUUID), account, + find(account, accountProjectionUUID)); + } + + public boolean canReverseSafely(BuySellEntry entry) + { + Objects.requireNonNull(entry); + + if (!(entry.getAccountTransaction() instanceof LedgerBackedAccountTransaction accountTransaction) + || !(entry.getPortfolioTransaction() instanceof LedgerBackedPortfolioTransaction portfolioTransaction) + || accountTransaction.getLedgerEntry() != portfolioTransaction.getLedgerEntry()) + return false; + + try + { + var ledgerEntry = accountTransaction.getLedgerEntry(); + preflight(ledgerEntry, accountTransaction, portfolioTransaction); + LedgerInvestmentPlanRefSupport.requireCurrentRefsResolveUniquely(client, ledgerEntry); + return true; + } + catch (IllegalArgumentException | UnsupportedOperationException e) + { + return false; + } + } + + private void preflight(LedgerEntry entry, LedgerBackedAccountTransaction accountTransaction, + LedgerBackedPortfolioTransaction portfolioTransaction) + { + if (entry.getType() != LedgerEntryType.BUY && entry.getType() != LedgerEntryType.SELL) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_CONVERT_028.message("Only ledger-backed buy/sell entries can be reversed")); //$NON-NLS-1$ + + if (accountTransaction.getLedgerEntry() != portfolioTransaction.getLedgerEntry()) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_028.message("Buy/sell projections do not belong to the same ledger entry")); //$NON-NLS-1$ + + if (accountTransaction.getLedgerProjectionRef().getRole() != LedgerProjectionRole.ACCOUNT + || portfolioTransaction.getLedgerProjectionRef().getRole() != LedgerProjectionRole.PORTFOLIO) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_029.message("Buy/sell projections are not account/portfolio projections")); //$NON-NLS-1$ + + var accountProjection = uniqueProjection(entry, LedgerProjectionRole.ACCOUNT); + var portfolioProjection = uniqueProjection(entry, LedgerProjectionRole.PORTFOLIO); + var cashPosting = requireOnePosting(entry, LedgerPostingType.CASH); + var securityPosting = requireOnePosting(entry, LedgerPostingType.SECURITY); + + if (LedgerProjectionSupport.primaryPosting(entry, accountProjection) != cashPosting + || LedgerProjectionSupport.primaryPosting(entry, portfolioProjection) != securityPosting) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_030.message("Buy/sell projection primary postings are ambiguous")); //$NON-NLS-1$ + + if (cashPosting.getAccount() != accountProjection.getAccount() + || securityPosting.getPortfolio() != portfolioProjection.getPortfolio()) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_031.message("Buy/sell projection and posting owners do not match")); //$NON-NLS-1$ + + rejectPostingForex(cashPosting); + rejectPostingForex(securityPosting); + } + + private void reverse(LedgerEntry entry) + { + var currentType = entry.getType(); + var targetType = currentType == LedgerEntryType.BUY ? LedgerEntryType.SELL : LedgerEntryType.BUY; + var cashPosting = requireOnePosting(entry, LedgerPostingType.CASH); + var securityPosting = requireOnePosting(entry, LedgerPostingType.SECURITY); + var amount = reversedAmount(entry, currentType); + + cashPosting.setAmount(amount.getAmount()); + cashPosting.setCurrency(amount.getCurrencyCode()); + securityPosting.setAmount(amount.getAmount()); + securityPosting.setCurrency(amount.getCurrencyCode()); + entry.setType(targetType); + } + + private Money reversedAmount(LedgerEntry entry, LedgerEntryType currentType) + { + var securityPosting = requireOnePosting(entry, LedgerPostingType.SECURITY); + var transactionCurrency = securityPosting.getCurrency(); + var grossAmount = entry.getPostings().stream() // + .filter(posting -> posting.getType() == LedgerPostingType.GROSS_VALUE) // + .findFirst() // + .map(posting -> Money.of(posting.getCurrency(), posting.getAmount())) // + .orElseGet(() -> Money.of(transactionCurrency, grossValueAmount(entry, currentType))); + var feesAndTaxes = entry.getPostings().stream() // + .filter(posting -> posting.getType() == LedgerPostingType.FEE + || posting.getType() == LedgerPostingType.TAX) // + .map(posting -> Money.of(posting.getCurrency(), posting.getAmount())) // + .collect(MoneyCollectors.sum(transactionCurrency)); + + return currentType == LedgerEntryType.BUY ? grossAmount.subtract(feesAndTaxes) + : grossAmount.add(feesAndTaxes); + } + + private long grossValueAmount(LedgerEntry entry, LedgerEntryType currentType) + { + var securityPosting = requireOnePosting(entry, LedgerPostingType.SECURITY); + var feesAndTaxes = entry.getPostings().stream() // + .filter(posting -> posting.getType() == LedgerPostingType.FEE + || posting.getType() == LedgerPostingType.TAX) // + .map(posting -> Money.of(posting.getCurrency(), posting.getAmount())) // + .collect(MoneyCollectors.sum(securityPosting.getCurrency())).getAmount(); + + return currentType == LedgerEntryType.BUY ? securityPosting.getAmount() - feesAndTaxes + : securityPosting.getAmount() + feesAndTaxes; + } + + private void rejectPostingForex(LedgerPosting posting) + { + if (posting.getForexAmount() != null || posting.getForexCurrency() != null || posting.getExchangeRate() != null) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_CONVERT_001 + .message("Ledger buy/sell posting forex metadata cannot be reversed")); //$NON-NLS-1$ + } + + private LedgerPosting requireOnePosting(LedgerEntry entry, LedgerPostingType type) + { + var postings = entry.getPostings().stream().filter(posting -> posting.getType() == type).toList(); + + if (postings.size() != 1) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_029.message("Ledger buy/sell entry must have exactly one " + type + " posting")); //$NON-NLS-1$ //$NON-NLS-2$ + + return postings.get(0); + } + + private LedgerProjectionRef uniqueProjection(LedgerEntry entry, LedgerProjectionRole role) + { + var projections = entry.getProjectionRefs().stream().filter(projection -> projection.getRole() == role).toList(); + + if (projections.size() != 1) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_032 + .message("Expected one projection for role " + role + " but found " //$NON-NLS-1$ //$NON-NLS-2$ + + projections.size())); + + return projections.get(0); + } + + private AccountTransaction find(Account account, String projectionUUID) + { + return account.getTransactions().stream() // + .filter(LedgerBackedTransaction.class::isInstance) // + .filter(transaction -> projectionUUID.equals(transaction.getUUID())) // + .findFirst() + .orElseThrow(() -> new IllegalStateException( + "Ledger buy/sell account projection was not materialized: " //$NON-NLS-1$ + + projectionUUID)); + } + + private PortfolioTransaction find(Portfolio portfolio, String projectionUUID) + { + return portfolio.getTransactions().stream() // + .filter(LedgerBackedTransaction.class::isInstance) // + .filter(transaction -> projectionUUID.equals(transaction.getUUID())) // + .findFirst() + .orElseThrow(() -> new IllegalStateException( + "Ledger buy/sell portfolio projection was not materialized: " //$NON-NLS-1$ + + projectionUUID)); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryDirectionConverter.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryDirectionConverter.java new file mode 100644 index 0000000000..aeaab80975 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryDirectionConverter.java @@ -0,0 +1,185 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.util.Objects; + +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.PortfolioTransaction; +import name.abuchen.portfolio.model.TransactionPair; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerMutationContext; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedPortfolioTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionSupport; +import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.MoneyCollectors; + +/** + * Reverses ledger-backed delivery transactions between inbound and outbound deliveries. + * This class is part of the Ledger compatibility layer for existing UI and action code. It + * updates the Ledger entry instead of replaying legacy projection setters. + */ +public final class LedgerDeliveryDirectionConverter +{ + private final Client client; + + public LedgerDeliveryDirectionConverter(Client client) + { + this.client = Objects.requireNonNull(client); + } + + public PortfolioTransaction reverse(TransactionPair transaction) + { + Objects.requireNonNull(transaction); + + if (!(transaction.getTransaction() instanceof LedgerBackedPortfolioTransaction ledgerTransaction)) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_CONVERT_034.message("Only ledger-backed delivery transactions can be reversed")); //$NON-NLS-1$ + + var entry = ledgerTransaction.getLedgerEntry(); + var projectionRef = ledgerTransaction.getLedgerProjectionRef(); + var portfolio = projectionRef.getPortfolio(); + var projectionUUID = projectionRef.getUUID(); + + preflight(entry, projectionRef, transaction, portfolio); + var targetRole = role(entry.getType() == LedgerEntryType.DELIVERY_INBOUND + ? LedgerEntryType.DELIVERY_OUTBOUND + : LedgerEntryType.DELIVERY_INBOUND); + var roleChange = LedgerInvestmentPlanRefSupport.roleChange(projectionUUID, projectionRef.getRole(), + targetRole); + LedgerInvestmentPlanRefSupport.requireRefsFollowRoleChanges(client, entry, roleChange); + + new LedgerMutationContext(client).mutateEntry(entry, editedEntry -> reverse(editedEntry, projectionUUID)); + LedgerInvestmentPlanRefSupport.updateProjectionRoles(client, entry, roleChange); + + return find(portfolio, projectionUUID); + } + + private void preflight(LedgerEntry entry, LedgerProjectionRef projectionRef, + TransactionPair transaction, Portfolio portfolio) + { + if (entry.getType() != LedgerEntryType.DELIVERY_INBOUND && entry.getType() != LedgerEntryType.DELIVERY_OUTBOUND) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PROJ_033.message("Only ledger-backed delivery entries can be reversed")); //$NON-NLS-1$ + + var expectedRole = role(entry.getType()); + + if (projectionRef.getRole() != expectedRole) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PROJ_034.message("Only the delivery projection can be reversed")); //$NON-NLS-1$ + + if (transaction.getOwner() != portfolio) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_035.message("Selected portfolio does not own the ledger projection")); //$NON-NLS-1$ + + var projection = requireOneProjection(entry, expectedRole); + var securityPosting = requireOnePosting(entry, LedgerPostingType.SECURITY); + + if (projection != projectionRef) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_036.message("Selected projection is not the unique delivery projection")); //$NON-NLS-1$ + + if (LedgerProjectionSupport.primaryPosting(entry, projection) != securityPosting) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_037.message("Delivery projection primary posting is ambiguous")); //$NON-NLS-1$ + + if (securityPosting.getPortfolio() != projection.getPortfolio()) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_038.message("Delivery projection and posting portfolio do not match")); //$NON-NLS-1$ + + if (securityPosting.getForexAmount() != null || securityPosting.getForexCurrency() != null + || securityPosting.getExchangeRate() != null) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_CONVERT_002 + .message("Ledger delivery posting forex metadata cannot be reversed")); //$NON-NLS-1$ + + reversedAmount(entry, entry.getType()); + } + + private void reverse(LedgerEntry entry, String projectionUUID) + { + var currentType = entry.getType(); + var targetType = currentType == LedgerEntryType.DELIVERY_INBOUND ? LedgerEntryType.DELIVERY_OUTBOUND + : LedgerEntryType.DELIVERY_INBOUND; + var amount = reversedAmount(entry, currentType); + var securityPosting = requireOnePosting(entry, LedgerPostingType.SECURITY); + var projection = entry.getProjectionRefs().stream() // + .filter(item -> projectionUUID.equals(item.getUUID())) // + .findFirst() + .orElseThrow(() -> new IllegalArgumentException( + "Ledger delivery projection not found: " + projectionUUID)); //$NON-NLS-1$ + + securityPosting.setAmount(amount.getAmount()); + securityPosting.setCurrency(amount.getCurrencyCode()); + projection.setRole(role(targetType)); + entry.setType(targetType); + } + + private Money reversedAmount(LedgerEntry entry, LedgerEntryType currentType) + { + var securityPosting = requireOnePosting(entry, LedgerPostingType.SECURITY); + var transactionCurrency = securityPosting.getCurrency(); + var grossAmount = entry.getPostings().stream() // + .filter(posting -> posting.getType() == LedgerPostingType.GROSS_VALUE) // + .findFirst() // + .map(posting -> Money.of(posting.getCurrency(), posting.getAmount())) // + .orElseGet(() -> Money.of(transactionCurrency, grossValueAmount(entry, currentType))); + var feesAndTaxes = entry.getPostings().stream() // + .filter(posting -> posting.getType() == LedgerPostingType.FEE + || posting.getType() == LedgerPostingType.TAX) // + .map(posting -> Money.of(posting.getCurrency(), posting.getAmount())) // + .collect(MoneyCollectors.sum(transactionCurrency)); + + return currentType == LedgerEntryType.DELIVERY_INBOUND ? grossAmount.subtract(feesAndTaxes) + : grossAmount.add(feesAndTaxes); + } + + private long grossValueAmount(LedgerEntry entry, LedgerEntryType currentType) + { + var securityPosting = requireOnePosting(entry, LedgerPostingType.SECURITY); + var feesAndTaxes = entry.getPostings().stream() // + .filter(posting -> posting.getType() == LedgerPostingType.FEE + || posting.getType() == LedgerPostingType.TAX) // + .map(posting -> Money.of(posting.getCurrency(), posting.getAmount())) // + .collect(MoneyCollectors.sum(securityPosting.getCurrency())).getAmount(); + + return currentType == LedgerEntryType.DELIVERY_INBOUND ? securityPosting.getAmount() - feesAndTaxes + : securityPosting.getAmount() + feesAndTaxes; + } + + private LedgerPosting requireOnePosting(LedgerEntry entry, LedgerPostingType type) + { + var postings = entry.getPostings().stream().filter(posting -> posting.getType() == type).toList(); + + if (postings.size() != 1) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_035.message("Ledger delivery entry must have exactly one " + type + " posting")); //$NON-NLS-1$ //$NON-NLS-2$ + + return postings.get(0); + } + + private LedgerProjectionRef requireOneProjection(LedgerEntry entry, LedgerProjectionRole role) + { + var projections = entry.getProjectionRefs().stream().filter(projection -> projection.getRole() == role).toList(); + + if (projections.size() != 1) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_039 + .message("Ledger delivery entry must have exactly one " + role + " projection")); //$NON-NLS-1$ //$NON-NLS-2$ + + return projections.get(0); + } + + private LedgerProjectionRole role(LedgerEntryType entryType) + { + return entryType == LedgerEntryType.DELIVERY_INBOUND ? LedgerProjectionRole.DELIVERY_INBOUND + : LedgerProjectionRole.DELIVERY_OUTBOUND; + } + + private PortfolioTransaction find(Portfolio portfolio, String projectionUUID) + { + return portfolio.getTransactions().stream() // + .filter(LedgerBackedTransaction.class::isInstance) // + .filter(transaction -> projectionUUID.equals(transaction.getUUID())) // + .findFirst() + .orElseThrow(() -> new IllegalStateException( + "Reversed ledger delivery projection was not materialized: " //$NON-NLS-1$ + + projectionUUID)); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerOwnerPatchHelper.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerOwnerPatchHelper.java new file mode 100644 index 0000000000..955c65c105 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerOwnerPatchHelper.java @@ -0,0 +1,231 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.util.Objects; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.AccountTransferEntry; +import name.abuchen.portfolio.model.BuySellEntry; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.PortfolioTransferEntry; +import name.abuchen.portfolio.model.Transaction; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerMutationContext; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedAccountTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedPortfolioTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionSupport; + +/** + * Moves account or portfolio owners for ledger-backed cross-entry transactions. + * This class is compatibility mutation support. Contributor code should use these + * family-specific paths instead of legacy delete, insert, or cross-entry replay. + */ +public final class LedgerOwnerPatchHelper +{ + private final LedgerMutationContext mutationContext; + + public LedgerOwnerPatchHelper(Client client) + { + this(new LedgerMutationContext(client)); + } + + LedgerOwnerPatchHelper(LedgerMutationContext mutationContext) + { + this.mutationContext = Objects.requireNonNull(mutationContext); + } + + public void moveAccountOnly(LedgerBackedAccountTransaction transaction, Account newAccount) + { + Objects.requireNonNull(transaction); + Objects.requireNonNull(newAccount); + + var entry = transaction.getLedgerEntry(); + + if (!isAccountOnly(entry.getType())) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_046.message("Unsupported account-only owner patch for " + entry.getType())); //$NON-NLS-1$ + + moveAccountProjection(entry, LedgerProjectionRole.ACCOUNT, newAccount); + } + + public void moveDelivery(LedgerBackedPortfolioTransaction transaction, Portfolio newPortfolio) + { + Objects.requireNonNull(transaction); + Objects.requireNonNull(newPortfolio); + + var entry = transaction.getLedgerEntry(); + + if (entry.getType() != LedgerEntryType.DELIVERY_INBOUND && entry.getType() != LedgerEntryType.DELIVERY_OUTBOUND) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_047.message("Unsupported delivery owner patch for " + entry.getType())); //$NON-NLS-1$ + + movePortfolioProjection(entry, transaction.getLedgerProjectionRef().getRole(), newPortfolio); + } + + public void moveBuySellAccountSide(LedgerEntry entry, Account newAccount) + { + Objects.requireNonNull(entry); + Objects.requireNonNull(newAccount); + requireBuySell(entry); + + moveAccountProjection(entry, LedgerProjectionRole.ACCOUNT, newAccount); + } + + public void moveBuySellAccountSide(BuySellEntry entry, Account newAccount) + { + Objects.requireNonNull(entry); + moveBuySellAccountSide(ledgerEntry(entry.getAccountTransaction(), entry.getPortfolioTransaction()), + newAccount); + } + + public void moveBuySellPortfolioSide(LedgerEntry entry, Portfolio newPortfolio) + { + Objects.requireNonNull(entry); + Objects.requireNonNull(newPortfolio); + requireBuySell(entry); + + movePortfolioProjection(entry, LedgerProjectionRole.PORTFOLIO, newPortfolio); + } + + public void moveBuySellPortfolioSide(BuySellEntry entry, Portfolio newPortfolio) + { + Objects.requireNonNull(entry); + moveBuySellPortfolioSide(ledgerEntry(entry.getAccountTransaction(), entry.getPortfolioTransaction()), + newPortfolio); + } + + public void moveAccountTransferSource(LedgerEntry entry, Account newAccount) + { + Objects.requireNonNull(entry); + Objects.requireNonNull(newAccount); + requireType(entry, LedgerEntryType.CASH_TRANSFER); + + moveAccountProjection(entry, LedgerProjectionRole.SOURCE_ACCOUNT, newAccount); + } + + public void moveAccountTransferSource(AccountTransferEntry entry, Account newAccount) + { + Objects.requireNonNull(entry); + moveAccountTransferSource(ledgerEntry(entry.getSourceTransaction(), entry.getTargetTransaction()), + newAccount); + } + + public void moveAccountTransferTarget(LedgerEntry entry, Account newAccount) + { + Objects.requireNonNull(entry); + Objects.requireNonNull(newAccount); + requireType(entry, LedgerEntryType.CASH_TRANSFER); + + moveAccountProjection(entry, LedgerProjectionRole.TARGET_ACCOUNT, newAccount); + } + + public void moveAccountTransferTarget(AccountTransferEntry entry, Account newAccount) + { + Objects.requireNonNull(entry); + moveAccountTransferTarget(ledgerEntry(entry.getSourceTransaction(), entry.getTargetTransaction()), + newAccount); + } + + public void movePortfolioTransferSource(LedgerEntry entry, Portfolio newPortfolio) + { + Objects.requireNonNull(entry); + Objects.requireNonNull(newPortfolio); + requireType(entry, LedgerEntryType.SECURITY_TRANSFER); + + movePortfolioProjection(entry, LedgerProjectionRole.SOURCE_PORTFOLIO, newPortfolio); + } + + public void movePortfolioTransferSource(PortfolioTransferEntry entry, Portfolio newPortfolio) + { + Objects.requireNonNull(entry); + movePortfolioTransferSource(ledgerEntry(entry.getSourceTransaction(), entry.getTargetTransaction()), + newPortfolio); + } + + public void movePortfolioTransferTarget(LedgerEntry entry, Portfolio newPortfolio) + { + Objects.requireNonNull(entry); + Objects.requireNonNull(newPortfolio); + requireType(entry, LedgerEntryType.SECURITY_TRANSFER); + + movePortfolioProjection(entry, LedgerProjectionRole.TARGET_PORTFOLIO, newPortfolio); + } + + public void movePortfolioTransferTarget(PortfolioTransferEntry entry, Portfolio newPortfolio) + { + Objects.requireNonNull(entry); + movePortfolioTransferTarget(ledgerEntry(entry.getSourceTransaction(), entry.getTargetTransaction()), + newPortfolio); + } + + private void moveAccountProjection(LedgerEntry entry, LedgerProjectionRole role, Account newAccount) + { + mutationContext.mutateEntry(entry, editedEntry -> { + var projection = uniqueProjection(editedEntry, role); + var posting = LedgerProjectionSupport.primaryPosting(editedEntry, projection); + + projection.setAccount(newAccount); + projection.setPortfolio(null); + posting.setAccount(newAccount); + }); + } + + private void movePortfolioProjection(LedgerEntry entry, LedgerProjectionRole role, Portfolio newPortfolio) + { + mutationContext.mutateEntry(entry, editedEntry -> { + var projection = uniqueProjection(editedEntry, role); + var posting = LedgerProjectionSupport.primaryPosting(editedEntry, projection); + + projection.setPortfolio(newPortfolio); + projection.setAccount(null); + posting.setPortfolio(newPortfolio); + }); + } + + private LedgerProjectionRef uniqueProjection(LedgerEntry entry, LedgerProjectionRole role) + { + var projections = entry.getProjectionRefs().stream().filter(projection -> projection.getRole() == role).toList(); + + if (projections.size() != 1) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_042 + .message("Expected one projection for role " + role + " but found " //$NON-NLS-1$ //$NON-NLS-2$ + + projections.size())); + + return projections.get(0); + } + + private boolean isAccountOnly(LedgerEntryType type) + { + return switch (type) + { + case DEPOSIT, REMOVAL, INTEREST, INTEREST_CHARGE, FEES, FEES_REFUND, TAXES, TAX_REFUND, DIVIDENDS -> true; + default -> false; + }; + } + + private void requireBuySell(LedgerEntry entry) + { + if (entry.getType() != LedgerEntryType.BUY && entry.getType() != LedgerEntryType.SELL) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_048.message("Unsupported buy/sell owner patch for " + entry.getType())); //$NON-NLS-1$ + } + + private void requireType(LedgerEntry entry, LedgerEntryType type) + { + if (entry.getType() != type) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_049.message("Unsupported owner patch for " + entry.getType())); //$NON-NLS-1$ + } + + private LedgerEntry ledgerEntry(Transaction... transactions) + { + for (var transaction : transactions) + { + if (transaction instanceof LedgerBackedTransaction ledgerBackedTransaction) + return ledgerBackedTransaction.getLedgerEntry(); + } + + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_050.message("Ledger-backed transaction not found")); //$NON-NLS-1$ + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioCompositeTypeConverter.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioCompositeTypeConverter.java new file mode 100644 index 0000000000..8b57e011a6 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioCompositeTypeConverter.java @@ -0,0 +1,435 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.PortfolioTransaction; +import name.abuchen.portfolio.model.TransactionPair; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerEntryEditSupport; +import name.abuchen.portfolio.model.ledger.LedgerMutationContext; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedPortfolioTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionSupport; +import name.abuchen.portfolio.money.ExchangeRate; +import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.MoneyCollectors; + +/** + * Applies composite ledger-backed portfolio type conversions as one atomic Ledger mutation. + * This covers the inline transitions that need both a direction reversal and a shape conversion. + */ +public final class LedgerPortfolioCompositeTypeConverter +{ + private static final BigDecimal DEFAULT_EXCHANGE_RATE = BigDecimal.ONE; + + private final Client client; + + public LedgerPortfolioCompositeTypeConverter(Client client) + { + this.client = Objects.requireNonNull(client); + } + + public boolean canConvertSafely(TransactionPair transaction) + { + Objects.requireNonNull(transaction); + + try + { + if (!(transaction.getTransaction() instanceof LedgerBackedPortfolioTransaction ledgerTransaction)) + return false; + + prepare(ledgerTransaction, transaction); + return true; + } + catch (RuntimeException e) + { + return false; + } + } + + public PortfolioTransaction convert(TransactionPair transaction) + { + Objects.requireNonNull(transaction); + + if (!(transaction.getTransaction() instanceof LedgerBackedPortfolioTransaction ledgerTransaction)) + throw new UnsupportedOperationException( + LedgerDiagnosticCode.LEDGER_CONVERT_051.message("Only ledger-backed portfolio transactions can use composite conversion")); //$NON-NLS-1$ + + var operation = prepare(ledgerTransaction, transaction); + + new LedgerMutationContext(client).mutateEntry(operation.entry(), operation::apply); + LedgerInvestmentPlanRefSupport.updateProjectionRoles(client, operation.entry(), operation.roleChange()); + + return find(operation.portfolio(), operation.portfolioProjectionUUID()); + } + + private Operation prepare(LedgerBackedPortfolioTransaction ledgerTransaction, + TransactionPair transaction) + { + var entry = ledgerTransaction.getLedgerEntry(); + var projectionRef = ledgerTransaction.getLedgerProjectionRef(); + var type = entry.getType(); + + if (type == LedgerEntryType.BUY || type == LedgerEntryType.SELL) + return prepareBuySellToOppositeDelivery(entry, projectionRef, transaction); + + if (type == LedgerEntryType.DELIVERY_INBOUND || type == LedgerEntryType.DELIVERY_OUTBOUND) + return prepareDeliveryToOppositeBuySell(entry, projectionRef, transaction); + + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_CONVERT_052.message("Unsupported composite portfolio conversion: " + type)); //$NON-NLS-1$ + } + + private Operation prepareBuySellToOppositeDelivery(LedgerEntry entry, LedgerProjectionRef projectionRef, + TransactionPair transaction) + { + var portfolio = projectionRef.getPortfolio(); + var projectionUUID = projectionRef.getUUID(); + + preflightBuySell(entry, projectionRef, transaction, portfolio); + + var targetType = entry.getType() == LedgerEntryType.BUY ? LedgerEntryType.DELIVERY_OUTBOUND + : LedgerEntryType.DELIVERY_INBOUND; + var targetRole = role(targetType); + var roleChange = LedgerInvestmentPlanRefSupport.roleChange(projectionUUID, LedgerProjectionRole.PORTFOLIO, + targetRole); + + LedgerInvestmentPlanRefSupport.requireRefsFollowRoleChanges(client, entry, roleChange); + LedgerEntryEditSupport.validatePatch(entry, editedEntry -> applyBuySellToOppositeDelivery(editedEntry, + projectionUUID)); + + return new Operation(entry, portfolio, projectionUUID, roleChange, + editedEntry -> applyBuySellToOppositeDelivery(editedEntry, projectionUUID)); + } + + private Operation prepareDeliveryToOppositeBuySell(LedgerEntry entry, LedgerProjectionRef projectionRef, + TransactionPair transaction) + { + var portfolio = projectionRef.getPortfolio(); + var account = requireReferenceAccount(portfolio); + var projectionUUID = projectionRef.getUUID(); + var accountProjectionUUID = UUID.randomUUID().toString(); + var cashPostingUUID = UUID.randomUUID().toString(); + + preflightDelivery(entry, projectionRef, transaction, portfolio); + + var roleChange = LedgerInvestmentPlanRefSupport.roleChange(projectionUUID, projectionRef.getRole(), + LedgerProjectionRole.PORTFOLIO); + + LedgerInvestmentPlanRefSupport.requireRefsFollowRoleChanges(client, entry, roleChange); + LedgerEntryEditSupport.validatePatch(entry, + editedEntry -> applyDeliveryToOppositeBuySell(editedEntry, projectionUUID, account, + accountProjectionUUID, cashPostingUUID)); + + return new Operation(entry, portfolio, projectionUUID, roleChange, + editedEntry -> applyDeliveryToOppositeBuySell(editedEntry, projectionUUID, account, + accountProjectionUUID, cashPostingUUID)); + } + + private void preflightBuySell(LedgerEntry entry, LedgerProjectionRef projectionRef, + TransactionPair transaction, Portfolio portfolio) + { + if (entry.getType() != LedgerEntryType.BUY && entry.getType() != LedgerEntryType.SELL) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PROJ_043.message("Only ledger-backed buy/sell entries can be converted")); //$NON-NLS-1$ + + if (projectionRef.getRole() != LedgerProjectionRole.PORTFOLIO) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PROJ_044.message("Only the portfolio side of a buy/sell entry can be converted")); //$NON-NLS-1$ + + if (transaction.getOwner() != portfolio) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_045.message("Selected portfolio does not own the ledger projection")); //$NON-NLS-1$ + + var accountProjection = uniqueProjection(entry, LedgerProjectionRole.ACCOUNT); + var portfolioProjection = uniqueProjection(entry, LedgerProjectionRole.PORTFOLIO); + var cashPosting = requireOnePosting(entry, LedgerPostingType.CASH); + var securityPosting = requireOnePosting(entry, LedgerPostingType.SECURITY); + + if (portfolioProjection != projectionRef) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_046.message("Selected projection is not the unique portfolio projection")); //$NON-NLS-1$ + + if (LedgerProjectionSupport.primaryPosting(entry, accountProjection) != cashPosting + || LedgerProjectionSupport.primaryPosting(entry, portfolioProjection) != securityPosting) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_047.message("Buy/sell projection primary postings are ambiguous")); //$NON-NLS-1$ + + if (cashPosting.getAccount() != accountProjection.getAccount() + || securityPosting.getPortfolio() != portfolioProjection.getPortfolio()) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_048.message("Buy/sell projection and posting owners do not match")); //$NON-NLS-1$ + + rejectPostingForex(cashPosting); + rejectPostingForex(securityPosting); + reversedBuySellAmount(entry, entry.getType()); + } + + private void preflightDelivery(LedgerEntry entry, LedgerProjectionRef projectionRef, + TransactionPair transaction, Portfolio portfolio) + { + if (entry.getType() != LedgerEntryType.DELIVERY_INBOUND && entry.getType() != LedgerEntryType.DELIVERY_OUTBOUND) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PROJ_049.message("Only ledger-backed delivery entries can be converted")); //$NON-NLS-1$ + + var expectedRole = role(entry.getType()); + + if (projectionRef.getRole() != expectedRole) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PROJ_050.message("Only the delivery projection can be converted")); //$NON-NLS-1$ + + if (transaction.getOwner() != portfolio) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_051.message("Selected portfolio does not own the ledger projection")); //$NON-NLS-1$ + + if (entry.getPostings().stream().anyMatch(posting -> posting.getType() == LedgerPostingType.CASH)) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_053.message("Ledger delivery entry must not already have a cash posting")); //$NON-NLS-1$ + + if (entry.getProjectionRefs().stream().anyMatch(projection -> projection.getRole() == LedgerProjectionRole.ACCOUNT)) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_052.message("Ledger delivery entry must not already have an account projection")); //$NON-NLS-1$ + + var projection = uniqueProjection(entry, expectedRole); + var securityPosting = requireOnePosting(entry, LedgerPostingType.SECURITY); + + if (projection != projectionRef) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_053.message("Selected projection is not the unique delivery projection")); //$NON-NLS-1$ + + if (LedgerProjectionSupport.primaryPosting(entry, projection) != securityPosting) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_054.message("Delivery projection primary posting is ambiguous")); //$NON-NLS-1$ + + if (securityPosting.getPortfolio() != projection.getPortfolio()) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_055.message("Delivery projection and posting portfolio do not match")); //$NON-NLS-1$ + + rejectPostingForex(securityPosting); + reversedDeliveryAmount(entry, entry.getType()); + } + + private void applyBuySellToOppositeDelivery(LedgerEntry entry, String projectionUUID) + { + var targetType = entry.getType() == LedgerEntryType.BUY ? LedgerEntryType.DELIVERY_OUTBOUND + : LedgerEntryType.DELIVERY_INBOUND; + var targetRole = role(targetType); + var amount = reversedBuySellAmount(entry, entry.getType()); + var securityPosting = requireOnePosting(entry, LedgerPostingType.SECURITY); + var portfolioProjection = projection(entry, projectionUUID); + + securityPosting.setAmount(amount.getAmount()); + securityPosting.setCurrency(amount.getCurrencyCode()); + + List.copyOf(entry.getPostings()).stream() // + .filter(posting -> posting.getType() == LedgerPostingType.CASH) // + .forEach(entry::removePosting); + + List.copyOf(entry.getProjectionRefs()).stream() // + .filter(projection -> projection.getRole() == LedgerProjectionRole.ACCOUNT) // + .forEach(entry::removeProjectionRef); + + portfolioProjection.setRole(targetRole); + entry.setType(targetType); + } + + private void applyDeliveryToOppositeBuySell(LedgerEntry entry, String portfolioProjectionUUID, Account account, + String accountProjectionUUID, String cashPostingUUID) + { + var targetType = entry.getType() == LedgerEntryType.DELIVERY_INBOUND ? LedgerEntryType.SELL + : LedgerEntryType.BUY; + var amount = reversedDeliveryAmount(entry, entry.getType()); + var securityPosting = requireOnePosting(entry, LedgerPostingType.SECURITY); + var cashPosting = new LedgerPosting(cashPostingUUID); + var accountProjection = new LedgerProjectionRef(accountProjectionUUID); + var portfolioProjection = projection(entry, portfolioProjectionUUID); + var unitPostings = entry.getPostings().stream() // + .filter(posting -> posting != securityPosting) // + .toList(); + + securityPosting.setAmount(amount.getAmount()); + securityPosting.setCurrency(amount.getCurrencyCode()); + + cashPosting.setType(LedgerPostingType.CASH); + cashPosting.setAccount(account); + applyDeliveryCashPosting(securityPosting, cashPosting, account); + + accountProjection.setRole(LedgerProjectionRole.ACCOUNT); + accountProjection.setAccount(account); + accountProjection.setPrimaryPosting(cashPosting); + + portfolioProjection.setRole(LedgerProjectionRole.PORTFOLIO); + + List.copyOf(entry.getPostings()).forEach(entry::removePosting); + entry.addPosting(cashPosting); + entry.addPosting(securityPosting); + unitPostings.forEach(entry::addPosting); + + List.copyOf(entry.getProjectionRefs()).forEach(entry::removeProjectionRef); + entry.addProjectionRef(accountProjection); + entry.addProjectionRef(portfolioProjection); + + entry.setType(targetType); + } + + private Money reversedBuySellAmount(LedgerEntry entry, LedgerEntryType currentType) + { + var securityPosting = requireOnePosting(entry, LedgerPostingType.SECURITY); + var transactionCurrency = securityPosting.getCurrency(); + var grossAmount = entry.getPostings().stream() // + .filter(posting -> posting.getType() == LedgerPostingType.GROSS_VALUE) // + .findFirst() // + .map(posting -> Money.of(posting.getCurrency(), posting.getAmount())) // + .orElseGet(() -> Money.of(transactionCurrency, grossValueAmount(entry, currentType))); + var feesAndTaxes = feesAndTaxes(entry, transactionCurrency); + + return currentType == LedgerEntryType.BUY ? grossAmount.subtract(feesAndTaxes) + : grossAmount.add(feesAndTaxes); + } + + private Money reversedDeliveryAmount(LedgerEntry entry, LedgerEntryType currentType) + { + var securityPosting = requireOnePosting(entry, LedgerPostingType.SECURITY); + var transactionCurrency = securityPosting.getCurrency(); + var grossAmount = entry.getPostings().stream() // + .filter(posting -> posting.getType() == LedgerPostingType.GROSS_VALUE) // + .findFirst() // + .map(posting -> Money.of(posting.getCurrency(), posting.getAmount())) // + .orElseGet(() -> Money.of(transactionCurrency, grossValueAmount(entry, currentType))); + var feesAndTaxes = feesAndTaxes(entry, transactionCurrency); + + return currentType == LedgerEntryType.DELIVERY_INBOUND ? grossAmount.subtract(feesAndTaxes) + : grossAmount.add(feesAndTaxes); + } + + private long grossValueAmount(LedgerEntry entry, LedgerEntryType currentType) + { + var securityPosting = requireOnePosting(entry, LedgerPostingType.SECURITY); + var feesAndTaxes = feesAndTaxes(entry, securityPosting.getCurrency()).getAmount(); + + return switch (currentType) + { + case BUY, DELIVERY_INBOUND -> securityPosting.getAmount() - feesAndTaxes; + case SELL, DELIVERY_OUTBOUND -> securityPosting.getAmount() + feesAndTaxes; + default -> throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_054.message("Unsupported reversal type " + currentType)); //$NON-NLS-1$ + }; + } + + private Money feesAndTaxes(LedgerEntry entry, String currency) + { + return entry.getPostings().stream() // + .filter(posting -> posting.getType() == LedgerPostingType.FEE + || posting.getType() == LedgerPostingType.TAX) // + .map(posting -> Money.of(posting.getCurrency(), posting.getAmount())) // + .collect(MoneyCollectors.sum(currency)); + } + + private void applyDeliveryCashPosting(LedgerPosting securityPosting, LedgerPosting cashPosting, Account account) + { + var accountCurrency = account.getCurrencyCode(); + + if (Objects.equals(securityPosting.getCurrency(), accountCurrency)) + { + cashPosting.setAmount(securityPosting.getAmount()); + cashPosting.setCurrency(accountCurrency); + return; + } + + if (hasCompleteForex(securityPosting) && Objects.equals(securityPosting.getForexCurrency(), accountCurrency)) + { + if (securityPosting.getExchangeRate().signum() <= 0) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_FOREX_004 + .message("Delivery forex exchange rate is not positive")); //$NON-NLS-1$ + + cashPosting.setAmount(securityPosting.getForexAmount()); + cashPosting.setCurrency(accountCurrency); + cashPosting.setForexAmount(securityPosting.getAmount()); + cashPosting.setForexCurrency(securityPosting.getCurrency()); + cashPosting.setExchangeRate(ExchangeRate.inverse(securityPosting.getExchangeRate())); + return; + } + + cashPosting.setAmount(securityPosting.getAmount()); + cashPosting.setCurrency(accountCurrency); + cashPosting.setForexAmount(securityPosting.getAmount()); + cashPosting.setForexCurrency(securityPosting.getCurrency()); + cashPosting.setExchangeRate(DEFAULT_EXCHANGE_RATE); + } + + private boolean hasCompleteForex(LedgerPosting posting) + { + return posting.getForexAmount() != null && posting.getForexCurrency() != null + && posting.getExchangeRate() != null; + } + + private void rejectPostingForex(LedgerPosting posting) + { + if (posting.getForexAmount() != null || posting.getForexCurrency() != null || posting.getExchangeRate() != null) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_FOREX_005 + .message("Ledger posting forex metadata cannot be reversed")); //$NON-NLS-1$ + } + + private Account requireReferenceAccount(Portfolio portfolio) + { + var account = portfolio.getReferenceAccount(); + + if (account == null) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_055.message("Delivery portfolio has no reference account")); //$NON-NLS-1$ + + return account; + } + + private LedgerPosting requireOnePosting(LedgerEntry entry, LedgerPostingType type) + { + var postings = entry.getPostings().stream().filter(posting -> posting.getType() == type).toList(); + + if (postings.size() != 1) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_056.message("Ledger entry must have exactly one " + type + " posting")); //$NON-NLS-1$ //$NON-NLS-2$ + + return postings.get(0); + } + + private LedgerProjectionRef uniqueProjection(LedgerEntry entry, LedgerProjectionRole role) + { + var projections = entry.getProjectionRefs().stream().filter(projection -> projection.getRole() == role).toList(); + + if (projections.size() != 1) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_056 + .message("Expected one projection for role " + role + " but found " //$NON-NLS-1$ //$NON-NLS-2$ + + projections.size())); + + return projections.get(0); + } + + private LedgerProjectionRef projection(LedgerEntry entry, String projectionUUID) + { + return entry.getProjectionRefs().stream() // + .filter(projection -> projectionUUID.equals(projection.getUUID())) // + .findFirst() + .orElseThrow(() -> new IllegalArgumentException( + "Ledger portfolio projection not found: " + projectionUUID)); //$NON-NLS-1$ + } + + private LedgerProjectionRole role(LedgerEntryType entryType) + { + return entryType == LedgerEntryType.DELIVERY_INBOUND ? LedgerProjectionRole.DELIVERY_INBOUND + : LedgerProjectionRole.DELIVERY_OUTBOUND; + } + + private PortfolioTransaction find(Portfolio portfolio, String projectionUUID) + { + return portfolio.getTransactions().stream() // + .filter(LedgerBackedTransaction.class::isInstance) // + .filter(transaction -> projectionUUID.equals(transaction.getUUID())) // + .findFirst() + .orElseThrow(() -> new IllegalStateException( + "Composite ledger portfolio projection was not materialized: " //$NON-NLS-1$ + + projectionUUID)); + } + + private record Operation(LedgerEntry entry, Portfolio portfolio, String portfolioProjectionUUID, + LedgerInvestmentPlanRefSupport.RoleChange roleChange, LedgerEntryEditSupport.EntryPatch mutation) + { + void apply(LedgerEntry entry) + { + mutation.apply(entry); + } + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPostingPatch.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPostingPatch.java new file mode 100644 index 0000000000..b64d201c15 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPostingPatch.java @@ -0,0 +1,148 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.math.BigDecimal; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.ledger.LedgerFieldEdit; +import name.abuchen.portfolio.model.ledger.LedgerPosting; + +/** + * Carries posting data for Ledger compatibility creators, editors, or patchers. + * This is a compatibility-layer value object. Contributor code may pass it to Ledger write + * paths, but it does not mutate Ledger truth by itself. + */ +final class LedgerPostingPatch +{ + private final LedgerFieldEdit amount; + private final LedgerFieldEdit currency; + private final LedgerFieldEdit forexAmount; + private final LedgerFieldEdit forexCurrency; + private final LedgerFieldEdit exchangeRate; + private final LedgerFieldEdit security; + private final LedgerFieldEdit shares; + private final LedgerFieldEdit account; + private final LedgerFieldEdit portfolio; + + private LedgerPostingPatch(Builder builder) + { + this.amount = builder.amount; + this.currency = builder.currency; + this.forexAmount = builder.forexAmount; + this.forexCurrency = builder.forexCurrency; + this.exchangeRate = builder.exchangeRate; + this.security = builder.security; + this.shares = builder.shares; + this.account = builder.account; + this.portfolio = builder.portfolio; + } + + static LedgerPostingPatch none() + { + return builder().build(); + } + + static Builder builder() + { + return new Builder(); + } + + void applyTo(LedgerPosting posting) + { + if (!amount.isOmitted()) + posting.setAmount(amount.isClear() ? 0L : amount.getValue()); + if (!currency.isOmitted()) + posting.setCurrency(currency.isClear() ? null : currency.getValue()); + if (!forexAmount.isOmitted()) + posting.setForexAmount(forexAmount.isClear() ? null : forexAmount.getValue()); + if (!forexCurrency.isOmitted()) + posting.setForexCurrency(forexCurrency.isClear() ? null : forexCurrency.getValue()); + if (!exchangeRate.isOmitted()) + posting.setExchangeRate(exchangeRate.isClear() ? null : exchangeRate.getValue()); + if (!security.isOmitted()) + posting.setSecurity(security.isClear() ? null : security.getValue()); + if (!shares.isOmitted()) + posting.setShares(shares.isClear() ? 0L : shares.getValue()); + if (!account.isOmitted()) + posting.setAccount(account.isClear() ? null : account.getValue()); + if (!portfolio.isOmitted()) + posting.setPortfolio(portfolio.isClear() ? null : portfolio.getValue()); + } + + static final class Builder + { + private LedgerFieldEdit amount = LedgerFieldEdit.omitted(); + private LedgerFieldEdit currency = LedgerFieldEdit.omitted(); + private LedgerFieldEdit forexAmount = LedgerFieldEdit.omitted(); + private LedgerFieldEdit forexCurrency = LedgerFieldEdit.omitted(); + private LedgerFieldEdit exchangeRate = LedgerFieldEdit.omitted(); + private LedgerFieldEdit security = LedgerFieldEdit.omitted(); + private LedgerFieldEdit shares = LedgerFieldEdit.omitted(); + private LedgerFieldEdit account = LedgerFieldEdit.omitted(); + private LedgerFieldEdit portfolio = LedgerFieldEdit.omitted(); + + private Builder() + { + } + + Builder amount(long amount) + { + this.amount = LedgerFieldEdit.set(amount); + return this; + } + + Builder currency(String currency) + { + this.currency = currency != null ? LedgerFieldEdit.set(currency) : LedgerFieldEdit.clear(); + return this; + } + + Builder forexAmount(Long forexAmount) + { + this.forexAmount = forexAmount != null ? LedgerFieldEdit.set(forexAmount) : LedgerFieldEdit.clear(); + return this; + } + + Builder forexCurrency(String forexCurrency) + { + this.forexCurrency = forexCurrency != null ? LedgerFieldEdit.set(forexCurrency) : LedgerFieldEdit.clear(); + return this; + } + + Builder exchangeRate(BigDecimal exchangeRate) + { + this.exchangeRate = exchangeRate != null ? LedgerFieldEdit.set(exchangeRate) : LedgerFieldEdit.clear(); + return this; + } + + Builder security(Security security) + { + this.security = security != null ? LedgerFieldEdit.set(security) : LedgerFieldEdit.clear(); + return this; + } + + Builder shares(long shares) + { + this.shares = LedgerFieldEdit.set(shares); + return this; + } + + Builder account(Account account) + { + this.account = account != null ? LedgerFieldEdit.set(account) : LedgerFieldEdit.clear(); + return this; + } + + Builder portfolio(Portfolio portfolio) + { + this.portfolio = portfolio != null ? LedgerFieldEdit.set(portfolio) : LedgerFieldEdit.clear(); + return this; + } + + LedgerPostingPatch build() + { + return new LedgerPostingPatch(this); + } + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerShareAdjustmentHelper.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerShareAdjustmentHelper.java new file mode 100644 index 0000000000..36164a6394 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerShareAdjustmentHelper.java @@ -0,0 +1,190 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.function.LongUnaryOperator; +import java.util.stream.Stream; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.Transaction; +import name.abuchen.portfolio.model.ledger.Ledger; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerModelCopy; +import name.abuchen.portfolio.model.ledger.LedgerMutationContext; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; + +/** + * Applies share adjustments to ledger-backed security postings. + * This class is compatibility mutation support for stock split and similar write paths. + * It updates Ledger truth instead of projected legacy rows. + */ +public final class LedgerShareAdjustmentHelper +{ + private LedgerShareAdjustmentHelper() + { + } + + public static Plan plan(Client client, Security security, List transactions, + LongUnaryOperator shareAdjustment) + { + Objects.requireNonNull(client); + Objects.requireNonNull(security); + Objects.requireNonNull(transactions); + Objects.requireNonNull(shareAdjustment); + + var adjustments = new LinkedHashMap(); + + for (var transaction : transactions) + { + if (!(transaction instanceof LedgerBackedTransaction ledgerBackedTransaction)) + continue; + + var posting = primaryPosting(ledgerBackedTransaction); + + if (posting.getSecurity() != security) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_060.message("Selected Ledger posting does not match stock split security")); //$NON-NLS-1$ + + adjustments.computeIfAbsent(posting.getUUID(), uuid -> new Adjustment(ledgerBackedTransaction.getLedgerEntry(), + posting, shareAdjustment.applyAsLong(posting.getShares()))); + } + + var plan = new Plan(client, List.copyOf(adjustments.values())); + plan.validate(); + return plan; + } + + public static Plan emptyPlan() + { + return new Plan(null, List.of()); + } + + private static LedgerPosting primaryPosting(LedgerBackedTransaction transaction) + { + var entry = transaction.getLedgerEntry(); + var projectionRef = transaction.getLedgerProjectionRef(); + + if (projectionRef.getPrimaryPostingUUID() != null) + return requirePostingInEntry(entry, projectionRef.getPrimaryPostingUUID()); + + if (entry.getType().requiresTargetedProjectionRefs()) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_058 + .message("Targeted Ledger projection has no primary posting: " + projectionRef.getUUID())); //$NON-NLS-1$ + + Optional posting = switch (projectionRef.getRole()) + { + case SOURCE_ACCOUNT -> accountPostings(entry, projectionRef).findFirst(); + case TARGET_ACCOUNT -> last(accountPostings(entry, projectionRef)); + case SOURCE_PORTFOLIO -> portfolioPostings(entry, projectionRef).findFirst(); + case TARGET_PORTFOLIO -> last(portfolioPostings(entry, projectionRef)); + case ACCOUNT, CASH_COMPENSATION -> accountPostings(entry, projectionRef).findFirst(); + case PORTFOLIO, DELIVERY, DELIVERY_INBOUND, DELIVERY_OUTBOUND, OLD_SECURITY_LEG, NEW_SECURITY_LEG -> + portfolioPostings(entry, projectionRef).findFirst(); + }; + + return posting.orElseThrow(() -> new IllegalArgumentException( + "No primary Ledger posting for projection " + projectionRef.getUUID())); //$NON-NLS-1$ + } + + private static Stream accountPostings(LedgerEntry entry, LedgerProjectionRef projectionRef) + { + return entry.getPostings().stream().filter(posting -> posting.getAccount() == projectionRef.getAccount()); + } + + private static Stream portfolioPostings(LedgerEntry entry, LedgerProjectionRef projectionRef) + { + return entry.getPostings().stream().filter(posting -> posting.getPortfolio() == projectionRef.getPortfolio()); + } + + private static Optional last(Stream stream) + { + var postings = stream.toList(); + return postings.isEmpty() ? Optional.empty() : Optional.of(postings.get(postings.size() - 1)); + } + + private static LedgerEntry requireEntryInLedger(Ledger ledger, String uuid) + { + return ledger.getEntries().stream() // + .filter(entry -> entry.getUUID().equals(uuid)) // + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Ledger entry not found: " + uuid)); //$NON-NLS-1$ + } + + private static LedgerPosting requirePostingInEntry(LedgerEntry entry, String uuid) + { + return entry.getPostings().stream() // + .filter(posting -> posting.getUUID().equals(uuid)) // + .findFirst() + .orElseThrow(() -> new IllegalArgumentException( + "Ledger posting not found in entry " + entry.getUUID() + ": " + uuid)); //$NON-NLS-1$ //$NON-NLS-2$ + } + + private record Adjustment(LedgerEntry entry, LedgerPosting posting, long newShares) + { + } + + public static final class Plan + { + private final Client client; + private final List adjustments; + + private Plan(Client client, List adjustments) + { + this.client = client; + this.adjustments = adjustments; + } + + public boolean isLedgerBacked(Transaction transaction) + { + return transaction instanceof LedgerBackedTransaction; + } + + public void apply() + { + if (adjustments.isEmpty()) + return; + + var affectedEntryUUIDs = adjustments.stream() // + .map(adjustment -> adjustment.entry().getUUID()) // + .distinct() // + .toList(); + + new LedgerMutationContext(client).mutateEntries(affectedEntryUUIDs, ledger -> { + for (var adjustment : adjustments) + { + var entry = requireEntryInLedger(ledger, adjustment.entry().getUUID()); + + requirePostingInEntry(entry, adjustment.posting().getUUID()).setShares(adjustment.newShares()); + entry.setUpdatedAt(Instant.now()); + } + }); + } + + private void validate() + { + if (adjustments.isEmpty()) + return; + + var candidate = LedgerModelCopy.copyLedger(client.getLedger()); + + for (var adjustment : adjustments) + { + var entry = requireEntryInLedger(candidate, adjustment.entry().getUUID()); + + requirePostingInEntry(entry, adjustment.posting().getUUID()).setShares(adjustment.newShares()); + } + + var result = LedgerStructuralValidator.validate(candidate); + + if (!result.isOK()) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_061.message("Invalid Ledger share adjustment: " + result.format())); //$NON-NLS-1$ + } + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerTransactionDeleter.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerTransactionDeleter.java new file mode 100644 index 0000000000..258b2f2fce --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerTransactionDeleter.java @@ -0,0 +1,48 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.util.Objects; + +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerMutationContext; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; + +/** + * Deletes ledger-backed transactions through their persisted Ledger entry. + * This class is part of the Ledger compatibility layer. Contributor code should use it + * instead of removing only a runtime projection from an owner list. + */ +public final class LedgerTransactionDeleter +{ + private final Client client; + private final LedgerMutationContext mutationContext; + + public LedgerTransactionDeleter(Client client) + { + this.client = Objects.requireNonNull(client); + this.mutationContext = new LedgerMutationContext(client); + } + + LedgerTransactionDeleter(LedgerMutationContext mutationContext) + { + this.client = null; + this.mutationContext = Objects.requireNonNull(mutationContext); + } + + public void delete(LedgerBackedTransaction transaction) + { + Objects.requireNonNull(transaction); + + delete(transaction.getLedgerEntry()); + } + + public void delete(LedgerEntry entry) + { + Objects.requireNonNull(entry); + + mutationContext.removeEntry(entry); + + if (client != null) + client.getPlans().forEach(plan -> plan.removeLedgerExecutionRefs(entry)); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerTransferDirectionConverter.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerTransferDirectionConverter.java new file mode 100644 index 0000000000..660bcbb849 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerTransferDirectionConverter.java @@ -0,0 +1,249 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.math.BigDecimal; +import java.util.Objects; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.AccountTransferEntry; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.PortfolioTransaction; +import name.abuchen.portfolio.model.PortfolioTransferEntry; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerMutationContext; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedAccountTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedPortfolioTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionSupport; +import name.abuchen.portfolio.money.ExchangeRate; + +/** + * Reverses ledger-backed account and portfolio transfer directions. + * This class is part of the Ledger compatibility layer for existing UI and action code. It + * keeps both transfer sides derived from one consistent Ledger entry. + */ +public final class LedgerTransferDirectionConverter +{ + private final Client client; + + public LedgerTransferDirectionConverter(Client client) + { + this.client = Objects.requireNonNull(client); + } + + public AccountTransferEntry reverse(AccountTransferEntry transfer) + { + Objects.requireNonNull(transfer); + + if (!(transfer.getSourceTransaction() instanceof LedgerBackedAccountTransaction sourceTransaction) + || !(transfer.getTargetTransaction() instanceof LedgerBackedAccountTransaction targetTransaction)) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_CONVERT_063.message("Only ledger-backed account transfers can be reversed")); //$NON-NLS-1$ + + var entry = sourceTransaction.getLedgerEntry(); + var sourceProjectionUUID = sourceTransaction.getLedgerProjectionRef().getUUID(); + var targetProjectionUUID = targetTransaction.getLedgerProjectionRef().getUUID(); + var sourceAccount = sourceTransaction.getLedgerProjectionRef().getAccount(); + var targetAccount = targetTransaction.getLedgerProjectionRef().getAccount(); + + preflightAccountTransfer(entry, sourceTransaction, targetTransaction); + var sourceRoleChange = LedgerInvestmentPlanRefSupport.roleChange(sourceProjectionUUID, + LedgerProjectionRole.SOURCE_ACCOUNT, LedgerProjectionRole.TARGET_ACCOUNT); + var targetRoleChange = LedgerInvestmentPlanRefSupport.roleChange(targetProjectionUUID, + LedgerProjectionRole.TARGET_ACCOUNT, LedgerProjectionRole.SOURCE_ACCOUNT); + LedgerInvestmentPlanRefSupport.requireRefsFollowRoleChanges(client, entry, sourceRoleChange, targetRoleChange); + + new LedgerMutationContext(client).mutateEntry(entry, this::reverseAccountTransfer); + LedgerInvestmentPlanRefSupport.updateProjectionRoles(client, entry, sourceRoleChange, targetRoleChange); + + return AccountTransferEntry.readOnly(targetAccount, find(targetAccount, targetProjectionUUID), sourceAccount, + find(sourceAccount, sourceProjectionUUID)); + } + + public PortfolioTransferEntry reverse(PortfolioTransferEntry transfer) + { + Objects.requireNonNull(transfer); + + if (!(transfer.getSourceTransaction() instanceof LedgerBackedPortfolioTransaction sourceTransaction) + || !(transfer.getTargetTransaction() instanceof LedgerBackedPortfolioTransaction targetTransaction)) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_CONVERT_064.message("Only ledger-backed portfolio transfers can be reversed")); //$NON-NLS-1$ + + var entry = sourceTransaction.getLedgerEntry(); + var sourceProjectionUUID = sourceTransaction.getLedgerProjectionRef().getUUID(); + var targetProjectionUUID = targetTransaction.getLedgerProjectionRef().getUUID(); + var sourcePortfolio = sourceTransaction.getLedgerProjectionRef().getPortfolio(); + var targetPortfolio = targetTransaction.getLedgerProjectionRef().getPortfolio(); + + preflightPortfolioTransfer(entry, sourceTransaction, targetTransaction); + var sourceRoleChange = LedgerInvestmentPlanRefSupport.roleChange(sourceProjectionUUID, + LedgerProjectionRole.SOURCE_PORTFOLIO, LedgerProjectionRole.TARGET_PORTFOLIO); + var targetRoleChange = LedgerInvestmentPlanRefSupport.roleChange(targetProjectionUUID, + LedgerProjectionRole.TARGET_PORTFOLIO, LedgerProjectionRole.SOURCE_PORTFOLIO); + LedgerInvestmentPlanRefSupport.requireRefsFollowRoleChanges(client, entry, sourceRoleChange, targetRoleChange); + + new LedgerMutationContext(client).mutateEntry(entry, this::reversePortfolioTransfer); + LedgerInvestmentPlanRefSupport.updateProjectionRoles(client, entry, sourceRoleChange, targetRoleChange); + + return PortfolioTransferEntry.readOnly(targetPortfolio, find(targetPortfolio, targetProjectionUUID), + sourcePortfolio, find(sourcePortfolio, sourceProjectionUUID)); + } + + private void preflightAccountTransfer(LedgerEntry entry, LedgerBackedAccountTransaction sourceTransaction, + LedgerBackedAccountTransaction targetTransaction) + { + if (entry.getType() != LedgerEntryType.CASH_TRANSFER) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_CONVERT_065.message("Only ledger-backed account transfer entries can be reversed")); //$NON-NLS-1$ + + if (sourceTransaction.getLedgerEntry() != targetTransaction.getLedgerEntry()) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_059.message("Transfer projections do not belong to the same ledger entry")); //$NON-NLS-1$ + + if (sourceTransaction.getLedgerProjectionRef().getRole() != LedgerProjectionRole.SOURCE_ACCOUNT + || targetTransaction.getLedgerProjectionRef().getRole() != LedgerProjectionRole.TARGET_ACCOUNT) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_060.message("Account transfer projections are not in source/target order")); //$NON-NLS-1$ + + var sourceProjection = uniqueProjection(entry, LedgerProjectionRole.SOURCE_ACCOUNT); + var targetProjection = uniqueProjection(entry, LedgerProjectionRole.TARGET_ACCOUNT); + var sourcePosting = LedgerProjectionSupport.primaryPosting(entry, sourceProjection); + var targetPosting = LedgerProjectionSupport.primaryPosting(entry, targetProjection); + + if (sourcePosting == targetPosting) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_066.message("Account transfer source and target postings are ambiguous")); //$NON-NLS-1$ + + if (sourcePosting.getAccount() != sourceProjection.getAccount() + || targetPosting.getAccount() != targetProjection.getAccount()) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_061.message("Account transfer projection and posting owners do not match")); //$NON-NLS-1$ + } + + private void preflightPortfolioTransfer(LedgerEntry entry, LedgerBackedPortfolioTransaction sourceTransaction, + LedgerBackedPortfolioTransaction targetTransaction) + { + if (entry.getType() != LedgerEntryType.SECURITY_TRANSFER) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_CONVERT_067.message("Only ledger-backed portfolio transfer entries can be reversed")); //$NON-NLS-1$ + + if (sourceTransaction.getLedgerEntry() != targetTransaction.getLedgerEntry()) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_062.message("Transfer projections do not belong to the same ledger entry")); //$NON-NLS-1$ + + if (sourceTransaction.getLedgerProjectionRef().getRole() != LedgerProjectionRole.SOURCE_PORTFOLIO + || targetTransaction.getLedgerProjectionRef().getRole() != LedgerProjectionRole.TARGET_PORTFOLIO) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_063.message("Portfolio transfer projections are not in source/target order")); //$NON-NLS-1$ + + var sourceProjection = uniqueProjection(entry, LedgerProjectionRole.SOURCE_PORTFOLIO); + var targetProjection = uniqueProjection(entry, LedgerProjectionRole.TARGET_PORTFOLIO); + var sourcePosting = LedgerProjectionSupport.primaryPosting(entry, sourceProjection); + var targetPosting = LedgerProjectionSupport.primaryPosting(entry, targetProjection); + + if (sourcePosting == targetPosting) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_068.message("Portfolio transfer source and target postings are ambiguous")); //$NON-NLS-1$ + + if (sourcePosting.getPortfolio() != sourceProjection.getPortfolio() + || targetPosting.getPortfolio() != targetProjection.getPortfolio()) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_064.message("Portfolio transfer projection and posting owners do not match")); //$NON-NLS-1$ + } + + private void reverseAccountTransfer(LedgerEntry entry) + { + var sourceProjection = uniqueProjection(entry, LedgerProjectionRole.SOURCE_ACCOUNT); + var targetProjection = uniqueProjection(entry, LedgerProjectionRole.TARGET_ACCOUNT); + var sourcePosting = LedgerProjectionSupport.primaryPosting(entry, sourceProjection); + var targetPosting = LedgerProjectionSupport.primaryPosting(entry, targetProjection); + + reverseAccountTransferForex(sourcePosting, targetPosting); + + sourceProjection.setRole(LedgerProjectionRole.TARGET_ACCOUNT); + targetProjection.setRole(LedgerProjectionRole.SOURCE_ACCOUNT); + } + + private void reverseAccountTransferForex(LedgerPosting oldSourcePosting, LedgerPosting oldTargetPosting) + { + if (oldSourcePosting.getCurrency() == null || oldTargetPosting.getCurrency() == null + || oldSourcePosting.getCurrency().equals(oldTargetPosting.getCurrency())) + return; + + if (hasForexFor(oldTargetPosting, oldSourcePosting)) + { + clearForex(oldSourcePosting); + return; + } + + if (!hasForexFor(oldSourcePosting, oldTargetPosting)) + return; + + var reversedExchangeRate = inverse(oldSourcePosting.getExchangeRate()); + + oldTargetPosting.setForexAmount(oldSourcePosting.getAmount()); + oldTargetPosting.setForexCurrency(oldSourcePosting.getCurrency()); + oldTargetPosting.setExchangeRate(reversedExchangeRate); + clearForex(oldSourcePosting); + } + + private boolean hasForexFor(LedgerPosting posting, LedgerPosting oppositePosting) + { + return posting.getForexAmount() != null && posting.getForexCurrency() != null + && posting.getExchangeRate() != null + && posting.getForexCurrency().equals(oppositePosting.getCurrency()); + } + + private BigDecimal inverse(BigDecimal exchangeRate) + { + if (exchangeRate.signum() <= 0) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_FOREX_001 + .message("Account transfer exchange rate must be positive")); //$NON-NLS-1$ + + return ExchangeRate.inverse(exchangeRate); + } + + private void clearForex(LedgerPosting posting) + { + posting.setForexAmount(null); + posting.setForexCurrency(null); + posting.setExchangeRate(null); + } + + private void reversePortfolioTransfer(LedgerEntry entry) + { + var sourceProjection = uniqueProjection(entry, LedgerProjectionRole.SOURCE_PORTFOLIO); + var targetProjection = uniqueProjection(entry, LedgerProjectionRole.TARGET_PORTFOLIO); + + sourceProjection.setRole(LedgerProjectionRole.TARGET_PORTFOLIO); + targetProjection.setRole(LedgerProjectionRole.SOURCE_PORTFOLIO); + } + + private LedgerProjectionRef uniqueProjection(LedgerEntry entry, LedgerProjectionRole role) + { + var projections = entry.getProjectionRefs().stream().filter(projection -> projection.getRole() == role).toList(); + + if (projections.size() != 1) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_065 + .message("Expected one projection for role " + role + " but found " //$NON-NLS-1$ //$NON-NLS-2$ + + projections.size())); + + return projections.get(0); + } + + private AccountTransaction find(Account account, String projectionUUID) + { + return account.getTransactions().stream() // + .filter(LedgerBackedTransaction.class::isInstance) // + .filter(transaction -> projectionUUID.equals(transaction.getUUID())) // + .findFirst() + .orElseThrow(() -> new IllegalStateException( + "Ledger account transfer projection was not materialized: " //$NON-NLS-1$ + + projectionUUID)); + } + + private PortfolioTransaction find(Portfolio portfolio, String projectionUUID) + { + return portfolio.getTransactions().stream() // + .filter(LedgerBackedTransaction.class::isInstance) // + .filter(transaction -> projectionUUID.equals(transaction.getUUID())) // + .findFirst() + .orElseThrow(() -> new IllegalStateException( + "Ledger portfolio transfer projection was not materialized: " //$NON-NLS-1$ + + projectionUUID)); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerUnitPostingEdit.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerUnitPostingEdit.java new file mode 100644 index 0000000000..a2e6aa5e8e --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerUnitPostingEdit.java @@ -0,0 +1,133 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.util.Objects; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.money.Money; + +/** + * Carries unit posting data for Ledger compatibility creators, editors, or patchers. + * This is a compatibility-layer value object. Contributor code may pass it to Ledger write + * paths, but it does not mutate Ledger truth by itself. + */ +public final class LedgerUnitPostingEdit +{ + enum Operation + { + ADD, + UPDATE, + REMOVE + } + + private final Operation operation; + private final String postingUUID; + private final LedgerPostingType postingType; + private final LedgerPostingPatch postingPatch; + + private LedgerUnitPostingEdit(Operation operation, String postingUUID, LedgerPostingType postingType, + LedgerPostingPatch postingPatch) + { + this.operation = operation; + this.postingUUID = postingUUID; + this.postingType = postingType; + this.postingPatch = postingPatch; + } + + public static LedgerUnitPostingEdit add(LedgerPostingType postingType, Money amount) + { + return add(postingType, amount, LedgerForexAmount.none()); + } + + public static LedgerUnitPostingEdit add(LedgerPostingType postingType, Money amount, LedgerForexAmount forex) + { + requireUnitType(postingType); + Objects.requireNonNull(amount); + Objects.requireNonNull(forex); + + var builder = LedgerPostingPatch.builder().amount(amount.getAmount()).currency(amount.getCurrencyCode()); + + if (forex.isPresent()) + { + builder.forexAmount(forex.getForexAmount().getAmount()); + builder.forexCurrency(forex.getForexAmount().getCurrencyCode()); + builder.exchangeRate(forex.getExchangeRate()); + } + + return new LedgerUnitPostingEdit(Operation.ADD, null, postingType, builder.build()); + } + + public static LedgerUnitPostingEdit update(String postingUUID, Money amount) + { + return update(postingUUID, amount, LedgerForexAmount.none()); + } + + public static LedgerUnitPostingEdit update(String postingUUID, Money amount, LedgerForexAmount forex) + { + Objects.requireNonNull(amount); + Objects.requireNonNull(forex); + + var builder = LedgerPostingPatch.builder().amount(amount.getAmount()).currency(amount.getCurrencyCode()); + + if (forex.isPresent()) + { + builder.forexAmount(forex.getForexAmount().getAmount()); + builder.forexCurrency(forex.getForexAmount().getCurrencyCode()); + builder.exchangeRate(forex.getExchangeRate()); + } + + return update(postingUUID, builder.build()); + } + + public static LedgerUnitPostingEdit clearForex(String postingUUID) + { + var builder = LedgerPostingPatch.builder().forexAmount(null).forexCurrency(null).exchangeRate(null); + + return update(postingUUID, builder.build()); + } + + static LedgerUnitPostingEdit update(String postingUUID, LedgerPostingPatch postingPatch) + { + return new LedgerUnitPostingEdit(Operation.UPDATE, Objects.requireNonNull(postingUUID), null, + Objects.requireNonNull(postingPatch)); + } + + public static LedgerUnitPostingEdit remove(String postingUUID) + { + return new LedgerUnitPostingEdit(Operation.REMOVE, Objects.requireNonNull(postingUUID), null, + LedgerPostingPatch.none()); + } + + Operation getOperation() + { + return operation; + } + + String getPostingUUID() + { + return postingUUID; + } + + LedgerPostingType getPostingType() + { + return postingType; + } + + LedgerPostingPatch getPostingPatch() + { + return postingPatch; + } + + static void requireUnitType(LedgerPostingType postingType) + { + switch (postingType) + { + case FEE: + case TAX: + case GROSS_VALUE: + return; + default: + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_069.message("Unsupported unit posting type: " + postingType)); //$NON-NLS-1$ + } + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerUnitPostingPatch.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerUnitPostingPatch.java new file mode 100644 index 0000000000..733332d8e3 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerUnitPostingPatch.java @@ -0,0 +1,45 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.util.List; +import java.util.Objects; + +/** + * Carries unit posting data for Ledger compatibility creators, editors, or patchers. + * This is a compatibility-layer value object. Contributor code may pass it to Ledger write + * paths, but it does not mutate Ledger truth by itself. + */ +public final class LedgerUnitPostingPatch +{ + private static final LedgerUnitPostingPatch NONE = new LedgerUnitPostingPatch(List.of()); + + private final List edits; + + private LedgerUnitPostingPatch(List edits) + { + this.edits = List.copyOf(edits); + } + + public static LedgerUnitPostingPatch none() + { + return NONE; + } + + public static LedgerUnitPostingPatch of(LedgerUnitPostingEdit first, LedgerUnitPostingEdit... rest) + { + Objects.requireNonNull(first); + Objects.requireNonNull(rest); + + var edits = new java.util.ArrayList(); + + edits.add(first); + for (var edit : rest) + edits.add(Objects.requireNonNull(edit)); + + return new LedgerUnitPostingPatch(edits); + } + + public List getEdits() + { + return edits; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerUnitPostingUpdater.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerUnitPostingUpdater.java new file mode 100644 index 0000000000..b8c39e2cdc --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerUnitPostingUpdater.java @@ -0,0 +1,91 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.util.Objects; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerEntryEditSupport; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.ProjectionMembershipRole; + +/** + * Updates fee, tax, and forex unit postings on ledger-backed transactions. + * This class is compatibility mutation support. Contributor code should use it instead of + * changing unit projections or posting facts directly. + */ +public final class LedgerUnitPostingUpdater +{ + public void apply(LedgerEntry entry, LedgerUnitPostingPatch patch) + { + Objects.requireNonNull(entry); + Objects.requireNonNull(patch); + + LedgerEntryEditSupport.applyValidated(entry, editedEntry -> applyDirect(editedEntry, patch)); + } + + private void applyDirect(LedgerEntry entry, LedgerUnitPostingPatch patch) + { + for (var edit : patch.getEdits()) + { + switch (edit.getOperation()) + { + case ADD -> add(entry, edit); + case UPDATE -> update(entry, edit); + case REMOVE -> remove(entry, edit); + default -> throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_070.message("Unsupported unit posting edit " + edit.getOperation())); //$NON-NLS-1$ + } + } + } + + private void add(LedgerEntry entry, LedgerUnitPostingEdit edit) + { + LedgerUnitPostingEdit.requireUnitType(edit.getPostingType()); + + var posting = new LedgerPosting(); + + posting.setType(edit.getPostingType()); + edit.getPostingPatch().applyTo(posting); + entry.addPosting(posting); + addUnitMemberships(entry, posting); + } + + private void update(LedgerEntry entry, LedgerUnitPostingEdit edit) + { + var posting = LedgerEntryEditSupport.postingByUUID(entry, edit.getPostingUUID()); + + LedgerUnitPostingEdit.requireUnitType(posting.getType()); + edit.getPostingPatch().applyTo(posting); + } + + private void remove(LedgerEntry entry, LedgerUnitPostingEdit edit) + { + var posting = LedgerEntryEditSupport.postingByUUID(entry, edit.getPostingUUID()); + + LedgerUnitPostingEdit.requireUnitType(posting.getType()); + entry.removePosting(posting); + removeUnitMemberships(entry, posting); + } + + private void addUnitMemberships(LedgerEntry entry, LedgerPosting posting) + { + var role = unitMembershipRole(posting); + + entry.getProjectionRefs().forEach(projection -> projection.addMembership(posting.getUUID(), role)); + } + + private void removeUnitMemberships(LedgerEntry entry, LedgerPosting posting) + { + entry.getProjectionRefs().forEach(projection -> projection.removeMembershipsForPostingUUID(posting.getUUID())); + } + + private ProjectionMembershipRole unitMembershipRole(LedgerPosting posting) + { + return switch (posting.getType()) + { + case FEE -> ProjectionMembershipRole.FEE_UNIT; + case TAX -> ProjectionMembershipRole.TAX_UNIT; + case GROSS_VALUE -> ProjectionMembershipRole.GROSS_VALUE_UNIT; + default -> throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_071.message("Unsupported unit posting type: " + posting.getType())); //$NON-NLS-1$ + }; + } +} From a836403ad47a7dec0a81c3265acb47d9c36480eb Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Fri, 26 Jun 2026 14:30:19 +0200 Subject: [PATCH 08/68] Wire Ledger into transaction dialogs and viewers Wire Ledger-aware behavior into transaction dialogs, viewers, inline editing, actions, and UI tests. This groups user-facing transaction table and dialog integration for review. Importer guardrails, reporting bridge, diagnostic NLS, and documentation are intentionally left to later commits. --- .../ledger/LedgerInlineEditingPolicyTest.java | 39 + ...dgerNativeComponentInspectorModelTest.java | 323 +++ .../AccountTransactionModelTest.java | 202 ++ .../AccountTransferModelTest.java | 202 ++ .../transactions/BuySellModelTest.java | 154 ++ ...gerTransactionDuplicateCopyParityTest.java | 545 +++++ .../SecurityDeliveryModelTest.java | 196 ++ .../SecurityTransferModelTest.java | 184 ++ .../viewers/ExDateEditingSupportTest.java | 12 + .../views/LedgerTransactionRoutingTest.java | 1873 +++++++++++++++++ .../ui/views/TransactionContextMenuTest.java | 424 ++++ .../AccountTransferLegacyActionGuardTest.java | 990 +++++++++ .../AbstractSecurityTransactionModel.java | 17 +- .../AccountTransactionDialog.java | 4 +- .../transactions/AccountTransactionModel.java | 90 +- .../transactions/AccountTransferModel.java | 29 +- .../ui/dialogs/transactions/BuySellModel.java | 87 +- .../LedgerNativeComponentInspectorDialog.java | 223 ++ .../transactions/SecurityDeliveryModel.java | 18 + .../transactions/SecurityTransferModel.java | 25 +- .../preferences/PreferencesInitializer.java | 4 +- .../ui/util/viewers/ColumnEditingSupport.java | 16 +- .../util/viewers/DateTimeEditingSupport.java | 6 + .../ui/util/viewers/ExDateEditingSupport.java | 100 +- .../util/viewers/PropertyEditingSupport.java | 36 +- .../ui/util/viewers/StringEditingSupport.java | 2 + .../TransactionOwnerListEditingSupport.java | 159 +- .../TransactionTypeEditingSupport.java | 228 +- .../ui/util/viewers/ValueEditingSupport.java | 2 + .../ui/views/AllTransactionsView.java | 52 +- .../ui/views/InvestmentPlanListView.java | 11 +- .../ui/views/TransactionContextMenu.java | 125 +- .../ui/views/TransactionsViewer.java | 138 +- .../ConvertBuySellToDeliveryAction.java | 9 + .../ConvertDeliveryToBuySellAction.java | 9 + .../ConvertPortfolioCompositeTypeAction.java | 58 + ...ConvertTransferToDepositRemovalAction.java | 36 + .../LedgerNativeComponentInspectorAction.java | 39 + .../ui/views/actions/RevertBuySellAction.java | 11 + .../views/actions/RevertDeliveryAction.java | 10 + .../actions/RevertDepositRemovalAction.java | 10 + .../ui/views/actions/RevertFeeTaxAction.java | 56 + .../views/actions/RevertInterestAction.java | 10 + .../views/actions/RevertTransferAction.java | 18 + .../views/panes/AccountTransactionsPane.java | 97 +- .../LedgerInlineEditingField.java | 15 + .../LedgerInlineEditingPolicy.java | 111 + .../LedgerNativeComponentInspectorModel.java | 311 +++ 48 files changed, 7227 insertions(+), 89 deletions(-) create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerInlineEditingPolicyTest.java create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerNativeComponentInspectorModelTest.java create mode 100644 name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/AccountTransactionModelTest.java create mode 100644 name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/AccountTransferModelTest.java create mode 100644 name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/LedgerTransactionDuplicateCopyParityTest.java create mode 100644 name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/SecurityDeliveryModelTest.java create mode 100644 name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/SecurityTransferModelTest.java create mode 100644 name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/views/LedgerTransactionRoutingTest.java create mode 100644 name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/views/TransactionContextMenuTest.java create mode 100644 name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/views/actions/AccountTransferLegacyActionGuardTest.java create mode 100644 name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/LedgerNativeComponentInspectorDialog.java create mode 100644 name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/ConvertPortfolioCompositeTypeAction.java create mode 100644 name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/LedgerNativeComponentInspectorAction.java create mode 100644 name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/RevertFeeTaxAction.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerInlineEditingField.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerInlineEditingPolicy.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerNativeComponentInspectorModel.java diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerInlineEditingPolicyTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerInlineEditingPolicyTest.java new file mode 100644 index 0000000000..382b238636 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerInlineEditingPolicyTest.java @@ -0,0 +1,39 @@ +package name.abuchen.portfolio.model.ledger; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; + +import org.junit.Test; + +import name.abuchen.portfolio.model.ledger.compatibility.LedgerInlineEditingField; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerInlineEditingPolicy; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; + +@SuppressWarnings("nls") +public class LedgerInlineEditingPolicyTest +{ + @Test + public void testSuppliedInlineEditingMatrixRows() + { + assertThat(LedgerInlineEditingPolicy.isEditable(LedgerEntryType.DEPOSIT, LedgerProjectionRole.ACCOUNT, + LedgerInlineEditingField.SOURCE), is(false)); + assertThat(LedgerInlineEditingPolicy.isEditable(LedgerEntryType.DEPOSIT, LedgerProjectionRole.ACCOUNT, + LedgerInlineEditingField.TRANSACTION_SOURCE), is(false)); + assertThat(LedgerInlineEditingPolicy.isEditable(LedgerEntryType.DEPOSIT, LedgerProjectionRole.ACCOUNT, + LedgerInlineEditingField.DATE), is(true)); + assertThat(LedgerInlineEditingPolicy.isEditable(LedgerEntryType.DIVIDENDS, LedgerProjectionRole.ACCOUNT, + LedgerInlineEditingField.EX_DATE), is(true)); + assertThat(LedgerInlineEditingPolicy.isEditable(LedgerEntryType.DIVIDENDS, LedgerProjectionRole.ACCOUNT, + LedgerInlineEditingField.SHARES), is(true)); + assertThat(LedgerInlineEditingPolicy.isEditable(LedgerEntryType.FEES, LedgerProjectionRole.ACCOUNT, + LedgerInlineEditingField.TYPE), is(false)); + assertThat(LedgerInlineEditingPolicy.isEditable(LedgerEntryType.BUY, LedgerProjectionRole.PORTFOLIO, + LedgerInlineEditingField.SHARES), is(false)); + assertThat(LedgerInlineEditingPolicy.isEditable(LedgerEntryType.DELIVERY_INBOUND, + LedgerProjectionRole.DELIVERY_INBOUND, LedgerInlineEditingField.TYPE), is(true)); + assertThat(LedgerInlineEditingPolicy.isEditable(LedgerEntryType.SPIN_OFF, + LedgerProjectionRole.DELIVERY_INBOUND, LedgerInlineEditingField.DATE), is(false)); + assertThat(LedgerInlineEditingPolicy.isEditable(LedgerEntryType.SPIN_OFF, + LedgerProjectionRole.CASH_COMPENSATION, LedgerInlineEditingField.SOURCE), is(false)); + } +} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerNativeComponentInspectorModelTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerNativeComponentInspectorModelTest.java new file mode 100644 index 0000000000..e415cde5f3 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerNativeComponentInspectorModelTest.java @@ -0,0 +1,323 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +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.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.junit.Test; + +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.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerParameter; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerNativeComponentInspectorModel.HeaderField; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionKind; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinitionRegistry; +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.model.ledger.nativeentry.LedgerNativeEntryAssembler; +import name.abuchen.portfolio.model.ledger.nativeentry.NativeCorporateActionEvent; +import name.abuchen.portfolio.model.ledger.nativeentry.NativeEntryMetadata; +import name.abuchen.portfolio.model.ledger.nativeentry.NativeSecurityLeg; +import name.abuchen.portfolio.model.ledger.nativeentry.Ratio; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.Values; + +/** + * Tests the read-only model behind the Ledger entry inspector. + * The tests make sure selected ledger-backed projections can be displayed from Ledger facts + * and optional Java-only native leg definitions without mutating the entry or legacy projection. + */ +public class LedgerNativeComponentInspectorModelTest +{ + /** + * Checks that a spin-off entry can be inspected from the selected old-security projection. + * The inspector model must expose entry parameters, functional legs, postings, posting + * parameters, and projection refs from the persisted Ledger entry. + */ + @Test + public void testSpinOffInspectionIncludesEntryLegPostingParameterAndProjectionRows() + { + var entry = spinOffEntry(); + var selectedProjectionRef = entry.getProjectionRefs().get(0); + var updatedAt = entry.getUpdatedAt(); + + var model = LedgerNativeComponentInspectorModel + .from(entry, selectedProjectionRef, LedgerEntryDefinitionRegistry::lookup).orElseThrow(); + + assertThat(model.getHeaderRows().stream() + .anyMatch(row -> row.field() == HeaderField.ENTRY_TYPE && "SPIN_OFF".equals(row.value())), + is(true)); + assertThat(model.getHeaderRows().stream() + .anyMatch(row -> row.field() == HeaderField.NATIVE_TARGETED && "true".equals(row.value())), + is(true)); + assertThat(model.getHeaderRows().stream() + .anyMatch(row -> row.field() == HeaderField.SELECTED_PROJECTION_UUID + && "projection-old".equals(row.value())), + is(true)); + assertTrue(model.isNativeEntryDefinitionAvailable()); + assertThat(model.getEntryParameters().stream() + .anyMatch(row -> "CORPORATE_ACTION_KIND".equals(row.parameter()) + && "SPIN_OFF".equals(row.value())), + is(true)); + assertThat(model.getLegs().stream().anyMatch(row -> "SOURCE_SECURITY_LEG".equals(row.legRole())), is(true)); + assertThat(model.getLegs().stream().anyMatch(row -> "TARGET_SECURITY_LEG".equals(row.legRole())), is(true)); + assertThat(model.getPostings().stream() + .anyMatch(row -> "posting-source".equals(row.postingUUID()) + && "SECURITY".equals(row.postingType()) + && "Old Security".equals(row.security())), + is(true)); + assertThat(model.getPostingParameters().stream() + .anyMatch(row -> "posting-source".equals(row.postingUUID()) + && "SOURCE_SECURITY".equals(row.parameter()) + && "Old Security".equals(row.value())), + is(true)); + assertThat(model.getProjectionRefs().stream() + .anyMatch(row -> "OLD_SECURITY_LEG".equals(row.projectionRole()) + && "projection-old".equals(row.projectionUUID()) + && "posting-source".equals(row.primaryPostingUUID())), + is(true)); + + assertThat(entry.getUpdatedAt(), is(updatedAt)); + assertThat(entry.getPostings().size(), is(2)); + assertThat(entry.getProjectionRefs().size(), is(2)); + } + + /** + * Checks that other configured native corporate-action entries can be inspected as native + * Ledger entries. + * Each smoke case must expose at least the leg rows from its entry definition. + */ + @Test + public void testOtherNativeEntryTypesExposeConfiguredLegs() + { + assertHasLegRows(LedgerEntryType.STOCK_DIVIDEND, LedgerProjectionRole.DELIVERY_INBOUND); + assertHasLegRows(LedgerEntryType.BONUS_ISSUE, LedgerProjectionRole.DELIVERY_INBOUND); + assertHasLegRows(LedgerEntryType.BOND_CONVERSION, LedgerProjectionRole.OLD_SECURITY_LEG); + } + + /** + * Checks that a materialized native corporate-action projection is recognized by the + * inspector support method. + * UI guards use this result to offer inspection without legacy edit, duplicate, delete, or + * convert actions. + */ + @Test + public void testMaterializedNativeProjectionIsRecognizedAsNativeTargeted() + { + var fixture = fixture(); + + LedgerNativeEntryAssembler.forClient(fixture.client()).spinOff() + .metadata(NativeEntryMetadata.of(LocalDateTime.of(2026, 6, 23, 9, 0))) + .event(NativeCorporateActionEvent.builder() + .kind(CorporateActionKind.SPIN_OFF) + .effectiveDate(LocalDate.of(2026, 6, 20)) + .build()) + .securityLeg(NativeSecurityLeg.source() + .portfolio(fixture.portfolio()) + .security(fixture.security()) + .shares(Values.Share.factorize(10)) + .amount(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(100))) + .sourceSecurity(fixture.security()) + .targetSecurity(fixture.targetSecurity()) + .ratio(Ratio.of(BigDecimal.ONE, BigDecimal.TEN)) + .build()) + .securityLeg(NativeSecurityLeg.target() + .portfolio(fixture.portfolio()) + .security(fixture.targetSecurity()) + .shares(Values.Share.factorize(1)) + .amount(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(10))) + .sourceSecurity(fixture.security()) + .targetSecurity(fixture.targetSecurity()) + .ratio(Ratio.of(BigDecimal.ONE, BigDecimal.TEN)) + .build()) + .buildAndAdd(); + + var transaction = fixture.portfolio().getTransactions().get(0); + + assertTrue(LedgerNativeComponentInspectorModel.isLedgerNativeTargetedProjection(transaction)); + assertTrue(LedgerNativeComponentInspectorModel.from(transaction).isPresent()); + } + + /** + * Checks that a legacy fixed-shape Ledger-backed transaction can still be inspected. + * The model must show Ledger facts without native leg definitions because standard + * transaction families are not configured through native legs. + */ + @Test + public void testLegacyFixedShapeEntryShowsLedgerFactsWithoutNativeLegDefinitions() + { + var client = new Client(); + var account = new Account("Account"); + var portfolio = new Portfolio("Portfolio"); + var security = new Security("Security", CurrencyUnit.EUR); + + account.setCurrencyCode(CurrencyUnit.EUR); + portfolio.setReferenceAccount(account); + client.addAccount(account); + client.addPortfolio(portfolio); + client.addSecurity(security); + + var buySell = new LedgerBuySellTransactionCreator(client).create(portfolio, account, + PortfolioTransaction.Type.BUY, LocalDateTime.of(2026, 6, 23, 9, 0), + Values.Amount.factorize(123), CurrencyUnit.EUR, security, Values.Share.factorize(5), + List.of(), "note", "source"); + + var model = LedgerNativeComponentInspectorModel.from(buySell.getPortfolioTransaction()).orElseThrow(); + + assertThat(model.getHeaderRows().stream() + .anyMatch(row -> row.field() == HeaderField.ENTRY_TYPE && "BUY".equals(row.value())), + is(true)); + assertFalse(model.isNativeEntryDefinitionAvailable()); + assertTrue(model.getLegs().isEmpty()); + assertFalse(model.getPostings().isEmpty()); + assertFalse(model.getProjectionRefs().isEmpty()); + } + + /** + * Checks that a native entry without a matching Java definition still shows persisted facts. + * The model must not guess functional legs when the registry cannot describe the entry. + */ + @Test + public void testNativeEntryWithMissingDefinitionShowsLedgerFactsWithoutLegs() + { + var entry = new LedgerEntry("entry-spin-off"); + entry.setType(LedgerEntryType.SPIN_OFF); + entry.setDateTime(LocalDateTime.of(2026, 6, 23, 9, 0)); + entry.addProjectionRef(projection("projection-old", LedgerProjectionRole.OLD_SECURITY_LEG, null)); + + var model = LedgerNativeComponentInspectorModel.from(entry, entry.getProjectionRefs().get(0), + ignored -> Optional.empty()).orElseThrow(); + + assertFalse(model.isNativeEntryDefinitionAvailable()); + assertTrue(model.getLegs().isEmpty()); + assertThat(model.getProjectionRefs().size(), is(1)); + } + + /** + * Checks that normal legacy transactions are not offered to the Ledger inspector. + * The action must only appear when a selected row resolves to a ledger-backed projection. + */ + @Test + public void testNonLedgerBackedTransactionIsUnsupported() + { + var transaction = new AccountTransaction(); + transaction.setType(AccountTransaction.Type.DEPOSIT); + + assertFalse(LedgerNativeComponentInspectorModel.from(transaction).isPresent()); + } + + private static void assertHasLegRows(LedgerEntryType type, LedgerProjectionRole projectionRole) + { + var entry = new LedgerEntry("entry-" + type.name()); + entry.setType(type); + entry.setDateTime(LocalDateTime.of(2026, 6, 23, 9, 0)); + var projectionRef = projection("projection-" + type.name(), projectionRole, null); + entry.addProjectionRef(projectionRef); + + var model = LedgerNativeComponentInspectorModel.from(entry, projectionRef, LedgerEntryDefinitionRegistry::lookup) + .orElseThrow(); + + assertTrue(type + " should expose configured legs", model.getLegs().size() > 0); + } + + private static LedgerEntry spinOffEntry() + { + var entry = new LedgerEntry("entry-spin-off"); + entry.setType(LedgerEntryType.SPIN_OFF); + entry.setDateTime(LocalDateTime.of(2026, 6, 23, 9, 0)); + entry.setNote("Spin-off note"); + entry.setSource("manual"); + entry.addParameter(LedgerParameter.ofCode(LedgerParameterType.CORPORATE_ACTION_KIND, + CorporateActionKind.SPIN_OFF)); + entry.addParameter(LedgerParameter.ofLocalDate(LedgerParameterType.EFFECTIVE_DATE, + LocalDate.of(2026, 6, 20))); + + var portfolio = new Portfolio("Growth Portfolio"); + var oldSecurity = new Security("Old Security", CurrencyUnit.EUR); + var newSecurity = new Security("New Security", CurrencyUnit.EUR); + + var sourcePosting = securityPosting("posting-source", oldSecurity, CorporateActionLeg.SOURCE_SECURITY, + LedgerParameterType.SOURCE_SECURITY, oldSecurity); + sourcePosting.setPortfolio(portfolio); + entry.addPosting(sourcePosting); + + var targetPosting = securityPosting("posting-target", newSecurity, CorporateActionLeg.TARGET_SECURITY, + LedgerParameterType.TARGET_SECURITY, newSecurity); + targetPosting.setPortfolio(portfolio); + entry.addPosting(targetPosting); + + var oldProjection = projection("projection-old", LedgerProjectionRole.OLD_SECURITY_LEG, + sourcePosting.getUUID()); + oldProjection.setPortfolio(portfolio); + entry.addProjectionRef(oldProjection); + + var newProjection = projection("projection-new", LedgerProjectionRole.NEW_SECURITY_LEG, + targetPosting.getUUID()); + newProjection.setPortfolio(portfolio); + entry.addProjectionRef(newProjection); + + return entry; + } + + private static Fixture fixture() + { + var client = new Client(); + var account = new Account("Account"); + var portfolio = new Portfolio("Portfolio"); + var security = new Security("Security", CurrencyUnit.EUR); + var targetSecurity = new Security("Target Security", CurrencyUnit.EUR); + + account.setCurrencyCode(CurrencyUnit.EUR); + portfolio.setReferenceAccount(account); + client.addAccount(account); + client.addPortfolio(portfolio); + client.addSecurity(security); + client.addSecurity(targetSecurity); + + return new Fixture(client, account, portfolio, security, targetSecurity); + } + + private static LedgerPosting securityPosting(String uuid, Security security, CorporateActionLeg leg, + LedgerParameterType securityParameterType, Security parameterSecurity) + { + var posting = new LedgerPosting(uuid); + posting.setType(LedgerPostingType.SECURITY); + posting.setSecurity(security); + posting.setShares(123_00000000L); + posting.addParameter(LedgerParameter.ofCode(LedgerParameterType.CORPORATE_ACTION_LEG, leg)); + posting.addParameter(LedgerParameter.ofSecurity(securityParameterType, parameterSecurity)); + posting.addParameter(LedgerParameter.ofDecimal(LedgerParameterType.RATIO_NUMERATOR, BigDecimal.ONE)); + posting.addParameter(LedgerParameter.ofDecimal(LedgerParameterType.RATIO_DENOMINATOR, BigDecimal.TEN)); + return posting; + } + + private static LedgerProjectionRef projection(String uuid, LedgerProjectionRole role, String primaryPostingUUID) + { + var projectionRef = new LedgerProjectionRef(uuid); + projectionRef.setRole(role); + projectionRef.setPrimaryPostingUUID(primaryPostingUUID); + return projectionRef; + } + + private record Fixture(Client client, Account account, Portfolio portfolio, Security security, Security targetSecurity) + { + } +} diff --git a/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/AccountTransactionModelTest.java b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/AccountTransactionModelTest.java new file mode 100644 index 0000000000..699e6035e3 --- /dev/null +++ b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/AccountTransactionModelTest.java @@ -0,0 +1,202 @@ +package name.abuchen.portfolio.ui.dialogs.transactions; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.lang.reflect.InvocationTargetException; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +import org.junit.Test; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.Transaction; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.ExchangeRateProviderFactory; +import name.abuchen.portfolio.money.Values; + +@SuppressWarnings("nls") +public class AccountTransactionModelTest +{ + @Test + public void testNewAccountOnlyTransactionCreatesLedgerBackedProjection() throws ReflectiveOperationException + { + var client = new Client(); + var account = new Account("Account"); + account.setCurrencyCode(CurrencyUnit.EUR); + client.addAccount(account); + + var model = new AccountTransactionModel(client, AccountTransaction.Type.DEPOSIT); + + model.setAccount(account); + model.setDate(LocalDate.of(2026, 6, 7)); + model.setTotal(Values.Amount.factorize(123)); + model.setNote("note"); + model.applyChanges(); + + assertThat(ledgerEntryCount(client), is(1)); + assertThat(account.getTransactions().size(), is(1)); + assertThat(account.getTransactions().get(0).getClass().getName(), containsString("LedgerBacked")); + assertThat(client.getAllTransactions().size(), is(1)); + } + + @Test + public void testExistingLedgerBackedAccountOnlyTransactionEditsThroughLedger() + throws ReflectiveOperationException + { + var client = new Client(); + var account = new Account("Account"); + account.setCurrencyCode(CurrencyUnit.EUR); + client.addAccount(account); + + var createModel = new AccountTransactionModel(client, AccountTransaction.Type.DEPOSIT); + createModel.setAccount(account); + createModel.setDate(LocalDate.of(2026, 6, 7)); + createModel.setTotal(Values.Amount.factorize(123)); + createModel.setNote("note"); + createModel.applyChanges(); + + var transaction = account.getTransactions().get(0); + var editModel = new AccountTransactionModel(client, AccountTransaction.Type.DEPOSIT); + editModel.setSource(account, transaction); + editModel.setTotal(Values.Amount.factorize(456)); + editModel.setNote("updated"); + editModel.applyChanges(); + + assertThat(ledgerEntryCount(client), is(1)); + assertThat(account.getTransactions().size(), is(1)); + assertThat(account.getTransactions().get(0), is(transaction)); + assertThat(transaction.getAmount(), is(Values.Amount.factorize(456))); + assertThat(transaction.getNote(), is("updated")); + assertThat(client.getAllTransactions().size(), is(1)); + } + + @Test + public void testAccountOnlyFeeDoesNotOfferExDate() + { + var model = new AccountTransactionModel(new Client(), AccountTransaction.Type.FEES); + + assertThat(model.supportsSecurity(), is(true)); + assertThat(model.supportsExDate(), is(false)); + } + + @Test + public void testNewDividendCreatesLedgerBackedProjection() throws ReflectiveOperationException + { + var client = new Client(); + client.setBaseCurrency(CurrencyUnit.EUR); + var account = new Account("Account"); + var security = new Security("Security", CurrencyUnit.USD); + account.setCurrencyCode(CurrencyUnit.EUR); + client.addAccount(account); + client.addSecurity(security); + + var model = new AccountTransactionModel(client, AccountTransaction.Type.DIVIDENDS); + model.setExchangeRateProviderFactory(new ExchangeRateProviderFactory(client)); + + model.setAccount(account); + model.setSecurity(security); + model.setDate(LocalDate.of(2026, 6, 7)); + model.setShares(Values.Share.factorize(12)); + model.setExDate(LocalDateTime.of(2026, 6, 1, 0, 0)); + model.setExchangeRate(BigDecimal.valueOf(2)); + model.setFxGrossAmount(Values.Amount.factorize(100)); + model.setFxFees(Values.Amount.factorize(10)); + model.setFxTaxes(Values.Amount.factorize(20)); + model.setNote("note"); + model.applyChanges(); + + var transaction = account.getTransactions().get(0); + + assertThat(ledgerEntryCount(client), is(1)); + assertThat(transaction.getClass().getName(), containsString("LedgerBacked")); + assertThat(transaction.getType(), is(AccountTransaction.Type.DIVIDENDS)); + assertSame(security, transaction.getSecurity()); + assertThat(transaction.getShares(), is(Values.Share.factorize(12))); + assertThat(transaction.getExDate(), is(LocalDateTime.of(2026, 6, 1, 0, 0))); + assertThat(transaction.getAmount(), is(Values.Amount.factorize(140))); + assertThat(transaction.getNote(), is("note")); + assertThat(transaction.getUnits().count(), is(3L)); + assertThat(client.getAllTransactions().size(), is(1)); + } + + @Test + public void testExistingLedgerBackedDividendEditsThroughLedgerAndMovesOwner() + throws ReflectiveOperationException + { + var client = new Client(); + client.setBaseCurrency(CurrencyUnit.EUR); + var account = new Account("Account"); + var otherAccount = new Account("Other Account"); + var security = new Security("Security", CurrencyUnit.EUR); + account.setCurrencyCode(CurrencyUnit.EUR); + otherAccount.setCurrencyCode(CurrencyUnit.EUR); + client.addAccount(account); + client.addAccount(otherAccount); + client.addSecurity(security); + + var createModel = new AccountTransactionModel(client, AccountTransaction.Type.DIVIDENDS); + createModel.setExchangeRateProviderFactory(new ExchangeRateProviderFactory(client)); + createModel.setAccount(account); + createModel.setSecurity(security); + createModel.setDate(LocalDate.of(2026, 6, 7)); + createModel.setShares(Values.Share.factorize(12)); + createModel.setTotal(Values.Amount.factorize(123)); + createModel.setNote("note"); + createModel.applyChanges(); + + var transaction = account.getTransactions().get(0); + var editModel = new AccountTransactionModel(client, AccountTransaction.Type.DIVIDENDS); + editModel.setExchangeRateProviderFactory(new ExchangeRateProviderFactory(client)); + editModel.setSource(account, transaction); + editModel.setShares(Values.Share.factorize(20)); + editModel.setExDate(LocalDateTime.of(2026, 6, 2, 0, 0)); + editModel.setTotal(Values.Amount.factorize(456)); + editModel.setTaxes(Values.Amount.factorize(5)); + editModel.setNote("updated"); + editModel.applyChanges(); + + assertThat(ledgerEntryCount(client), is(1)); + assertThat(account.getTransactions().size(), is(1)); + assertThat(account.getTransactions().get(0), is(transaction)); + assertThat(transaction.getAmount(), is(Values.Amount.factorize(451))); + assertThat(transaction.getShares(), is(Values.Share.factorize(20))); + assertThat(transaction.getExDate(), is(LocalDateTime.of(2026, 6, 2, 0, 0))); + assertThat(transaction.getNote(), is("updated")); + assertThat(transaction.getUnitSum(Transaction.Unit.Type.TAX).getAmount(), is(Values.Amount.factorize(5))); + assertThat(client.getAllTransactions().size(), is(1)); + + var moveModel = new AccountTransactionModel(client, AccountTransaction.Type.DIVIDENDS); + moveModel.setExchangeRateProviderFactory(new ExchangeRateProviderFactory(client)); + moveModel.setSource(account, transaction); + moveModel.setAccount(otherAccount); + moveModel.applyChanges(); + + assertTrue(account.getTransactions().isEmpty()); + assertThat(otherAccount.getTransactions().size(), is(1)); + assertThat(otherAccount.getTransactions().get(0).getUUID(), is(transaction.getUUID())); + assertThat(otherAccount.getTransactions().get(0).getAmount(), is(Values.Amount.factorize(451))); + assertThat(client.getAllTransactions().size(), is(1)); + } + + private int ledgerEntryCount(Client client) throws ReflectiveOperationException + { + try + { + var ledger = Client.class.getMethod("getLedger").invoke(client); + var entries = ledger.getClass().getMethod("getEntries").invoke(ledger); + return ((java.util.List) entries).size(); + } + catch (InvocationTargetException e) + { + throw new AssertionError(e.getCause()); + } + } +} diff --git a/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/AccountTransferModelTest.java b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/AccountTransferModelTest.java new file mode 100644 index 0000000000..832817a376 --- /dev/null +++ b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/AccountTransferModelTest.java @@ -0,0 +1,202 @@ +package name.abuchen.portfolio.ui.dialogs.transactions; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.lang.reflect.InvocationTargetException; +import java.math.BigDecimal; +import java.time.LocalDate; + +import org.junit.Test; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.AccountTransferEntry; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.ExchangeRateProviderFactory; +import name.abuchen.portfolio.money.Values; + +@SuppressWarnings("nls") +public class AccountTransferModelTest +{ + @Test + public void testNewAccountTransferCreatesLedgerBackedProjections() throws ReflectiveOperationException + { + var fixture = fixture(CurrencyUnit.EUR, CurrencyUnit.EUR); + var model = model(fixture); + + model.setAmount(Values.Amount.factorize(123)); + model.setNote("note"); + model.applyChanges(); + + var sourceTransaction = fixture.source().getTransactions().get(0); + var targetTransaction = fixture.target().getTransactions().get(0); + + assertThat(ledgerEntryCount(fixture.client()), is(1)); + assertThat(sourceTransaction.getClass().getName(), containsString("LedgerBacked")); + assertThat(targetTransaction.getClass().getName(), containsString("LedgerBacked")); + assertThat(sourceTransaction.getType(), is(AccountTransaction.Type.TRANSFER_OUT)); + assertThat(targetTransaction.getType(), is(AccountTransaction.Type.TRANSFER_IN)); + assertThat(sourceTransaction.getAmount(), is(Values.Amount.factorize(123))); + assertThat(targetTransaction.getAmount(), is(Values.Amount.factorize(123))); + assertThat(sourceTransaction.getCurrencyCode(), is(CurrencyUnit.EUR)); + assertThat(targetTransaction.getCurrencyCode(), is(CurrencyUnit.EUR)); + assertThat(sourceTransaction.getNote(), is("note")); + assertSame(targetTransaction, sourceTransaction.getCrossEntry().getCrossTransaction(sourceTransaction)); + assertThat(fixture.client().getAllTransactions().size(), is(1)); + } + + @Test + public void testNewCrossCurrencyAccountTransferPreservesSourceAndTargetAmounts() + throws ReflectiveOperationException + { + var fixture = fixture(CurrencyUnit.EUR, CurrencyUnit.USD); + var model = model(fixture); + + model.setExchangeRate(BigDecimal.valueOf(2)); + model.setFxAmount(Values.Amount.factorize(100)); + model.applyChanges(); + + var sourceTransaction = fixture.source().getTransactions().get(0); + var targetTransaction = fixture.target().getTransactions().get(0); + + assertThat(ledgerEntryCount(fixture.client()), is(1)); + assertThat(sourceTransaction.getAmount(), is(Values.Amount.factorize(100))); + assertThat(sourceTransaction.getCurrencyCode(), is(CurrencyUnit.EUR)); + assertThat(targetTransaction.getAmount(), is(Values.Amount.factorize(200))); + assertThat(targetTransaction.getCurrencyCode(), is(CurrencyUnit.USD)); + } + + @Test + public void testExistingLedgerBackedAccountTransferEditsThroughLedgerAndMovesOwner() + throws ReflectiveOperationException + { + var fixture = fixture(CurrencyUnit.EUR, CurrencyUnit.EUR); + var createModel = model(fixture); + createModel.setAmount(Values.Amount.factorize(123)); + createModel.setNote("note"); + createModel.applyChanges(); + + var transfer = wrap(fixture); + var sourceTransaction = transfer.getSourceTransaction(); + var targetTransaction = transfer.getTargetTransaction(); + + var editModel = model(fixture); + editModel.setSource(transfer); + editModel.setAmount(Values.Amount.factorize(456)); + editModel.setNote("updated"); + editModel.applyChanges(); + + assertThat(ledgerEntryCount(fixture.client()), is(1)); + assertThat(fixture.source().getTransactions(), is(java.util.List.of(sourceTransaction))); + assertThat(fixture.target().getTransactions(), is(java.util.List.of(targetTransaction))); + assertThat(sourceTransaction.getAmount(), is(Values.Amount.factorize(456))); + assertThat(targetTransaction.getAmount(), is(Values.Amount.factorize(456))); + assertThat(sourceTransaction.getNote(), is("updated")); + assertThat(fixture.client().getAllTransactions().size(), is(1)); + + var otherAccount = account("Other", CurrencyUnit.EUR); + fixture.client().addAccount(otherAccount); + var moveModel = model(fixture); + moveModel.setSource(transfer); + moveModel.setSourceAccount(otherAccount); + moveModel.applyChanges(); + + assertTrue(fixture.source().getTransactions().isEmpty()); + assertThat(otherAccount.getTransactions().size(), is(1)); + assertThat(otherAccount.getTransactions().get(0).getUUID(), is(sourceTransaction.getUUID())); + assertThat(fixture.target().getTransactions().size(), is(1)); + assertThat(fixture.target().getTransactions().get(0).getUUID(), is(targetTransaction.getUUID())); + assertSame(fixture.target().getTransactions().get(0), + otherAccount.getTransactions().get(0).getCrossEntry() + .getCrossTransaction(otherAccount.getTransactions().get(0))); + assertThat(fixture.client().getAllTransactions().size(), is(1)); + } + + @Test + public void testExistingLedgerBackedAccountTransferMovesSourceAndTargetOwners() throws ReflectiveOperationException + { + var fixture = fixture(CurrencyUnit.EUR, CurrencyUnit.EUR); + var createModel = model(fixture); + createModel.setAmount(Values.Amount.factorize(123)); + createModel.applyChanges(); + + var transfer = wrap(fixture); + var swapModel = model(fixture); + swapModel.setSource(transfer); + swapModel.setSourceAccount(fixture.target()); + swapModel.setTargetAccount(fixture.source()); + swapModel.applyChanges(); + + assertThat(ledgerEntryCount(fixture.client()), is(1)); + assertThat(fixture.source().getTransactions().size(), is(1)); + assertThat(fixture.target().getTransactions().size(), is(1)); + assertThat(fixture.source().getTransactions().get(0).getType(), is(AccountTransaction.Type.TRANSFER_IN)); + assertThat(fixture.target().getTransactions().get(0).getType(), is(AccountTransaction.Type.TRANSFER_OUT)); + assertSame(fixture.source().getTransactions().get(0), + fixture.target().getTransactions().get(0).getCrossEntry() + .getCrossTransaction(fixture.target().getTransactions().get(0))); + assertThat(fixture.client().getAllTransactions().size(), is(1)); + } + + private AccountTransferModel model(Fixture fixture) + { + var model = new AccountTransferModel(fixture.client()); + + model.setExchangeRateProviderFactory(new ExchangeRateProviderFactory(fixture.client())); + model.setSourceAccount(fixture.source()); + model.setTargetAccount(fixture.target()); + model.setDate(LocalDate.of(2026, 6, 7)); + + return model; + } + + private AccountTransferEntry wrap(Fixture fixture) + { + return AccountTransferEntry.readOnly(fixture.source(), fixture.source().getTransactions().get(0), + fixture.target(), fixture.target().getTransactions().get(0)); + } + + private int ledgerEntryCount(Client client) throws ReflectiveOperationException + { + try + { + var ledger = Client.class.getMethod("getLedger").invoke(client); + var entries = ledger.getClass().getMethod("getEntries").invoke(ledger); + return ((java.util.List) entries).size(); + } + catch (InvocationTargetException e) + { + throw new AssertionError(e.getCause()); + } + } + + private Fixture fixture(String sourceCurrency, String targetCurrency) + { + var client = new Client(); + var source = account("Source", sourceCurrency); + var target = account("Target", targetCurrency); + + client.addAccount(source); + client.addAccount(target); + + return new Fixture(client, source, target); + } + + private Account account(String name, String currency) + { + var account = new Account(name); + + account.setCurrencyCode(currency); + + return account; + } + + private record Fixture(Client client, Account source, Account target) + { + } +} diff --git a/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/BuySellModelTest.java b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/BuySellModelTest.java index 08c3388527..7f320fdb37 100644 --- a/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/BuySellModelTest.java +++ b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/BuySellModelTest.java @@ -1,19 +1,28 @@ package name.abuchen.portfolio.ui.dialogs.transactions; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; import java.math.BigDecimal; import java.text.MessageFormat; import java.time.LocalDate; +import java.time.LocalDateTime; import org.eclipse.core.databinding.validation.ValidationStatus; import org.junit.Test; +import name.abuchen.portfolio.model.Account; 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.SecurityPrice; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerBuySellTransactionCreator; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.ExchangeRateProviderFactory; import name.abuchen.portfolio.money.Values; import name.abuchen.portfolio.ui.Messages; @@ -138,4 +147,149 @@ public void testWithSecurity() assertThat(model.getSecurityCurrencyCode(), is("USD")); assertThat(model.getExchangeRate(), is(BigDecimal.ONE)); } + + @SuppressWarnings("nls") + @Test + public void testNewBuyCreatesLedgerBackedProjections() + { + var fixture = fixture(PortfolioTransaction.Type.BUY); + var model = model(fixture, PortfolioTransaction.Type.BUY); + + fillForexBuySell(model); + model.applyChanges(); + + var accountTransaction = fixture.account().getTransactions().get(0); + var portfolioTransaction = fixture.portfolio().getTransactions().get(0); + + assertThat(accountTransaction.getClass().getName(), containsString("LedgerBacked")); + assertThat(portfolioTransaction.getClass().getName(), containsString("LedgerBacked")); + assertThat(accountTransaction.getType().name(), is(PortfolioTransaction.Type.BUY.name())); + assertThat(portfolioTransaction.getType(), is(PortfolioTransaction.Type.BUY)); + assertThat(portfolioTransaction.getShares(), is(Values.Share.factorize(5))); + assertThat(portfolioTransaction.getAmount(), is(Values.Amount.factorize(127))); + assertThat(portfolioTransaction.getUnits().count(), is(3L)); + assertSame(portfolioTransaction, accountTransaction.getCrossEntry().getCrossTransaction(accountTransaction)); + assertThat(fixture.client().getAllTransactions().size(), is(1)); + } + + @SuppressWarnings("nls") + @Test + public void testNewSellCreatesLedgerBackedProjections() + { + var fixture = fixture(PortfolioTransaction.Type.SELL); + var model = model(fixture, PortfolioTransaction.Type.SELL); + + fillForexBuySell(model); + model.applyChanges(); + + var accountTransaction = fixture.account().getTransactions().get(0); + var portfolioTransaction = fixture.portfolio().getTransactions().get(0); + + assertThat(accountTransaction.getClass().getName(), containsString("LedgerBacked")); + assertThat(portfolioTransaction.getClass().getName(), containsString("LedgerBacked")); + assertThat(accountTransaction.getType().name(), is(PortfolioTransaction.Type.SELL.name())); + assertThat(portfolioTransaction.getType(), is(PortfolioTransaction.Type.SELL)); + assertThat(portfolioTransaction.getShares(), is(Values.Share.factorize(5))); + assertThat(portfolioTransaction.getAmount(), is(Values.Amount.factorize(113))); + assertThat(portfolioTransaction.getUnits().count(), is(3L)); + assertSame(portfolioTransaction, accountTransaction.getCrossEntry().getCrossTransaction(accountTransaction)); + assertThat(fixture.client().getAllTransactions().size(), is(1)); + } + + @SuppressWarnings("nls") + @Test + public void testExistingLedgerBackedBuySellEditsThroughLedgerAndMovesOwner() + { + var fixture = fixture(PortfolioTransaction.Type.BUY); + var entry = new LedgerBuySellTransactionCreator(fixture.client()).create(fixture.portfolio(), + fixture.account(), PortfolioTransaction.Type.BUY, LocalDateTime.of(2026, 6, 7, 8, 9), + Values.Amount.factorize(100), CurrencyUnit.EUR, fixture.security(), + Values.Share.factorize(5), java.util.List.of(), "note", "source"); + var accountTransaction = entry.getAccountTransaction(); + var portfolioTransaction = entry.getPortfolioTransaction(); + var editModel = editModel(fixture, PortfolioTransaction.Type.BUY); + + editModel.setSource(entry); + editModel.setShares(Values.Share.factorize(7)); + editModel.setGrossValue(Values.Amount.factorize(140)); + editModel.setNote("updated"); + editModel.applyChanges(); + + assertThat(fixture.account().getTransactions(), is(java.util.List.of(accountTransaction))); + assertThat(fixture.portfolio().getTransactions(), is(java.util.List.of(portfolioTransaction))); + assertThat(portfolioTransaction.getShares(), is(Values.Share.factorize(7))); + assertThat(portfolioTransaction.getAmount(), is(Values.Amount.factorize(140))); + assertThat(portfolioTransaction.getNote(), is("updated")); + + var otherPortfolio = new Portfolio("Other"); + otherPortfolio.setReferenceAccount(fixture.account()); + fixture.client().addPortfolio(otherPortfolio); + var moveModel = editModel(fixture, PortfolioTransaction.Type.BUY); + moveModel.setSource(entry); + moveModel.setPortfolio(otherPortfolio); + moveModel.applyChanges(); + + assertThat(fixture.account().getTransactions().size(), is(1)); + assertThat(fixture.account().getTransactions().get(0).getUUID(), is(accountTransaction.getUUID())); + assertTrue(fixture.portfolio().getTransactions().isEmpty()); + assertThat(otherPortfolio.getTransactions().size(), is(1)); + assertThat(otherPortfolio.getTransactions().get(0).getUUID(), is(portfolioTransaction.getUUID())); + assertSame(otherPortfolio.getTransactions().get(0), + fixture.account().getTransactions().get(0).getCrossEntry() + .getCrossTransaction(fixture.account().getTransactions().get(0))); + assertThat(fixture.client().getAllTransactions().size(), is(1)); + } + + private void fillForexBuySell(BuySellModel model) + { + model.setExchangeRate(new BigDecimal("0.5000")); + model.setShares(Values.Share.factorize(5)); + model.setGrossValue(Values.Amount.factorize(240)); + model.setFees(Values.Amount.factorize(3)); + model.setForexTaxes(Values.Amount.factorize(8)); + model.setNote("note"); + } + + private BuySellModel model(Fixture fixture, PortfolioTransaction.Type type) + { + var model = editModel(fixture, type); + + model.setPortfolio(fixture.portfolio()); + model.setSecurity(fixture.security()); + model.setDate(LocalDate.of(2026, 6, 7)); + + return model; + } + + private BuySellModel editModel(Fixture fixture, PortfolioTransaction.Type type) + { + var model = new BuySellModel(fixture.client(), type); + + model.setExchangeRateProviderFactory(new ExchangeRateProviderFactory(fixture.client())); + + return model; + } + + private Fixture fixture(PortfolioTransaction.Type type) + { + var client = new Client(); + var account = new Account("Account"); + var portfolio = new Portfolio("Portfolio"); + var security = new Security("Security", CurrencyUnit.USD); + + account.setCurrencyCode(CurrencyUnit.EUR); + portfolio.setReferenceAccount(account); + security.addPrice(new SecurityPrice(LocalDate.of(2026, 6, 7), Values.Quote.factorize(48))); + + client.addAccount(account); + client.addPortfolio(portfolio); + client.addSecurity(security); + + return new Fixture(client, account, portfolio, security, type); + } + + private record Fixture(Client client, Account account, Portfolio portfolio, Security security, + PortfolioTransaction.Type type) + { + } } diff --git a/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/LedgerTransactionDuplicateCopyParityTest.java b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/LedgerTransactionDuplicateCopyParityTest.java new file mode 100644 index 0000000000..9b74e0299f --- /dev/null +++ b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/LedgerTransactionDuplicateCopyParityTest.java @@ -0,0 +1,545 @@ +package name.abuchen.portfolio.ui.dialogs.transactions; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.EnumSet; +import java.util.HashSet; +import java.util.List; + +import org.eclipse.core.runtime.NullProgressMonitor; +import org.junit.Test; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.ClientFactory; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.PortfolioTransaction; +import name.abuchen.portfolio.model.SaveFlag; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.Transaction; +import name.abuchen.portfolio.model.TransactionPair; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountOnlyTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountTransferTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerBuySellTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerDeliveryTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerDividendTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferTransactionCreator; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.ExchangeRateProviderFactory; +import name.abuchen.portfolio.money.Values; + +/** + * Tests duplicate and copy behavior for ledger-backed transactions. + * These tests make sure copied bookings get fresh ledger truth and do not share runtime projections with the source. + */ +@SuppressWarnings("nls") +public class LedgerTransactionDuplicateCopyParityTest +{ + private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 6, 7, 8, 9); + private static final LocalDateTime EX_DATE = LocalDateTime.of(2026, 6, 1, 0, 0); + private static final long AMOUNT = Values.Amount.factorize(123); + private static final long SHARES = Values.Share.factorize(12); + + /** + * Verifies that duplicating a ledger-backed deposit creates a fresh ledger entry. + * The copy must not reuse posting or projection identifiers from the source booking. + */ + @Test + public void testDepositDuplicateCreatesFreshLedgerTruth() throws Exception + { + var fixture = fixture(); + var original = new LedgerAccountOnlyTransactionCreator(fixture.client()).create(fixture.account(), + AccountTransaction.Type.DEPOSIT, DATE_TIME, AMOUNT, CurrencyUnit.EUR, null, List.of(), "note", + "source"); + var originalSnapshot = EntrySnapshot.of(original); + + var model = new AccountTransactionModel(fixture.client(), AccountTransaction.Type.DEPOSIT); + model.presetFromSource(fixture.account(), original); + model.applyChanges(); + + var duplicate = onlyOther(fixture.account().getTransactions(), original); + + assertAccountProjectionDuplicate(original, duplicate, originalSnapshot, 1, 1); + assertThat(fixture.account().getTransactions().size(), is(2)); + assertThat(duplicate.getDateTime(), is(DATE_TIME)); + assertThat(duplicate.getNote(), is("note")); + assertThat(duplicate.getSource(), nullValue()); + assertThat(duplicate.getAmount(), is(AMOUNT)); + assertThat(duplicate.getCurrencyCode(), is(CurrencyUnit.EUR)); + assertNoDuplicateProjectionUUIDs(fixture.client()); + assertRoundtrips(fixture.client(), originalSnapshot.entryUUID(), uuid(entry(duplicate)), 2); + } + + /** + * Verifies that duplicating a ledger-backed dividend creates a fresh ledger entry. + * Dividend facts such as security, shares, and ex-date must be copied without sharing ledger identity. + */ + @Test + public void testDividendDuplicateCreatesFreshLedgerTruth() throws Exception + { + var fixture = fixture(); + var original = new LedgerDividendTransactionCreator(fixture.client()).create(fixture.account(), DATE_TIME, + AMOUNT, CurrencyUnit.EUR, fixture.security(), SHARES, EX_DATE, null, null, List.of(), "note", + "source"); + var originalSnapshot = EntrySnapshot.of(original); + + var model = new AccountTransactionModel(fixture.client(), AccountTransaction.Type.DIVIDENDS); + model.setExchangeRateProviderFactory(new ExchangeRateProviderFactory(fixture.client())); + model.presetFromSource(fixture.account(), original); + model.applyChanges(); + + var duplicate = onlyOther(fixture.account().getTransactions(), original); + + assertAccountProjectionDuplicate(original, duplicate, originalSnapshot, 1, 1); + assertThat(fixture.account().getTransactions().size(), is(2)); + assertThat(duplicate.getDateTime(), is(DATE_TIME)); + assertThat(duplicate.getNote(), is("note")); + assertThat(duplicate.getSource(), nullValue()); + assertSame(fixture.security(), duplicate.getSecurity()); + assertThat(duplicate.getShares(), is(SHARES)); + assertThat(duplicate.getExDate(), is(EX_DATE)); + assertThat(duplicate.getAmount(), is(AMOUNT)); + assertNoDuplicateProjectionUUIDs(fixture.client()); + assertRoundtrips(fixture.client(), originalSnapshot.entryUUID(), uuid(entry(duplicate)), 2); + } + + /** + * Verifies that duplicating a ledger-backed buy creates a fresh ledger entry and cross entry. + * The account and portfolio sides of the copy must point to each other, not to the original booking. + */ + @Test + public void testBuyDuplicateCreatesFreshLedgerTruthAndCrossEntry() throws Exception + { + var fixture = fixture(); + var original = new LedgerBuySellTransactionCreator(fixture.client()).create(fixture.portfolio(), + fixture.account(), PortfolioTransaction.Type.BUY, DATE_TIME, AMOUNT, CurrencyUnit.EUR, + fixture.security(), SHARES, List.of(), "note", "source"); + var originalPortfolioTransaction = original.getPortfolioTransaction(); + var originalAccountTransaction = original.getAccountTransaction(); + var originalSnapshot = EntrySnapshot.of(originalPortfolioTransaction); + + var model = new BuySellModel(fixture.client(), PortfolioTransaction.Type.BUY); + model.setExchangeRateProviderFactory(new ExchangeRateProviderFactory(fixture.client())); + model.presetFromSource(original); + model.applyChanges(); + + var duplicateAccountTransaction = onlyOther(fixture.account().getTransactions(), originalAccountTransaction); + var duplicatePortfolioTransaction = onlyOther(fixture.portfolio().getTransactions(), originalPortfolioTransaction); + + assertPortfolioProjectionDuplicate(originalPortfolioTransaction, duplicatePortfolioTransaction, originalSnapshot, + 2, 2); + assertThat(fixture.account().getTransactions().size(), is(2)); + assertThat(fixture.portfolio().getTransactions().size(), is(2)); + assertSame(duplicatePortfolioTransaction, + duplicateAccountTransaction.getCrossEntry().getCrossTransaction(duplicateAccountTransaction)); + assertSame(originalPortfolioTransaction, + originalAccountTransaction.getCrossEntry().getCrossTransaction(originalAccountTransaction)); + assertThat(duplicatePortfolioTransaction, not(originalPortfolioTransaction)); + assertThat(duplicateAccountTransaction, not(originalAccountTransaction)); + assertThat(duplicatePortfolioTransaction.getType(), is(PortfolioTransaction.Type.BUY)); + assertThat(duplicatePortfolioTransaction.getDateTime(), is(DATE_TIME)); + assertThat(duplicatePortfolioTransaction.getNote(), is("note")); + assertThat(duplicatePortfolioTransaction.getSource(), nullValue()); + assertSame(fixture.security(), duplicatePortfolioTransaction.getSecurity()); + assertThat(duplicatePortfolioTransaction.getShares(), is(SHARES)); + assertThat(duplicatePortfolioTransaction.getAmount(), is(AMOUNT)); + assertNoDuplicateProjectionUUIDs(fixture.client()); + assertRoundtrips(fixture.client(), originalSnapshot.entryUUID(), uuid(entry(duplicatePortfolioTransaction)), + 2); + } + + /** + * Verifies that duplicating a ledger-backed inbound delivery creates a fresh ledger entry. + * The copy must preserve delivery facts while using new ledger and projection identifiers. + */ + @Test + public void testDeliveryInboundDuplicateCreatesFreshLedgerTruth() throws Exception + { + var fixture = fixture(); + var original = new LedgerDeliveryTransactionCreator(fixture.client()).create(fixture.portfolio(), + PortfolioTransaction.Type.DELIVERY_INBOUND, DATE_TIME, AMOUNT, CurrencyUnit.EUR, + fixture.security(), SHARES, null, null, List.of(), "note", "source"); + var originalSnapshot = EntrySnapshot.of(original); + + var model = new SecurityDeliveryModel(fixture.client(), PortfolioTransaction.Type.DELIVERY_INBOUND); + model.setExchangeRateProviderFactory(new ExchangeRateProviderFactory(fixture.client())); + model.presetFromSource(new TransactionPair<>(fixture.portfolio(), original)); + model.applyChanges(); + + var duplicate = onlyOther(fixture.portfolio().getTransactions(), original); + + assertPortfolioProjectionDuplicate(original, duplicate, originalSnapshot, 1, 1); + assertThat(fixture.portfolio().getTransactions().size(), is(2)); + assertThat(duplicate.getType(), is(PortfolioTransaction.Type.DELIVERY_INBOUND)); + assertThat(duplicate.getDateTime(), is(DATE_TIME)); + assertThat(duplicate.getNote(), is("note")); + assertThat(duplicate.getSource(), nullValue()); + assertSame(fixture.security(), duplicate.getSecurity()); + assertThat(duplicate.getShares(), is(SHARES)); + assertThat(duplicate.getAmount(), is(AMOUNT)); + assertNoDuplicateProjectionUUIDs(fixture.client()); + assertRoundtrips(fixture.client(), originalSnapshot.entryUUID(), uuid(entry(duplicate)), 2); + } + + /** + * Verifies that duplicating a ledger-backed account transfer creates a fresh transfer entry. + * Source and target sides of the copy must be paired together and survive save/load. + */ + @Test + public void testAccountTransferDuplicateCreatesFreshLedgerTruthAndCrossEntry() throws Exception + { + var fixture = fixture(); + var original = new LedgerAccountTransferTransactionCreator(fixture.client()).create(fixture.account(), + fixture.targetAccount(), DATE_TIME, AMOUNT, CurrencyUnit.EUR, AMOUNT, CurrencyUnit.EUR, null, + null, "note", "source"); + var originalSourceTransaction = original.getSourceTransaction(); + var originalTargetTransaction = original.getTargetTransaction(); + var originalSnapshot = EntrySnapshot.of(originalSourceTransaction); + + var model = new AccountTransferModel(fixture.client()); + model.setExchangeRateProviderFactory(new ExchangeRateProviderFactory(fixture.client())); + model.presetFromSource(original); + model.applyChanges(); + + var duplicateSourceTransaction = onlyOther(fixture.account().getTransactions(), originalSourceTransaction); + var duplicateTargetTransaction = onlyOther(fixture.targetAccount().getTransactions(), originalTargetTransaction); + + assertAccountProjectionDuplicate(originalSourceTransaction, duplicateSourceTransaction, originalSnapshot, 2, 2); + assertThat(fixture.account().getTransactions().size(), is(2)); + assertThat(fixture.targetAccount().getTransactions().size(), is(2)); + assertSame(duplicateTargetTransaction, + duplicateSourceTransaction.getCrossEntry().getCrossTransaction(duplicateSourceTransaction)); + assertSame(originalTargetTransaction, + originalSourceTransaction.getCrossEntry().getCrossTransaction(originalSourceTransaction)); + assertThat(duplicateSourceTransaction, not(originalSourceTransaction)); + assertThat(duplicateTargetTransaction, not(originalTargetTransaction)); + assertThat(duplicateSourceTransaction.getType(), is(AccountTransaction.Type.TRANSFER_OUT)); + assertThat(duplicateTargetTransaction.getType(), is(AccountTransaction.Type.TRANSFER_IN)); + assertThat(duplicateSourceTransaction.getDateTime(), is(DATE_TIME)); + assertThat(duplicateSourceTransaction.getNote(), is("note")); + assertThat(duplicateSourceTransaction.getSource(), nullValue()); + assertThat(duplicateSourceTransaction.getAmount(), is(AMOUNT)); + assertThat(duplicateTargetTransaction.getAmount(), is(AMOUNT)); + assertNoDuplicateProjectionUUIDs(fixture.client()); + assertRoundtrips(fixture.client(), originalSnapshot.entryUUID(), uuid(entry(duplicateSourceTransaction)), 2); + } + + /** + * Verifies that duplicating a ledger-backed portfolio transfer creates a fresh transfer entry. + * Source and target depot sides of the copy must be paired together and survive save/load. + */ + @Test + public void testPortfolioTransferDuplicateCreatesFreshLedgerTruthAndCrossEntry() throws Exception + { + var fixture = fixture(); + var original = new LedgerPortfolioTransferTransactionCreator(fixture.client()).create(fixture.portfolio(), + fixture.targetPortfolio(), fixture.security(), DATE_TIME, SHARES, AMOUNT, CurrencyUnit.EUR, + "note", "source"); + var originalSourceTransaction = original.getSourceTransaction(); + var originalTargetTransaction = original.getTargetTransaction(); + var originalSnapshot = EntrySnapshot.of(originalSourceTransaction); + + var model = new SecurityTransferModel(fixture.client()); + model.setExchangeRateProviderFactory(new ExchangeRateProviderFactory(fixture.client())); + model.presetFromSource(original); + model.applyChanges(); + + var duplicateSourceTransaction = onlyOther(fixture.portfolio().getTransactions(), originalSourceTransaction); + var duplicateTargetTransaction = onlyOther(fixture.targetPortfolio().getTransactions(), + originalTargetTransaction); + + assertPortfolioProjectionDuplicate(originalSourceTransaction, duplicateSourceTransaction, originalSnapshot, 2, 2); + assertThat(fixture.portfolio().getTransactions().size(), is(2)); + assertThat(fixture.targetPortfolio().getTransactions().size(), is(2)); + assertSame(duplicateTargetTransaction, + duplicateSourceTransaction.getCrossEntry().getCrossTransaction(duplicateSourceTransaction)); + assertSame(originalTargetTransaction, + originalSourceTransaction.getCrossEntry().getCrossTransaction(originalSourceTransaction)); + assertThat(duplicateSourceTransaction, not(originalSourceTransaction)); + assertThat(duplicateTargetTransaction, not(originalTargetTransaction)); + assertThat(duplicateSourceTransaction.getType(), is(PortfolioTransaction.Type.TRANSFER_OUT)); + assertThat(duplicateTargetTransaction.getType(), is(PortfolioTransaction.Type.TRANSFER_IN)); + assertThat(duplicateSourceTransaction.getDateTime(), is(DATE_TIME)); + assertThat(duplicateSourceTransaction.getNote(), is("note")); + assertThat(duplicateSourceTransaction.getSource(), nullValue()); + assertSame(fixture.security(), duplicateSourceTransaction.getSecurity()); + assertThat(duplicateSourceTransaction.getShares(), is(SHARES)); + assertThat(duplicateTargetTransaction.getShares(), is(SHARES)); + assertThat(duplicateSourceTransaction.getAmount(), is(AMOUNT)); + assertThat(duplicateTargetTransaction.getAmount(), is(AMOUNT)); + assertNoDuplicateProjectionUUIDs(fixture.client()); + assertRoundtrips(fixture.client(), originalSnapshot.entryUUID(), uuid(entry(duplicateSourceTransaction)), 2); + } + + private void assertAccountProjectionDuplicate(AccountTransaction original, AccountTransaction duplicate, + EntrySnapshot originalSnapshot, int expectedPostings, int expectedProjectionRefs) + { + assertThat(duplicate.getClass().getName().contains("LedgerBacked"), is(true)); + assertThat(original.getUUID(), not(duplicate.getUUID())); + assertFreshLedgerIdentity(originalSnapshot, entry(duplicate), expectedPostings, expectedProjectionRefs); + } + + private void assertPortfolioProjectionDuplicate(PortfolioTransaction original, PortfolioTransaction duplicate, + EntrySnapshot originalSnapshot, int expectedPostings, int expectedProjectionRefs) + { + assertThat(duplicate.getClass().getName().contains("LedgerBacked"), is(true)); + assertThat(original.getUUID(), not(duplicate.getUUID())); + assertFreshLedgerIdentity(originalSnapshot, entry(duplicate), expectedPostings, expectedProjectionRefs); + } + + private void assertFreshLedgerIdentity(EntrySnapshot originalSnapshot, Object duplicateEntry, + int expectedPostings, int expectedProjectionRefs) + { + assertThat(uuid(duplicateEntry), not(originalSnapshot.entryUUID())); + assertThat(postings(duplicateEntry).size(), is(expectedPostings)); + assertThat(projections(duplicateEntry).size(), is(expectedProjectionRefs)); + assertTrue(originalSnapshot.postingUUIDs().stream() + .noneMatch(uuid -> postings(duplicateEntry).stream().anyMatch(p -> uuid.equals(uuid(p))))); + assertTrue(originalSnapshot.projectionUUIDs().stream() + .noneMatch(uuid -> projections(duplicateEntry).stream().anyMatch(p -> uuid.equals(uuid(p))))); + } + + private void assertRoundtrips(Client client, String originalEntryUUID, String duplicateEntryUUID, + int expectedLedgerEntries) throws Exception + { + assertValid(client); + assertRoundtrip(loadXml(saveXml(client)), originalEntryUUID, duplicateEntryUUID, expectedLedgerEntries); + assertRoundtrip(loadProtobuf(saveProtobuf(client)), originalEntryUUID, duplicateEntryUUID, + expectedLedgerEntries); + } + + private void assertRoundtrip(Client loaded, String originalEntryUUID, String duplicateEntryUUID, + int expectedLedgerEntries) + { + assertValid(loaded); + assertThat(entries(loaded).size(), is(expectedLedgerEntries)); + assertTrue(entries(loaded).stream().anyMatch(e -> originalEntryUUID.equals(uuid(e)))); + assertTrue(entries(loaded).stream().anyMatch(e -> duplicateEntryUUID.equals(uuid(e)))); + assertNoDuplicateProjectionUUIDs(loaded); + assertFalse(originalEntryUUID.equals(duplicateEntryUUID)); + } + + private void assertNoDuplicateProjectionUUIDs(Client client) + { + var projectionUUIDs = entries(client).stream().flatMap(e -> projections(e).stream()).map(this::uuid).toList(); + assertThat(new HashSet<>(projectionUUIDs).size(), is(projectionUUIDs.size())); + } + + private void assertValid(Client client) + { + try + { + var ledger = Client.class.getMethod("getLedger").invoke(client); + Class validator = Class.forName("name.abuchen.portfolio.model.ledger.LedgerStructuralValidator", true, + Client.class.getClassLoader()); + Method validate = validator.getMethod("validate", ledger.getClass()); + Object result = validate.invoke(null, ledger); + + if (!((Boolean) result.getClass().getMethod("isOK").invoke(result))) + throw new AssertionError(result.getClass().getMethod("format").invoke(result)); + } + catch (ReflectiveOperationException e) + { + throw new AssertionError(e); + } + } + + private Object entry(Transaction transaction) + { + try + { + return transaction.getClass().getMethod("getLedgerEntry").invoke(transaction); + } + catch (ReflectiveOperationException e) + { + throw new AssertionError(e); + } + } + + @SuppressWarnings("unchecked") + private List entries(Client client) + { + try + { + var ledger = Client.class.getMethod("getLedger").invoke(client); + return (List) ledger.getClass().getMethod("getEntries").invoke(ledger); + } + catch (ReflectiveOperationException e) + { + throw new AssertionError(e); + } + } + + @SuppressWarnings("unchecked") + private List postings(Object entry) + { + try + { + return (List) entry.getClass().getMethod("getPostings").invoke(entry); + } + catch (ReflectiveOperationException e) + { + throw new AssertionError(e); + } + } + + @SuppressWarnings("unchecked") + private List projections(Object entry) + { + try + { + return (List) entry.getClass().getMethod("getProjectionRefs").invoke(entry); + } + catch (ReflectiveOperationException e) + { + throw new AssertionError(e); + } + } + + private String uuid(Object ledgerObject) + { + try + { + return (String) ledgerObject.getClass().getMethod("getUUID").invoke(ledgerObject); + } + catch (ReflectiveOperationException e) + { + throw new AssertionError(e); + } + } + + private T onlyOther(List transactions, T original) + { + return transactions.stream().filter(transaction -> !transaction.getUUID().equals(original.getUUID())) + .findFirst().orElseThrow(); + } + + private String saveXml(Client client) throws Exception + { + var file = File.createTempFile("ledger-duplicate-copy", ".xml"); + + try + { + ClientFactory.save(client, file); + return Files.readString(file.toPath(), StandardCharsets.UTF_8); + } + finally + { + Files.deleteIfExists(file.toPath()); + } + } + + private Client loadXml(String xml) throws Exception + { + return ClientFactory.load(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); + } + + private byte[] saveProtobuf(Client client) throws Exception + { + var file = File.createTempFile("ledger-duplicate-copy", ".portfolio"); + + try + { + ClientFactory.saveAs(client, file, null, EnumSet.of(SaveFlag.BINARY, SaveFlag.COMPRESSED)); + return Files.readAllBytes(file.toPath()); + } + finally + { + Files.deleteIfExists(file.toPath()); + } + } + + private Client loadProtobuf(byte[] bytes) throws Exception + { + var file = File.createTempFile("ledger-duplicate-copy", ".portfolio"); + + try + { + Files.write(file.toPath(), bytes); + return ClientFactory.load(file, null, new NullProgressMonitor()); + } + finally + { + Files.deleteIfExists(file.toPath()); + } + } + + private Fixture fixture() + { + var client = new Client(); + client.setBaseCurrency(CurrencyUnit.EUR); + + var account = account("Account"); + var targetAccount = account("Target Account"); + var portfolio = portfolio("Portfolio", account); + var targetPortfolio = portfolio("Target Portfolio", account); + var security = new Security("Security", CurrencyUnit.EUR); + + account.setUpdatedAt(Instant.now()); + targetAccount.setUpdatedAt(Instant.now()); + portfolio.setUpdatedAt(Instant.now()); + targetPortfolio.setUpdatedAt(Instant.now()); + security.setUpdatedAt(Instant.now()); + + client.addAccount(account); + client.addAccount(targetAccount); + client.addPortfolio(portfolio); + client.addPortfolio(targetPortfolio); + client.addSecurity(security); + + return new Fixture(client, account, targetAccount, portfolio, targetPortfolio, security); + } + + private Account account(String name) + { + var account = new Account(name); + account.setCurrencyCode(CurrencyUnit.EUR); + return account; + } + + private Portfolio portfolio(String name, Account referenceAccount) + { + var portfolio = new Portfolio(name); + portfolio.setReferenceAccount(referenceAccount); + return portfolio; + } + + private record Fixture(Client client, Account account, Account targetAccount, Portfolio portfolio, + Portfolio targetPortfolio, Security security) + { + } + + private record EntrySnapshot(String entryUUID, List postingUUIDs, List projectionUUIDs) + { + static EntrySnapshot of(AccountTransaction transaction) + { + return of(new LedgerTransactionDuplicateCopyParityTest().entry(transaction)); + } + + static EntrySnapshot of(PortfolioTransaction transaction) + { + return of(new LedgerTransactionDuplicateCopyParityTest().entry(transaction)); + } + + private static EntrySnapshot of(Object entry) + { + var test = new LedgerTransactionDuplicateCopyParityTest(); + return new EntrySnapshot(test.uuid(entry), test.postings(entry).stream().map(test::uuid).toList(), + test.projections(entry).stream().map(test::uuid).toList()); + } + } +} diff --git a/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/SecurityDeliveryModelTest.java b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/SecurityDeliveryModelTest.java new file mode 100644 index 0000000000..81dc536f26 --- /dev/null +++ b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/SecurityDeliveryModelTest.java @@ -0,0 +1,196 @@ +package name.abuchen.portfolio.ui.dialogs.transactions; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.lang.reflect.InvocationTargetException; +import java.math.BigDecimal; +import java.time.LocalDate; + +import org.junit.Test; + +import name.abuchen.portfolio.model.Account; +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.model.TransactionPair; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.ExchangeRateProviderFactory; +import name.abuchen.portfolio.money.Values; + +@SuppressWarnings("nls") +public class SecurityDeliveryModelTest +{ + @Test + public void testNewDeliveryInboundCreatesLedgerBackedProjection() throws ReflectiveOperationException + { + var fixture = fixture(CurrencyUnit.USD); + var model = model(fixture, PortfolioTransaction.Type.DELIVERY_INBOUND); + + applyDeliveryValues(model); + model.applyChanges(); + + var transaction = fixture.portfolio().getTransactions().get(0); + + assertThat(ledgerEntryCount(fixture.client()), is(1)); + assertThat(transaction.getClass().getName(), containsString("LedgerBacked")); + assertThat(transaction.getType(), is(PortfolioTransaction.Type.DELIVERY_INBOUND)); + assertSame(fixture.security(), transaction.getSecurity()); + assertThat(transaction.getShares(), is(Values.Share.factorize(12))); + assertThat(transaction.getAmount(), is(Values.Amount.factorize(260))); + assertThat(transaction.getCurrencyCode(), is(CurrencyUnit.EUR)); + assertThat(transaction.getNote(), is("note")); + assertThat(transaction.getUnits().count(), is(3L)); + assertThat(fixture.client().getAllTransactions().size(), is(1)); + } + + @Test + public void testNewDeliveryOutboundCreatesLedgerBackedProjection() throws ReflectiveOperationException + { + var fixture = fixture(CurrencyUnit.USD); + var model = model(fixture, PortfolioTransaction.Type.DELIVERY_OUTBOUND); + + applyDeliveryValues(model); + model.applyChanges(); + + var transaction = fixture.portfolio().getTransactions().get(0); + + assertThat(ledgerEntryCount(fixture.client()), is(1)); + assertThat(transaction.getClass().getName(), containsString("LedgerBacked")); + assertThat(transaction.getType(), is(PortfolioTransaction.Type.DELIVERY_OUTBOUND)); + assertSame(fixture.security(), transaction.getSecurity()); + assertThat(transaction.getShares(), is(Values.Share.factorize(12))); + assertThat(transaction.getAmount(), is(Values.Amount.factorize(140))); + assertThat(transaction.getCurrencyCode(), is(CurrencyUnit.EUR)); + assertThat(transaction.getUnits().count(), is(3L)); + assertThat(fixture.client().getAllTransactions().size(), is(1)); + } + + @Test + public void testExistingLedgerBackedDeliveryEditsThroughLedgerAndMovesOwner() + throws ReflectiveOperationException + { + var fixture = fixture(CurrencyUnit.USD); + var createModel = model(fixture, PortfolioTransaction.Type.DELIVERY_INBOUND); + applyDeliveryValues(createModel); + createModel.applyChanges(); + var transaction = fixture.portfolio().getTransactions().get(0); + + var editModel = model(fixture, PortfolioTransaction.Type.DELIVERY_INBOUND); + editModel.setSource(new TransactionPair<>(fixture.portfolio(), transaction)); + editModel.setShares(Values.Share.factorize(20)); + editModel.setGrossValue(Values.Amount.factorize(150)); + editModel.setForexTaxes(Values.Amount.factorize(5)); + editModel.setNote("updated"); + editModel.applyChanges(); + + assertThat(ledgerEntryCount(fixture.client()), is(1)); + assertThat(fixture.portfolio().getTransactions(), is(java.util.List.of(transaction))); + assertThat(transaction.getAmount(), is(Values.Amount.factorize(330))); + assertThat(transaction.getShares(), is(Values.Share.factorize(20))); + assertThat(transaction.getNote(), is("updated")); + assertTrue(transaction.getUnits().anyMatch(unit -> unit.getType() == Transaction.Unit.Type.TAX + && unit.getForex() != null + && unit.getForex().getAmount() == Values.Amount.factorize(5))); + assertThat(fixture.client().getAllTransactions().size(), is(1)); + + var otherPortfolio = portfolio(fixture.account()); + fixture.client().addPortfolio(otherPortfolio); + var moveModel = model(fixture, PortfolioTransaction.Type.DELIVERY_INBOUND); + moveModel.setSource(new TransactionPair<>(fixture.portfolio(), transaction)); + moveModel.setPortfolio(otherPortfolio); + moveModel.applyChanges(); + + assertTrue(fixture.portfolio().getTransactions().isEmpty()); + assertThat(otherPortfolio.getTransactions().size(), is(1)); + assertThat(otherPortfolio.getTransactions().get(0).getUUID(), is(transaction.getUUID())); + assertThat(otherPortfolio.getTransactions().get(0).getShares(), is(Values.Share.factorize(20))); + assertThat(fixture.client().getAllTransactions().size(), is(1)); + } + + @Test + public void testUnsupportedFamiliesAreNotAcceptedByDeliveryModel() throws ReflectiveOperationException + { + var fixture = fixture(CurrencyUnit.EUR); + + assertThrows(IllegalArgumentException.class, + () -> new SecurityDeliveryModel(fixture.client(), PortfolioTransaction.Type.BUY)); + assertThrows(IllegalArgumentException.class, + () -> new SecurityDeliveryModel(fixture.client(), PortfolioTransaction.Type.SELL)); + assertThrows(IllegalArgumentException.class, + () -> new SecurityDeliveryModel(fixture.client(), PortfolioTransaction.Type.TRANSFER_IN)); + assertThrows(IllegalArgumentException.class, + () -> new SecurityDeliveryModel(fixture.client(), PortfolioTransaction.Type.TRANSFER_OUT)); + assertThat(ledgerEntryCount(fixture.client()), is(0)); + assertTrue(fixture.portfolio().getTransactions().isEmpty()); + } + + private int ledgerEntryCount(Client client) throws ReflectiveOperationException + { + try + { + var ledger = Client.class.getMethod("getLedger").invoke(client); + var entries = ledger.getClass().getMethod("getEntries").invoke(ledger); + return ((java.util.List) entries).size(); + } + catch (InvocationTargetException e) + { + throw new AssertionError(e.getCause()); + } + } + + private SecurityDeliveryModel model(Fixture fixture, PortfolioTransaction.Type type) + { + var model = new SecurityDeliveryModel(fixture.client(), type); + + model.setExchangeRateProviderFactory(new ExchangeRateProviderFactory(fixture.client())); + model.setPortfolio(fixture.portfolio()); + model.setSecurity(fixture.security()); + model.setDate(LocalDate.of(2026, 6, 7)); + + return model; + } + + private void applyDeliveryValues(SecurityDeliveryModel model) + { + model.setExchangeRate(BigDecimal.valueOf(2)); + model.setShares(Values.Share.factorize(12)); + model.setGrossValue(Values.Amount.factorize(100)); + model.setForexFees(Values.Amount.factorize(10)); + model.setForexTaxes(Values.Amount.factorize(20)); + model.setNote("note"); + } + + private Fixture fixture(String securityCurrency) + { + var client = new Client(); + client.setBaseCurrency(CurrencyUnit.EUR); + var account = new Account("Account"); + account.setCurrencyCode(CurrencyUnit.EUR); + var portfolio = portfolio(account); + var security = new Security("Security", securityCurrency); + + client.addAccount(account); + client.addPortfolio(portfolio); + client.addSecurity(security); + + return new Fixture(client, account, portfolio, security); + } + + private Portfolio portfolio(Account account) + { + var portfolio = new Portfolio("Portfolio"); + portfolio.setReferenceAccount(account); + return portfolio; + } + + private record Fixture(Client client, Account account, Portfolio portfolio, Security security) + { + } +} diff --git a/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/SecurityTransferModelTest.java b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/SecurityTransferModelTest.java new file mode 100644 index 0000000000..2bbd424f5e --- /dev/null +++ b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/SecurityTransferModelTest.java @@ -0,0 +1,184 @@ +package name.abuchen.portfolio.ui.dialogs.transactions; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.lang.reflect.InvocationTargetException; +import java.time.LocalDate; +import java.util.List; + +import org.junit.Test; + +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.ledger.compatibility.LedgerPortfolioTransferTransactionCreator; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.ExchangeRateProviderFactory; +import name.abuchen.portfolio.money.Values; + +@SuppressWarnings("nls") +public class SecurityTransferModelTest +{ + @Test + public void testNewPortfolioTransferCreatesLedgerBackedProjections() throws ReflectiveOperationException + { + var fixture = fixture(); + var model = model(fixture); + + model.setShares(Values.Share.factorize(5)); + model.setAmount(Values.Amount.factorize(123)); + model.setNote("note"); + model.applyChanges(); + + var sourceTransaction = fixture.source().getTransactions().get(0); + var targetTransaction = fixture.target().getTransactions().get(0); + + assertThat(ledgerEntryCount(fixture.client()), is(1)); + assertThat(sourceTransaction.getClass().getName(), containsString("LedgerBacked")); + assertThat(targetTransaction.getClass().getName(), containsString("LedgerBacked")); + assertThat(sourceTransaction.getType(), is(PortfolioTransaction.Type.TRANSFER_OUT)); + assertThat(targetTransaction.getType(), is(PortfolioTransaction.Type.TRANSFER_IN)); + assertSame(fixture.security(), sourceTransaction.getSecurity()); + assertSame(fixture.security(), targetTransaction.getSecurity()); + assertThat(sourceTransaction.getShares(), is(Values.Share.factorize(5))); + assertThat(targetTransaction.getShares(), is(Values.Share.factorize(5))); + assertThat(sourceTransaction.getAmount(), is(Values.Amount.factorize(123))); + assertThat(targetTransaction.getAmount(), is(Values.Amount.factorize(123))); + assertThat(sourceTransaction.getCurrencyCode(), is(CurrencyUnit.EUR)); + assertThat(targetTransaction.getCurrencyCode(), is(CurrencyUnit.EUR)); + assertThat(sourceTransaction.getNote(), is("note")); + assertSame(targetTransaction, sourceTransaction.getCrossEntry().getCrossTransaction(sourceTransaction)); + assertThat(fixture.client().getAllTransactions().size(), is(1)); + } + + @Test + public void testExistingLedgerBackedPortfolioTransferEditsThroughLedgerAndMovesOwner() + throws ReflectiveOperationException + { + var fixture = fixture(); + var creator = new LedgerPortfolioTransferTransactionCreator(fixture.client()); + var transfer = creator.create(fixture.source(), fixture.target(), fixture.security(), java.time.LocalDateTime + .of(2026, 6, 7, 8, 9), Values.Share.factorize(5), Values.Amount.factorize(123), + CurrencyUnit.EUR, "note", "source"); + var sourceTransaction = transfer.getSourceTransaction(); + var targetTransaction = transfer.getTargetTransaction(); + + var editModel = editModel(fixture); + editModel.setSource(transfer); + editModel.setShares(Values.Share.factorize(7)); + editModel.setAmount(Values.Amount.factorize(456)); + editModel.setNote("updated"); + editModel.applyChanges(); + + assertThat(ledgerEntryCount(fixture.client()), is(1)); + assertThat(fixture.source().getTransactions(), is(List.of(sourceTransaction))); + assertThat(fixture.target().getTransactions(), is(List.of(targetTransaction))); + assertThat(sourceTransaction.getShares(), is(Values.Share.factorize(7))); + assertThat(targetTransaction.getShares(), is(Values.Share.factorize(7))); + assertThat(sourceTransaction.getAmount(), is(Values.Amount.factorize(456))); + assertThat(targetTransaction.getAmount(), is(Values.Amount.factorize(456))); + assertThat(sourceTransaction.getNote(), is("updated")); + assertThat(fixture.client().getAllTransactions().size(), is(1)); + + var otherPortfolio = new Portfolio("Other"); + fixture.client().addPortfolio(otherPortfolio); + var moveModel = editModel(fixture); + moveModel.setSource(transfer); + moveModel.setSourcePortfolio(otherPortfolio); + moveModel.applyChanges(); + + assertTrue(fixture.source().getTransactions().isEmpty()); + assertThat(otherPortfolio.getTransactions().size(), is(1)); + assertThat(otherPortfolio.getTransactions().get(0).getUUID(), is(sourceTransaction.getUUID())); + assertThat(fixture.target().getTransactions().size(), is(1)); + assertThat(fixture.target().getTransactions().get(0).getUUID(), is(targetTransaction.getUUID())); + assertSame(fixture.target().getTransactions().get(0), + otherPortfolio.getTransactions().get(0).getCrossEntry() + .getCrossTransaction(otherPortfolio.getTransactions().get(0))); + assertThat(fixture.client().getAllTransactions().size(), is(1)); + } + + @Test + public void testExistingLedgerBackedPortfolioTransferMovesSourceAndTargetOwners() + throws ReflectiveOperationException + { + var fixture = fixture(); + var transfer = new LedgerPortfolioTransferTransactionCreator(fixture.client()).create(fixture.source(), + fixture.target(), fixture.security(), java.time.LocalDateTime.of(2026, 6, 7, 8, 9), + Values.Share.factorize(5), Values.Amount.factorize(123), CurrencyUnit.EUR, "note", "source"); + var swapModel = editModel(fixture); + swapModel.setSource(transfer); + swapModel.setSourcePortfolio(fixture.target()); + swapModel.setTargetPortfolio(fixture.source()); + swapModel.applyChanges(); + + assertThat(ledgerEntryCount(fixture.client()), is(1)); + assertThat(fixture.source().getTransactions().size(), is(1)); + assertThat(fixture.target().getTransactions().size(), is(1)); + assertThat(fixture.source().getTransactions().get(0).getType(), is(PortfolioTransaction.Type.TRANSFER_IN)); + assertThat(fixture.target().getTransactions().get(0).getType(), is(PortfolioTransaction.Type.TRANSFER_OUT)); + assertSame(fixture.source().getTransactions().get(0), + fixture.target().getTransactions().get(0).getCrossEntry() + .getCrossTransaction(fixture.target().getTransactions().get(0))); + assertThat(fixture.client().getAllTransactions().size(), is(1)); + } + + private SecurityTransferModel model(Fixture fixture) + { + var model = new SecurityTransferModel(fixture.client()); + + model.setExchangeRateProviderFactory(new ExchangeRateProviderFactory(fixture.client())); + model.setSourcePortfolio(fixture.source()); + model.setTargetPortfolio(fixture.target()); + model.setSecurity(fixture.security()); + model.setDate(LocalDate.of(2026, 6, 7)); + + return model; + } + + private SecurityTransferModel editModel(Fixture fixture) + { + var model = new SecurityTransferModel(fixture.client()); + + model.setExchangeRateProviderFactory(new ExchangeRateProviderFactory(fixture.client())); + + return model; + } + + private int ledgerEntryCount(Client client) throws ReflectiveOperationException + { + try + { + var ledger = Client.class.getMethod("getLedger").invoke(client); + var entries = ledger.getClass().getMethod("getEntries").invoke(ledger); + return ((java.util.List) entries).size(); + } + catch (InvocationTargetException e) + { + throw new AssertionError(e.getCause()); + } + } + + private Fixture fixture() + { + var client = new Client(); + var source = new Portfolio("Source"); + var target = new Portfolio("Target"); + var security = new Security("Security", CurrencyUnit.EUR); + + client.addPortfolio(source); + client.addPortfolio(target); + client.addSecurity(security); + + return new Fixture(client, source, target, security); + } + + private record Fixture(Client client, Portfolio source, Portfolio target, Security security) + { + } +} diff --git a/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/util/viewers/ExDateEditingSupportTest.java b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/util/viewers/ExDateEditingSupportTest.java index eda33edc89..714d93fb4b 100644 --- a/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/util/viewers/ExDateEditingSupportTest.java +++ b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/util/viewers/ExDateEditingSupportTest.java @@ -15,9 +15,17 @@ import name.abuchen.portfolio.model.AccountTransaction; import name.abuchen.portfolio.model.TransactionPair; +/** + * Tests inline ex-date value handling used by legacy and ledger-aware update paths. + * These tests make sure clearing and parsing ex-date values produces the expected model value. + */ @SuppressWarnings("nls") public class ExDateEditingSupportTest { + /** + * Verifies that a blank ex-date cell clears the field and notifies listeners with a null value. + * Ledger-aware callers rely on the same UI signal to clear the persisted ex-date fact safely. + */ @Test public void blankValueClearsExDateAndNotifiesNullAsNewValue() { @@ -48,6 +56,10 @@ public void blankValueClearsExDateAndNotifiesNullAsNewValue() assertThat(support.getValue(element), is("")); } + /** + * Verifies that a date-only ex-date input is stored at the start of that day. + * The inline editor must provide the same value shape to legacy and ledger-aware update paths. + */ @Test public void parsedLocalDateIsStoredAsLocalDateTimeAtStartOfDay() { diff --git a/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/views/LedgerTransactionRoutingTest.java b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/views/LedgerTransactionRoutingTest.java new file mode 100644 index 0000000000..3f2902b419 --- /dev/null +++ b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/views/LedgerTransactionRoutingTest.java @@ -0,0 +1,1873 @@ +package name.abuchen.portfolio.ui.views; + +import static org.hamcrest.CoreMatchers.instanceOf; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.text.MessageFormat; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; + +import org.eclipse.core.runtime.NullProgressMonitor; +import org.eclipse.jface.action.Action; +import org.junit.Test; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.AccountTransferEntry; +import name.abuchen.portfolio.model.BuySellEntry; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.ClientFactory; +import name.abuchen.portfolio.model.InvestmentPlan; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.PortfolioTransaction; +import name.abuchen.portfolio.model.PortfolioTransferEntry; +import name.abuchen.portfolio.model.SaveFlag; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.Transaction; +import name.abuchen.portfolio.model.TransactionPair; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountOnlyTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountTransferTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerBuySellTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerDeliveryTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerDividendTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerInlineEditingField; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferTransactionCreator; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Values; +import name.abuchen.portfolio.snapshot.filter.PortfolioClientFilter; +import name.abuchen.portfolio.ui.Messages; +import name.abuchen.portfolio.ui.dialogs.transactions.AccountTransactionDialog; +import name.abuchen.portfolio.ui.dialogs.transactions.AccountTransferDialog; +import name.abuchen.portfolio.ui.dialogs.transactions.OpenDialogAction; +import name.abuchen.portfolio.ui.dialogs.transactions.SecurityTransactionDialog; +import name.abuchen.portfolio.ui.dialogs.transactions.SecurityTransferDialog; +import name.abuchen.portfolio.ui.util.viewers.DateTimeEditingSupport; +import name.abuchen.portfolio.ui.util.viewers.ExDateEditingSupport; +import name.abuchen.portfolio.ui.util.viewers.StringEditingSupport; +import name.abuchen.portfolio.ui.util.viewers.TransactionOwnerListEditingSupport; +import name.abuchen.portfolio.ui.util.viewers.TransactionTypeEditingSupport; +import name.abuchen.portfolio.ui.util.viewers.ValueEditingSupport; +import name.abuchen.portfolio.ui.views.actions.ConvertBuySellToDeliveryAction; +import name.abuchen.portfolio.ui.views.actions.ConvertDeliveryToBuySellAction; + +/** + * Tests UI routing for ledger-backed dialogs, actions, inline editing, and owner changes. + * These tests make sure restored UI paths use ledger-aware editors while unsupported structural edits stay blocked. + */ +@SuppressWarnings("nls") +public class LedgerTransactionRoutingTest +{ + private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 6, 7, 8, 9); + private static final LocalDateTime UPDATED_DATE_TIME = LocalDateTime.of(2026, 6, 8, 10, 11); + private static final Instant FIXTURE_UPDATED_AT = Instant.parse("2026-06-07T08:09:00Z"); + + /** + * Verifies that account-transaction dialogs route ledger-backed rows through ledger-aware models. + * Supported account bookings must stay editable without writing directly to runtime projections. + */ + @Test + public void testSupportedLedgerBackedAccountTransactionDialogRoutes() throws Exception + { + assertLedgerAccountOnlyDialogRoute(AccountTransaction.Type.DEPOSIT); + assertLedgerAccountOnlyDialogRoute(AccountTransaction.Type.REMOVAL); + assertLedgerAccountOnlyDialogRoute(AccountTransaction.Type.INTEREST); + assertLedgerAccountOnlyDialogRoute(AccountTransaction.Type.INTEREST_CHARGE); + assertLedgerAccountOnlyDialogRoute(AccountTransaction.Type.FEES); + assertLedgerAccountOnlyDialogRoute(AccountTransaction.Type.FEES_REFUND); + assertLedgerAccountOnlyDialogRoute(AccountTransaction.Type.TAXES); + assertLedgerAccountOnlyDialogRoute(AccountTransaction.Type.TAX_REFUND); + assertLedgerDividendDialogRoute(); + } + + /** + * Verifies that account-side rows of ledger-backed cross entries open the supported edit routes. + * The UI must use the same ledger-safe dialog paths as normal actions. + */ + @Test + public void testSupportedLedgerBackedAccountSideCrossEntryDialogRoutes() throws Exception + { + var buy = ledgerBuySellFixture(PortfolioTransaction.Type.BUY); + assertSupportedAccountEditRoute(buy.client(), buy.account(), buy.account().getTransactions().get(0), + SecurityTransactionDialog.class, PortfolioTransaction.Type.BUY); + + var sell = ledgerBuySellFixture(PortfolioTransaction.Type.SELL); + assertSupportedAccountEditRoute(sell.client(), sell.account(), sell.account().getTransactions().get(0), + SecurityTransactionDialog.class, PortfolioTransaction.Type.SELL); + + var transfer = ledgerAccountTransferFixture(); + assertSupportedAccountEditRoute(transfer.client(), transfer.source(), transfer.source().getTransactions().get(0), + AccountTransferDialog.class); + assertSupportedAccountEditRoute(transfer.client(), transfer.target(), transfer.target().getTransactions().get(0), + AccountTransferDialog.class); + } + + /** + * Verifies that portfolio-side ledger-backed rows open only the supported transaction dialogs. + * Unsupported portfolio shapes must not fall back to legacy setter mutation. + */ + @Test + public void testSupportedLedgerBackedPortfolioTransactionDialogRoutes() throws Exception + { + var buy = ledgerBuySellFixture(PortfolioTransaction.Type.BUY); + assertSupportedPortfolioEditRoute(buy.client(), buy.portfolio(), buy.portfolio().getTransactions().get(0), + SecurityTransactionDialog.class, PortfolioTransaction.Type.BUY); + + var sell = ledgerBuySellFixture(PortfolioTransaction.Type.SELL); + assertSupportedPortfolioEditRoute(sell.client(), sell.portfolio(), sell.portfolio().getTransactions().get(0), + SecurityTransactionDialog.class, PortfolioTransaction.Type.SELL); + + var inbound = ledgerDeliveryFixture(PortfolioTransaction.Type.DELIVERY_INBOUND); + assertSupportedPortfolioEditRoute(inbound.client(), inbound.portfolio(), + inbound.portfolio().getTransactions().get(0), SecurityTransactionDialog.class, + PortfolioTransaction.Type.DELIVERY_INBOUND); + + var outbound = ledgerDeliveryFixture(PortfolioTransaction.Type.DELIVERY_OUTBOUND); + assertSupportedPortfolioEditRoute(outbound.client(), outbound.portfolio(), + outbound.portfolio().getTransactions().get(0), SecurityTransactionDialog.class, + PortfolioTransaction.Type.DELIVERY_OUTBOUND); + + var transfer = ledgerPortfolioTransferFixture(); + assertSupportedPortfolioEditRoute(transfer.client(), transfer.source(), + transfer.source().getTransactions().get(0), SecurityTransferDialog.class); + assertSupportedPortfolioEditRoute(transfer.client(), transfer.target(), + transfer.target().getTransactions().get(0), SecurityTransferDialog.class); + } + + /** + * Verifies that context-menu conversion actions are offered for supported ledger-backed rows. + * The action availability must match the ledger converters that can preserve booking truth. + */ + @Test + public void testSupportedLedgerBackedContextMenuConversionActions() + { + var buy = ledgerBuySellFixture(PortfolioTransaction.Type.BUY); + assertSupportedBuySellToDeliveryContextMenuRoute(buy.portfolio(), buy.portfolio().getTransactions().get(0)); + + var sell = ledgerBuySellFixture(PortfolioTransaction.Type.SELL); + assertSupportedBuySellToDeliveryContextMenuRoute(sell.portfolio(), sell.portfolio().getTransactions().get(0)); + + var inbound = ledgerDeliveryFixture(PortfolioTransaction.Type.DELIVERY_INBOUND); + assertSupportedDeliveryToBuySellContextMenuRoute(inbound.portfolio(), + inbound.portfolio().getTransactions().get(0)); + + var outbound = ledgerDeliveryFixture(PortfolioTransaction.Type.DELIVERY_OUTBOUND); + assertSupportedDeliveryToBuySellContextMenuRoute(outbound.portfolio(), + outbound.portfolio().getTransactions().get(0)); + } + + /** + * Verifies that supported context-menu conversions execute through ledger converters. + * The converted booking must keep a consistent ledger entry, projections, and owner lists. + */ + @Test + public void testSupportedLedgerBackedContextMenuConversionActionsExecute() throws Exception + { + var buy = ledgerBuySellFixture(PortfolioTransaction.Type.BUY); + var buyPortfolioTransaction = buy.portfolio().getTransactions().get(0); + var buyProjectionUUID = buyPortfolioTransaction.getUUID(); + + new ConvertBuySellToDeliveryAction(buy.client(), new TransactionPair<>(buy.portfolio(), buyPortfolioTransaction)) + .run(); + + assertThat(buy.account().getTransactions().isEmpty(), is(true)); + assertThat(buy.portfolio().getTransactions().size(), is(1)); + assertThat(buy.portfolio().getTransactions().get(0).getUUID(), is(buyProjectionUUID)); + assertThat(buy.portfolio().getTransactions().get(0).getType(), + is(PortfolioTransaction.Type.DELIVERY_INBOUND)); + assertThat(buy.portfolio().getTransactions().get(0).getCrossEntry(), is(nullValue())); + assertLedgerStructurallyValid(buy.client()); + + var inbound = ledgerDeliveryFixture(PortfolioTransaction.Type.DELIVERY_INBOUND); + var inboundPortfolioTransaction = inbound.portfolio().getTransactions().get(0); + var inboundProjectionUUID = inboundPortfolioTransaction.getUUID(); + + new ConvertDeliveryToBuySellAction(inbound.client(), + new TransactionPair<>(inbound.portfolio(), inboundPortfolioTransaction)).run(); + + assertThat(inbound.account().getTransactions().size(), is(1)); + assertThat(inbound.portfolio().getTransactions().size(), is(1)); + assertThat(inbound.portfolio().getTransactions().get(0).getUUID(), is(inboundProjectionUUID)); + assertThat(inbound.portfolio().getTransactions().get(0).getType(), is(PortfolioTransaction.Type.BUY)); + assertThat(inbound.account().getTransactions().get(0).getType(), is(AccountTransaction.Type.BUY)); + assertSame(inbound.portfolio().getTransactions().get(0), inbound.account().getTransactions().get(0) + .getCrossEntry().getCrossTransaction(inbound.account().getTransactions().get(0))); + assertLedgerStructurallyValid(inbound.client()); + } + + /** + * Verifies that unsupported ledger-backed action routes stay blocked in the UI. + * Actions without a safe ledger path must not become legacy repairs or projection writes. + */ + @Test + public void testForbiddenLedgerBackedActionRoutes() + { + var transfer = ledgerPortfolioTransferFixture(); + var transferPair = new TransactionPair<>(transfer.source(), transfer.source().getTransactions().get(0)); + + assertThat(TransactionContextMenu.supportsBuySellToDeliveryAction(List.of(transferPair)), is(false)); + assertThat(TransactionContextMenu.supportsDeliveryToBuySellAction(List.of(transferPair)), is(false)); + + var buySell = ledgerBuySellFixture(PortfolioTransaction.Type.BUY); + var buyPair = new TransactionPair<>(buySell.portfolio(), buySell.portfolio().getTransactions().get(0)); + var delivery = ledgerDeliveryFixture(PortfolioTransaction.Type.DELIVERY_INBOUND); + var deliveryPair = new TransactionPair<>(delivery.portfolio(), delivery.portfolio().getTransactions().get(0)); + + assertThat(TransactionContextMenu.supportsBuySellToDeliveryAction(List.of(buyPair, deliveryPair)), is(false)); + assertThat(TransactionContextMenu.supportsDeliveryToBuySellAction(List.of(buyPair, deliveryPair)), is(false)); + } + + /** + * Verifies that legacy transaction dialog routing is unchanged by the ledger guards. + * Non-ledger rows must keep their existing UI behavior. + */ + @Test + public void testLegacyTransactionDialogRoutesRemainUnchanged() throws Exception + { + var accountOnly = legacyAccountOnlyFixture(AccountTransaction.Type.DEPOSIT); + assertSupportedAccountEditRoute(accountOnly.client(), accountOnly.source(), + accountOnly.source().getTransactions().get(0), AccountTransactionDialog.class, + AccountTransaction.Type.DEPOSIT); + + var buySell = legacyBuySellFixture(PortfolioTransaction.Type.BUY); + assertSupportedAccountEditRoute(buySell.client(), buySell.account(), buySell.entry().getAccountTransaction(), + SecurityTransactionDialog.class, PortfolioTransaction.Type.BUY); + assertSupportedPortfolioEditRoute(buySell.client(), buySell.portfolio(), buySell.entry().getPortfolioTransaction(), + SecurityTransactionDialog.class, PortfolioTransaction.Type.BUY); + + var accountTransfer = legacyAccountTransferFixture(); + assertSupportedAccountEditRoute(accountTransfer.client(), accountTransfer.source(), + accountTransfer.transfer().getSourceTransaction(), AccountTransferDialog.class); + + var delivery = legacyDeliveryFixture(PortfolioTransaction.Type.DELIVERY_INBOUND); + assertSupportedPortfolioEditRoute(delivery.client(), delivery.portfolio(), + delivery.portfolio().getTransactions().get(0), SecurityTransactionDialog.class, + PortfolioTransaction.Type.DELIVERY_INBOUND); + + var portfolioTransfer = legacyPortfolioTransferFixture(); + assertSupportedPortfolioEditRoute(portfolioTransfer.client(), portfolioTransfer.source(), + portfolioTransfer.transfer().getSourceTransaction(), SecurityTransferDialog.class); + } + + /** + * Verifies that inline type editing offers the supported portfolio master transitions. + * Buy/sell and delivery changes must route through the ledger converters. + */ + @Test + public void testSupportedLedgerBackedPortfolioInlineTypeConversions() throws Exception + { + assertInlinePortfolioBuySellReversal(PortfolioTransaction.Type.BUY, PortfolioTransaction.Type.SELL, + AccountTransaction.Type.SELL); + assertInlinePortfolioBuySellReversal(PortfolioTransaction.Type.SELL, PortfolioTransaction.Type.BUY, + AccountTransaction.Type.BUY); + + assertInlineBuySellToDelivery(PortfolioTransaction.Type.BUY, PortfolioTransaction.Type.DELIVERY_INBOUND); + assertInlineBuySellToDelivery(PortfolioTransaction.Type.SELL, PortfolioTransaction.Type.DELIVERY_OUTBOUND); + assertInlineBuySellToDelivery(PortfolioTransaction.Type.BUY, PortfolioTransaction.Type.DELIVERY_OUTBOUND); + assertInlineBuySellToDelivery(PortfolioTransaction.Type.SELL, PortfolioTransaction.Type.DELIVERY_INBOUND); + + assertInlineDeliveryReversal(PortfolioTransaction.Type.DELIVERY_INBOUND, + PortfolioTransaction.Type.DELIVERY_OUTBOUND); + assertInlineDeliveryReversal(PortfolioTransaction.Type.DELIVERY_OUTBOUND, + PortfolioTransaction.Type.DELIVERY_INBOUND); + + assertInlineDeliveryToBuySell(PortfolioTransaction.Type.DELIVERY_INBOUND, PortfolioTransaction.Type.BUY, + AccountTransaction.Type.BUY); + assertInlineDeliveryToBuySell(PortfolioTransaction.Type.DELIVERY_OUTBOUND, PortfolioTransaction.Type.SELL, + AccountTransaction.Type.SELL); + assertInlineDeliveryToBuySell(PortfolioTransaction.Type.DELIVERY_INBOUND, PortfolioTransaction.Type.SELL, + AccountTransaction.Type.SELL); + assertInlineDeliveryToBuySell(PortfolioTransaction.Type.DELIVERY_OUTBOUND, PortfolioTransaction.Type.BUY, + AccountTransaction.Type.BUY); + } + + /** + * Verifies that inline type editing offers the supported account master transitions. + * Account-only changes must use the ledger converter instead of mutating the projection directly. + */ + @Test + public void testSupportedLedgerBackedAccountInlineTypeConversions() throws Exception + { + assertInlineAccountBuySellReversal(AccountTransaction.Type.BUY, AccountTransaction.Type.SELL, + PortfolioTransaction.Type.SELL); + assertInlineAccountBuySellReversal(AccountTransaction.Type.SELL, AccountTransaction.Type.BUY, + PortfolioTransaction.Type.BUY); + + assertInlineAccountTransferReversal(AccountTransaction.Type.TRANSFER_IN, + AccountTransaction.Type.TRANSFER_OUT); + assertInlineAccountTransferReversal(AccountTransaction.Type.TRANSFER_OUT, + AccountTransaction.Type.TRANSFER_IN); + + assertInlineAccountOnlyToggle(AccountTransaction.Type.DEPOSIT, AccountTransaction.Type.REMOVAL); + assertInlineAccountOnlyToggle(AccountTransaction.Type.REMOVAL, AccountTransaction.Type.DEPOSIT); + assertInlineAccountOnlyToggle(AccountTransaction.Type.INTEREST, AccountTransaction.Type.INTEREST_CHARGE); + assertInlineAccountOnlyToggle(AccountTransaction.Type.INTEREST_CHARGE, AccountTransaction.Type.INTEREST); + } + + /** + * Verifies that unsupported inline type changes are not exposed as ledger mutations. + * Historical combo options without a safe converter must be blocked before any change. + */ + @Test + public void testForbiddenLedgerBackedInlineTypeConversions() throws Exception + { + var deposit = ledgerAccountOnlyFixture(AccountTransaction.Type.DEPOSIT); + assertForbiddenTypeEdit(deposit.client(), deposit.source().getTransactions().get(0), + AccountTransaction.Type.DEPOSIT, AccountTransaction.Type.INTEREST); + assertThat(deposit.source().getTransactions().get(0).getType(), is(AccountTransaction.Type.DEPOSIT)); + + var buy = ledgerBuySellFixture(PortfolioTransaction.Type.BUY); + assertForbiddenTypeEdit(buy.client(), buy.portfolio().getTransactions().get(0), + PortfolioTransaction.Type.BUY, AccountTransaction.Type.TRANSFER_IN); + assertThat(buy.portfolio().getTransactions().get(0).getType(), is(PortfolioTransaction.Type.BUY)); + assertThat(buy.account().getTransactions().get(0).getType(), is(AccountTransaction.Type.BUY)); + + var transfer = ledgerAccountTransferFixture(); + assertForbiddenTypeEdit(transfer.client(), transfer.source().getTransactions().get(0), + AccountTransaction.Type.TRANSFER_OUT, AccountTransaction.Type.REMOVAL); + assertThat(transfer.source().getTransactions().get(0).getType(), is(AccountTransaction.Type.TRANSFER_OUT)); + assertThat(transfer.target().getTransactions().get(0).getType(), is(AccountTransaction.Type.TRANSFER_IN)); + assertSame(transfer.source().getTransactions().get(0), transfer.target().getTransactions().get(0) + .getCrossEntry().getCrossTransaction(transfer.target().getTransactions().get(0))); + + var fees = ledgerAccountOnlyFixture(AccountTransaction.Type.FEES); + assertForbiddenTypeEdit(fees.client(), fees.source().getTransactions().get(0), AccountTransaction.Type.FEES, + AccountTransaction.Type.FEES_REFUND); + assertThat(fees.source().getTransactions().get(0).getType(), is(AccountTransaction.Type.FEES)); + + var taxes = ledgerAccountOnlyFixture(AccountTransaction.Type.TAXES); + assertForbiddenTypeEdit(taxes.client(), taxes.source().getTransactions().get(0), AccountTransaction.Type.TAXES, + AccountTransaction.Type.TAX_REFUND); + assertThat(taxes.source().getTransactions().get(0).getType(), is(AccountTransaction.Type.TAXES)); + } + + /** + * Verifies that matrix-allowed buy/sell type editing is still gated by concrete converter capability. + * Posting-level forex is rejected by the buy/sell reversal converter, so inline editing must block it before execution. + */ + @Test + public void testLedgerBackedBuySellPostingForexInlineReversalIsBlockedBeforeExecution() throws Exception + { + assertUnsupportedPostingForexBuySellTypeEdit(PortfolioTransaction.Type.BUY, PortfolioTransaction.Type.SELL, + AccountTransaction.Type.SELL); + assertUnsupportedPostingForexBuySellTypeEdit(PortfolioTransaction.Type.SELL, PortfolioTransaction.Type.BUY, + AccountTransaction.Type.BUY); + } + + /** + * Verifies that plan-referenced rows can use safe inline type converters. + * The plan reference must still resolve when the converter can preserve or migrate the projection unambiguously. + */ + @Test + public void testInvestmentPlanReferencedLedgerBackedInlineTypeConversionsUseSafeConverters() throws Exception + { + var buyToDelivery = ledgerBuySellFixture(PortfolioTransaction.Type.BUY); + var buyToDeliveryTransaction = buyToDelivery.portfolio().getTransactions().get(0); + addInvestmentPlanRef(buyToDelivery.client(), buyToDeliveryTransaction); + + var deliverySupport = new TransactionTypeEditingSupport(buyToDelivery.client()); + assertThat(deliverySupport.canEdit(buyToDeliveryTransaction), is(true)); + setTypeValue(deliverySupport, buyToDeliveryTransaction, PortfolioTransaction.Type.BUY, + PortfolioTransaction.Type.DELIVERY_INBOUND); + assertThat(buyToDelivery.portfolio().getTransactions().get(0).getType(), + is(PortfolioTransaction.Type.DELIVERY_INBOUND)); + assertThat(buyToDelivery.client().getPlans().get(0).getTransactions(buyToDelivery.client()).get(0) + .getTransaction().getUUID(), is(buyToDeliveryTransaction.getUUID())); + + var deposit = ledgerAccountOnlyFixture(AccountTransaction.Type.DEPOSIT); + var accountTransaction = deposit.source().getTransactions().get(0); + addInvestmentPlanRef(deposit.client(), accountTransaction); + + var accountSupport = new TransactionTypeEditingSupport(deposit.client()); + assertThat(accountSupport.canEdit(accountTransaction), is(true)); + setTypeValue(accountSupport, accountTransaction, AccountTransaction.Type.DEPOSIT, + AccountTransaction.Type.REMOVAL); + assertThat(deposit.source().getTransactions().get(0).getType(), is(AccountTransaction.Type.REMOVAL)); + assertThat(deposit.client().getPlans().get(0).getTransactions(deposit.client()).get(0).getTransaction() + .getUUID(), is(accountTransaction.getUUID())); + } + + /** + * Verifies that account-side plan references are not converted to delivery by inline editing. + * That conversion would remove the account projection, so the editor must not offer it. + */ + @Test + public void testInvestmentPlanReferencedAccountSideBuySellInlineConversionToDeliveryIsNotEditable() + throws Exception + { + var buy = ledgerBuySellFixture(PortfolioTransaction.Type.BUY); + var portfolioTransaction = buy.portfolio().getTransactions().get(0); + addInvestmentPlanRef(buy.client(), buy.account().getTransactions().get(0)); + + var support = new TransactionTypeEditingSupport(buy.client()); + assertThat(support.canEdit(portfolioTransaction), is(true)); + assertForbiddenTypeEdit(buy.client(), portfolioTransaction, PortfolioTransaction.Type.BUY, + PortfolioTransaction.Type.DELIVERY_INBOUND); + assertThat(buy.portfolio().getTransactions().get(0).getType(), is(PortfolioTransaction.Type.BUY)); + assertThat(buy.account().getTransactions().get(0).getType(), is(AccountTransaction.Type.BUY)); + } + + /** + * Verifies that ledger-backed metadata fields follow the inline policy. + * Date and note remain editable, while source is blocked by the LedgerProjectionRole matrix. + */ + @Test + public void testLedgerBackedDepositMetadataInlinePolicy() throws Exception + { + var fixture = ledgerAccountOnlyFixture(AccountTransaction.Type.DEPOSIT); + var transaction = fixture.source().getTransactions().get(0); + + var dateTimeSupport = new DateTimeEditingSupport(AccountTransaction.class, "dateTime"); + assertThat(dateTimeSupport.canEdit(transaction), is(true)); + dateTimeSupport.setValue(transaction, UPDATED_DATE_TIME.toString()); + + var noteSupport = new StringEditingSupport(AccountTransaction.class, "note"); + assertThat(noteSupport.canEdit(transaction), is(true)); + noteSupport.setValue(transaction, "updated note"); + + var sourceSupport = new StringEditingSupport(AccountTransaction.class, "source"); + assertThat(sourceSupport.canEdit(transaction), is(false)); + assertThrows(UnsupportedOperationException.class, () -> sourceSupport.setValue(transaction, "updated source")); + + assertThat(transaction.getDateTime(), is(UPDATED_DATE_TIME)); + assertThat(transaction.getNote(), is("updated note")); + assertThat(transaction.getSource(), is("source")); + assertThat(ledgerEntryValue(transaction, "getDateTime"), is(UPDATED_DATE_TIME)); + assertThat(ledgerEntryValue(transaction, "getNote"), is("updated note")); + assertThat(ledgerEntryValue(transaction, "getSource"), is("source")); + assertThat(fixture.source().getTransactions().size(), is(1)); + assertLedgerStructurallyValid(fixture.client()); + } + + /** + * Verifies that the ledger inline-editing matrix leaves legacy source edits unchanged. + * The source policy is only applied to ledger-backed projection rows. + */ + @Test + public void testLegacyDepositSourceInlineEditUsesLegacySetter() throws Exception + { + var fixture = legacyAccountOnlyFixture(AccountTransaction.Type.DEPOSIT); + var transaction = fixture.source().getTransactions().get(0); + transaction.setSource("source"); + + var sourceSupport = new StringEditingSupport(AccountTransaction.class, "source"); + assertThat(sourceSupport.canEdit(transaction), is(true)); + sourceSupport.setValue(transaction, "legacy source"); + + assertThat(transaction.getSource(), is("legacy source")); + assertThat(fixture.source().getTransactions().size(), is(1)); + } + + /** + * Verifies that ledger-backed date inline editing accepts the UI's single-digit hour format. + * A valid date edit must not be reported as an internal workbench error. + */ + @Test + public void testLedgerBackedDateTimeInlineEditAcceptsSingleDigitHour() throws Exception + { + var fixture = ledgerBuySellFixture(PortfolioTransaction.Type.BUY); + var transaction = fixture.account().getTransactions().get(0); + + var dateTimeSupport = new DateTimeEditingSupport(AccountTransaction.class, "dateTime"); + dateTimeSupport.addListener((element, newValue, oldValue) -> ((AccountTransaction) element).getCrossEntry() + .updateFrom((AccountTransaction) element)); + dateTimeSupport.setValue(transaction, "02.02.2026, 0:05"); + + var expected = LocalDateTime.of(2026, 2, 2, 0, 5); + assertThat(transaction.getDateTime(), is(expected)); + assertThat(ledgerEntryValue(transaction, "getDateTime"), is(expected)); + assertThat(fixture.account().getTransactions().size(), is(1)); + assertThat(fixture.portfolio().getTransactions().size(), is(1)); + assertLedgerStructurallyValid(fixture.client()); + } + + /** + * Verifies that ledger-backed share inline editing follows the LedgerProjectionRole matrix. + * Only dividends remain editable; portfolio and transfer shares are blocked before mutation. + */ + @Test + public void testLedgerBackedSharesInlineEditPolicy() throws Exception + { + var buy = ledgerBuySellFixture(PortfolioTransaction.Type.BUY); + var portfolioTransaction = buy.portfolio().getTransactions().get(0); + + assertThat(TransactionsViewer.canEditShares(new TransactionPair<>(buy.portfolio(), portfolioTransaction)), + is(false)); + assertThat(TransactionsViewer.updateLedgerBackedShares(buy.client(), + new TransactionPair<>(buy.portfolio(), portfolioTransaction), Values.Share.factorize(6)), + is(false)); + assertThat(buy.portfolio().getTransactions().get(0).getShares(), is(Values.Share.factorize(5))); + assertThat(buy.portfolio().getTransactions().size(), is(1)); + assertThat(buy.account().getTransactions().size(), is(1)); + assertLedgerStructurallyValid(buy.client()); + + var delivery = ledgerDeliveryFixture(PortfolioTransaction.Type.DELIVERY_INBOUND); + var deliveryTransaction = delivery.portfolio().getTransactions().get(0); + assertThat(TransactionsViewer.canEditShares(new TransactionPair<>(delivery.portfolio(), deliveryTransaction)), + is(false)); + assertThat(TransactionsViewer.updateLedgerBackedShares(delivery.client(), + new TransactionPair<>(delivery.portfolio(), deliveryTransaction), Values.Share.factorize(7)), + is(false)); + assertThat(delivery.portfolio().getTransactions().get(0).getShares(), is(Values.Share.factorize(5))); + assertThat(delivery.portfolio().getTransactions().size(), is(1)); + assertLedgerStructurallyValid(delivery.client()); + + var transfer = ledgerPortfolioTransferFixture(); + var sourceTransaction = transfer.source().getTransactions().get(0); + assertThat(TransactionsViewer.canEditShares(new TransactionPair<>(transfer.source(), sourceTransaction)), + is(false)); + assertThat(TransactionsViewer.updateLedgerBackedShares(transfer.client(), + new TransactionPair<>(transfer.source(), sourceTransaction), Values.Share.factorize(8)), + is(false)); + assertThat(transfer.source().getTransactions().get(0).getShares(), is(Values.Share.factorize(5))); + assertThat(transfer.target().getTransactions().get(0).getShares(), is(Values.Share.factorize(5))); + assertThat(transfer.source().getTransactions().size(), is(1)); + assertThat(transfer.target().getTransactions().size(), is(1)); + assertLedgerStructurallyValid(transfer.client()); + + var dividend = ledgerDividendFixture(); + var dividendTransaction = dividend.source().getTransactions().get(0); + assertThat(TransactionsViewer.canEditShares(new TransactionPair<>(dividend.source(), dividendTransaction)), + is(true)); + assertThat(TransactionsViewer.updateLedgerBackedShares(dividend.client(), + new TransactionPair<>(dividend.source(), dividendTransaction), Values.Share.factorize(9)), + is(true)); + assertThat(dividend.source().getTransactions().get(0).getShares(), is(Values.Share.factorize(9))); + assertThat(ledgerPostingShares(dividend.source().getTransactions().get(0)), is(Values.Share.factorize(9))); + assertThat(dividend.source().getTransactions().size(), is(1)); + assertLedgerStructurallyValid(dividend.client()); + } + + /** + * Verifies that dividend ex-date inline editing routes through the ledger editor. + * The projection setter is not used and save/load keeps the changed ex-date. + */ + @Test + public void testLedgerBackedDividendExDateInlineEditRoutesThroughLedgerEditor() throws Exception + { + var dividend = ledgerDividendFixture(); + var transaction = dividend.source().getTransactions().get(0); + var entryUUID = ledgerEntryValue(transaction, "getUUID"); + var projectionUUID = transaction.getUUID(); + var projectionRole = ledgerProjectionValue(transaction, "getRole"); + var newExDate = LocalDateTime.of(2026, 6, 20, 0, 0); + + var support = new ExDateEditingSupport(dividend.client()); + assertThat(support.canEdit(transaction), is(true)); + support.setValue(transaction, "2026-06-20"); + + assertThat(transaction.getExDate(), is(newExDate)); + assertThat(ledgerEntryValue(transaction, "getUUID"), is(entryUUID)); + assertThat(transaction.getUUID(), is(projectionUUID)); + assertThat(ledgerProjectionValue(transaction, "getRole"), is(projectionRole)); + assertThat(ledgerPostingExDate(transaction), is(newExDate)); + assertThat(dividend.source().getTransactions().size(), is(1)); + assertLedgerStructurallyValid(dividend.client()); + + var xmlLoaded = reloadXml(dividend.client()); + var xmlTransaction = xmlLoaded.getAccounts().get(0).getTransactions().get(0); + assertThat(xmlTransaction.getUUID(), is(projectionUUID)); + assertThat(xmlTransaction.getExDate(), is(newExDate)); + assertThat(ledgerPostingExDate(xmlTransaction), is(newExDate)); + assertLedgerStructurallyValid(xmlLoaded); + + var protobufLoaded = reloadProtobuf(dividend.client()); + var protobufTransaction = protobufLoaded.getAccounts().get(0).getTransactions().get(0); + assertThat(protobufTransaction.getUUID(), is(projectionUUID)); + assertThat(protobufTransaction.getExDate(), is(newExDate)); + assertThat(ledgerPostingExDate(protobufTransaction), is(newExDate)); + assertLedgerStructurallyValid(protobufLoaded); + } + + /** + * Verifies that legacy dividend ex-date inline editing keeps its existing setter path. + * The ledger-specific route must not change non-ledger transaction behavior. + */ + @Test + public void testLegacyDividendExDateInlineEditUsesLegacySetter() + { + var dividend = legacyDividendFixture(); + var transaction = dividend.source().getTransactions().get(0); + var newExDate = LocalDateTime.of(2026, 6, 20, 0, 0); + + var support = new ExDateEditingSupport(); + assertThat(support.canEdit(transaction), is(true)); + support.setValue(transaction, "2026-06-20"); + + assertThat(transaction.getExDate(), is(newExDate)); + assertThat(dividend.source().getTransactions().size(), is(1)); + } + + /** + * Verifies that unsupported ledger-backed ex-date edits are rejected before mutation. + * Only account transactions with a safe ex-date ledger path may be changed inline. + */ + @Test + public void testUnsupportedLedgerBackedExDateInlineEditRejectsBeforeMutation() throws Exception + { + var buy = ledgerBuySellFixture(PortfolioTransaction.Type.BUY); + var accountProjection = buy.account().getTransactions().get(0); + var accountOnly = ledgerAccountOnlyWithSecurityFixture(AccountTransaction.Type.FEES); + var accountOnlyProjection = accountOnly.source().getTransactions().get(0); + + assertUnsupportedLedgerExDateEdit(buy.client(), accountProjection); + assertUnsupportedLedgerExDateEdit(accountOnly.client(), accountOnlyProjection); + } + + /** + * Verifies that structural inline fields without a safe ledger editor remain blocked. + * This prevents legacy setter writes against runtime projections. + */ + @Test + public void testForbiddenLedgerBackedStructuralFieldEdits() throws Exception + { + var buy = ledgerBuySellFixture(PortfolioTransaction.Type.BUY); + var portfolioTransaction = buy.portfolio().getTransactions().get(0); + var shares = portfolioTransaction.getShares(); + + var exception = assertThrows(UnsupportedOperationException.class, + () -> new ValueEditingSupport(PortfolioTransaction.class, "shares", Values.Share) + .setValue(portfolioTransaction, "6")); + assertThat(exception.getMessage(), is(LedgerDiagnosticCode.LEDGER_UI_011 + .message(MessageFormat.format(Messages.LedgerPropertyEditingSupportUnsupportedInlineEdit, + LedgerInlineEditingField.SHARES)))); + assertThat(portfolioTransaction.getShares(), is(shares)); + assertThat(buy.portfolio().getTransactions().size(), is(1)); + assertThat(buy.account().getTransactions().size(), is(1)); + assertLedgerStructurallyValid(buy.client()); + } + + /** + * Verifies that model-level transaction deduplication remains consistent for legacy and ledger-backed transfers. + * The client model keeps its deduplicated transaction list for reporting-style callers. + */ + @Test + public void testAllTransactionsTransferDeduplicationMatchesLegacyAndLedgerBacked() + { + assertThat(ledgerAccountTransferFixture().client().getAllTransactions().size(), is(1)); + assertThat(legacyAccountTransferFixture().client().getAllTransactions().size(), is(1)); + assertThat(ledgerPortfolioTransferFixture().client().getAllTransactions().size(), is(1)); + assertThat(legacyPortfolioTransferFixture().client().getAllTransactions().size(), is(1)); + } + + /** + * Verifies that the UI all-transactions view expands transfers into both owner sides. + * The view shows source and target rows without changing Client.getAllTransactions. + */ + @Test + public void testAllTransactionsViewExpandsTransfersWithoutChangingClientDeduplication() throws Exception + { + var legacyAccountTransfer = legacyAccountTransferFixture(); + var legacyAccountTransactions = AllTransactionsView.getTransactionsForView(legacyAccountTransfer.client()); + + assertThat(legacyAccountTransactions.size(), is(2)); + assertSame(legacyAccountTransfer.source(), legacyAccountTransactions.get(0).getOwner()); + assertThat(((AccountTransaction) legacyAccountTransactions.get(0).getTransaction()).getType(), + is(AccountTransaction.Type.TRANSFER_OUT)); + assertSame(legacyAccountTransfer.target(), legacyAccountTransactions.get(1).getOwner()); + assertThat(((AccountTransaction) legacyAccountTransactions.get(1).getTransaction()).getType(), + is(AccountTransaction.Type.TRANSFER_IN)); + assertThat(legacyAccountTransfer.client().getAllTransactions().size(), is(1)); + + var ledgerAccountTransfer = ledgerAccountTransferFixture(); + var ledgerAccountTransactions = AllTransactionsView.getTransactionsForView(ledgerAccountTransfer.client()); + + assertThat(ledgerAccountTransactions.size(), is(2)); + assertSame(ledgerAccountTransfer.source(), ledgerAccountTransactions.get(0).getOwner()); + assertThat(((AccountTransaction) ledgerAccountTransactions.get(0).getTransaction()).getType(), + is(AccountTransaction.Type.TRANSFER_OUT)); + assertSame(ledgerAccountTransfer.target(), ledgerAccountTransactions.get(1).getOwner()); + assertThat(((AccountTransaction) ledgerAccountTransactions.get(1).getTransaction()).getType(), + is(AccountTransaction.Type.TRANSFER_IN)); + assertSame(ledgerEntry(ledgerAccountTransactions.get(0).getTransaction()), + ledgerEntry(ledgerAccountTransactions.get(1).getTransaction())); + assertThat(ledgerAccountTransfer.client().getAllTransactions().size(), is(1)); + assertThat(AllTransactionsView.matchesClientFilter( + new PortfolioClientFilter(List.of(), List.of(ledgerAccountTransfer.source())), + ledgerAccountTransactions.get(0)), is(true)); + assertThat(AllTransactionsView.matchesClientFilter( + new PortfolioClientFilter(List.of(), List.of(ledgerAccountTransfer.source())), + ledgerAccountTransactions.get(1)), is(false)); + + var reloadedAccountTransfer = AllTransactionsView.getTransactionsForView(reloadXml(ledgerAccountTransfer.client())); + assertThat(reloadedAccountTransfer.size(), is(2)); + assertThat(((AccountTransaction) reloadedAccountTransfer.get(0).getTransaction()).getType(), + is(AccountTransaction.Type.TRANSFER_OUT)); + assertThat(((AccountTransaction) reloadedAccountTransfer.get(1).getTransaction()).getType(), + is(AccountTransaction.Type.TRANSFER_IN)); + assertSame(ledgerEntry(reloadedAccountTransfer.get(0).getTransaction()), + ledgerEntry(reloadedAccountTransfer.get(1).getTransaction())); + + var legacyPortfolioTransfer = legacyPortfolioTransferFixture(); + var legacyPortfolioTransactions = AllTransactionsView.getTransactionsForView(legacyPortfolioTransfer.client()); + + assertThat(legacyPortfolioTransactions.size(), is(2)); + assertSame(legacyPortfolioTransfer.source(), legacyPortfolioTransactions.get(0).getOwner()); + assertThat(((PortfolioTransaction) legacyPortfolioTransactions.get(0).getTransaction()).getType(), + is(PortfolioTransaction.Type.TRANSFER_OUT)); + assertSame(legacyPortfolioTransfer.target(), legacyPortfolioTransactions.get(1).getOwner()); + assertThat(((PortfolioTransaction) legacyPortfolioTransactions.get(1).getTransaction()).getType(), + is(PortfolioTransaction.Type.TRANSFER_IN)); + assertThat(legacyPortfolioTransfer.client().getAllTransactions().size(), is(1)); + + var ledgerPortfolioTransfer = ledgerPortfolioTransferFixture(); + var ledgerPortfolioTransactions = AllTransactionsView.getTransactionsForView(ledgerPortfolioTransfer.client()); + + assertThat(ledgerPortfolioTransactions.size(), is(2)); + assertSame(ledgerPortfolioTransfer.source(), ledgerPortfolioTransactions.get(0).getOwner()); + assertThat(((PortfolioTransaction) ledgerPortfolioTransactions.get(0).getTransaction()).getType(), + is(PortfolioTransaction.Type.TRANSFER_OUT)); + assertSame(ledgerPortfolioTransfer.target(), ledgerPortfolioTransactions.get(1).getOwner()); + assertThat(((PortfolioTransaction) ledgerPortfolioTransactions.get(1).getTransaction()).getType(), + is(PortfolioTransaction.Type.TRANSFER_IN)); + assertSame(ledgerEntry(ledgerPortfolioTransactions.get(0).getTransaction()), + ledgerEntry(ledgerPortfolioTransactions.get(1).getTransaction())); + assertThat(ledgerPortfolioTransfer.client().getAllTransactions().size(), is(1)); + assertThat(AllTransactionsView.matchesClientFilter( + new PortfolioClientFilter(List.of(ledgerPortfolioTransfer.source()), List.of()), + ledgerPortfolioTransactions.get(0)), is(true)); + assertThat(AllTransactionsView.matchesClientFilter( + new PortfolioClientFilter(List.of(ledgerPortfolioTransfer.source()), List.of()), + ledgerPortfolioTransactions.get(1)), is(false)); + + var reloadedPortfolioTransfer = AllTransactionsView + .getTransactionsForView(reloadXml(ledgerPortfolioTransfer.client())); + assertThat(reloadedPortfolioTransfer.size(), is(2)); + assertThat(((PortfolioTransaction) reloadedPortfolioTransfer.get(0).getTransaction()).getType(), + is(PortfolioTransaction.Type.TRANSFER_OUT)); + assertThat(((PortfolioTransaction) reloadedPortfolioTransfer.get(1).getTransaction()).getType(), + is(PortfolioTransaction.Type.TRANSFER_IN)); + assertSame(ledgerEntry(reloadedPortfolioTransfer.get(0).getTransaction()), + ledgerEntry(reloadedPortfolioTransfer.get(1).getTransaction())); + + assertThat(AllTransactionsView.getTransactionsForView(legacyBuySellFixture(PortfolioTransaction.Type.BUY).client()) + .size(), is(1)); + var ledgerBuySell = ledgerBuySellFixture(PortfolioTransaction.Type.BUY); + var ledgerBuySellTransactions = AllTransactionsView.getTransactionsForView(ledgerBuySell.client()); + assertThat(ledgerBuySellTransactions.size(), is(1)); + assertThat(AllTransactionsView.matchesClientFilter( + new PortfolioClientFilter(List.of(), List.of(ledgerBuySell.account())), + ledgerBuySellTransactions.get(0)), is(true)); + } + + /** + * Verifies that generic owner and cross-entry field edits stay blocked for unsupported ledger-backed rows. + * The UI must not open the old delete/insert/replay path for runtime projections. + */ + @Test + public void testForbiddenLedgerBackedOwnerAndCrossEntryFieldEdits() throws Exception + { + var buy = ledgerBuySellFixture(PortfolioTransaction.Type.BUY); + var replacement = new Portfolio("Replacement"); + buy.client().addPortfolio(replacement); + + var portfolioTransaction = buy.portfolio().getTransactions().get(0); + var crossEntry = portfolioTransaction.getCrossEntry(); + + assertThrows(UnsupportedOperationException.class, + () -> crossEntry.setOwner(portfolioTransaction, replacement)); + + assertSame(buy.portfolio(), crossEntry.getOwner(portfolioTransaction)); + assertThat(buy.portfolio().getTransactions().size(), is(1)); + assertThat(buy.account().getTransactions().size(), is(1)); + assertLedgerStructurallyValid(buy.client()); + } + + /** + * Verifies that buy/sell owner inline editing uses LedgerOwnerPatchHelper. + * Account and portfolio changes must move the ledger facts while keeping one persisted booking truth. + */ + @Test + public void testLedgerBackedBuySellOwnerListEditingSupportUsesLedgerOwnerPatch() throws Exception + { + assertLedgerBackedBuySellOwnerListEditingSupportUsesLedgerOwnerPatch(PortfolioTransaction.Type.BUY); + assertLedgerBackedBuySellOwnerListEditingSupportUsesLedgerOwnerPatch(PortfolioTransaction.Type.SELL); + } + + private void assertLedgerBackedBuySellOwnerListEditingSupportUsesLedgerOwnerPatch(PortfolioTransaction.Type type) + throws Exception + { + var buy = ledgerBuySellFixture(type); + var targetAccount = account("Target Account", CurrencyUnit.EUR); + var targetPortfolio = new Portfolio("Target Portfolio"); + buy.client().addAccount(targetAccount); + buy.client().addPortfolio(targetPortfolio); + + var accountTransaction = buy.account().getTransactions().get(0); + var portfolioTransaction = buy.portfolio().getTransactions().get(0); + var entryUUID = ledgerEntryValue(accountTransaction, "getUUID"); + var accountProjectionUUID = accountTransaction.getUUID(); + var portfolioProjectionUUID = portfolioTransaction.getUUID(); + var accountPlan = addInvestmentPlanRef(buy.client(), accountTransaction); + var portfolioPlan = addInvestmentPlanRef(buy.client(), portfolioTransaction); + + setOwnerValue(buy.client(), accountTransaction, TransactionOwnerListEditingSupport.EditMode.OWNER, + targetAccount); + + assertThat(buy.account().getTransactions().isEmpty(), is(true)); + accountTransaction = findAccountTransaction(targetAccount, accountProjectionUUID); + portfolioTransaction = findPortfolioTransaction(buy.portfolio(), portfolioProjectionUUID); + assertThat(ledgerEntryValue(accountTransaction, "getUUID"), is(entryUUID)); + assertSame(targetAccount, accountTransaction.getCrossEntry().getOwner(accountTransaction)); + assertSame(buy.portfolio(), accountTransaction.getCrossEntry().getCrossOwner(accountTransaction)); + assertPlanRefResolves(accountPlan, buy.client(), targetAccount, accountProjectionUUID); + + setOwnerValue(buy.client(), portfolioTransaction, TransactionOwnerListEditingSupport.EditMode.OWNER, + targetPortfolio); + + assertThat(buy.portfolio().getTransactions().isEmpty(), is(true)); + accountTransaction = findAccountTransaction(targetAccount, accountProjectionUUID); + portfolioTransaction = findPortfolioTransaction(targetPortfolio, portfolioProjectionUUID); + assertThat(ledgerEntryValue(portfolioTransaction, "getUUID"), is(entryUUID)); + assertSame(targetPortfolio, portfolioTransaction.getCrossEntry().getOwner(portfolioTransaction)); + assertSame(targetAccount, portfolioTransaction.getCrossEntry().getCrossOwner(portfolioTransaction)); + assertPlanRefResolves(portfolioPlan, buy.client(), targetPortfolio, portfolioProjectionUUID); + assertThat(targetAccount.getTransactions().size(), is(1)); + assertThat(targetPortfolio.getTransactions().size(), is(1)); + assertLedgerStructurallyValid(buy.client()); + + var reloaded = reloadXml(buy.client()); + assertPlanRefResolves(reloaded.getPlans().get(0), reloaded, reloaded.getAccounts().get(1), + accountProjectionUUID); + assertPlanRefResolves(reloaded.getPlans().get(1), reloaded, reloaded.getPortfolios().get(1), + portfolioProjectionUUID); + } + + /** + * Verifies that source-side transfer owner inline editing uses LedgerOwnerPatchHelper. + * The moved projection must leave the old owner list and appear in the new owner list without duplication. + */ + @Test + public void testLedgerBackedTransferOwnerListEditingSupportUsesLedgerOwnerPatch() throws Exception + { + var transfer = ledgerAccountTransferFixture(); + var newSource = account("New Source", CurrencyUnit.EUR); + var newTarget = account("New Target", CurrencyUnit.EUR); + transfer.client().addAccount(newSource); + transfer.client().addAccount(newTarget); + + var sourceTransaction = transfer.source().getTransactions().get(0); + var targetTransaction = transfer.target().getTransactions().get(0); + var entryUUID = ledgerEntryValue(sourceTransaction, "getUUID"); + var sourceProjectionUUID = sourceTransaction.getUUID(); + var targetProjectionUUID = targetTransaction.getUUID(); + var sourcePlan = addInvestmentPlanRef(transfer.client(), sourceTransaction); + var targetPlan = addInvestmentPlanRef(transfer.client(), targetTransaction); + + setOwnerValue(transfer.client(), sourceTransaction, TransactionOwnerListEditingSupport.EditMode.OWNER, + newSource); + sourceTransaction = findAccountTransaction(newSource, sourceProjectionUUID); + targetTransaction = findAccountTransaction(transfer.target(), targetProjectionUUID); + assertThat(transfer.source().getTransactions().isEmpty(), is(true)); + assertThat(ledgerEntryValue(sourceTransaction, "getUUID"), is(entryUUID)); + assertThat(sourceTransaction.getType(), is(AccountTransaction.Type.TRANSFER_OUT)); + assertSame(transfer.target(), sourceTransaction.getCrossEntry().getCrossOwner(sourceTransaction)); + assertPlanRefResolves(sourcePlan, transfer.client(), newSource, sourceProjectionUUID); + + setOwnerValue(transfer.client(), sourceTransaction, TransactionOwnerListEditingSupport.EditMode.CROSSOWNER, + newTarget); + sourceTransaction = findAccountTransaction(newSource, sourceProjectionUUID); + targetTransaction = findAccountTransaction(newTarget, targetProjectionUUID); + assertThat(transfer.target().getTransactions().isEmpty(), is(true)); + assertThat(ledgerEntryValue(targetTransaction, "getUUID"), is(entryUUID)); + assertThat(targetTransaction.getType(), is(AccountTransaction.Type.TRANSFER_IN)); + assertSame(newTarget, sourceTransaction.getCrossEntry().getCrossOwner(sourceTransaction)); + assertPlanRefResolves(targetPlan, transfer.client(), newTarget, targetProjectionUUID); + assertThat(newSource.getTransactions().size(), is(1)); + assertThat(newTarget.getTransactions().size(), is(1)); + assertLedgerStructurallyValid(transfer.client()); + + var portfolioTransfer = ledgerPortfolioTransferFixture(); + var newSourcePortfolio = new Portfolio("New Source Portfolio"); + var newTargetPortfolio = new Portfolio("New Target Portfolio"); + portfolioTransfer.client().addPortfolio(newSourcePortfolio); + portfolioTransfer.client().addPortfolio(newTargetPortfolio); + + var sourcePortfolioTransaction = portfolioTransfer.source().getTransactions().get(0); + var targetPortfolioTransaction = portfolioTransfer.target().getTransactions().get(0); + var portfolioEntryUUID = ledgerEntryValue(sourcePortfolioTransaction, "getUUID"); + var sourcePortfolioProjectionUUID = sourcePortfolioTransaction.getUUID(); + var targetPortfolioProjectionUUID = targetPortfolioTransaction.getUUID(); + var sourcePortfolioPlan = addInvestmentPlanRef(portfolioTransfer.client(), sourcePortfolioTransaction); + var targetPortfolioPlan = addInvestmentPlanRef(portfolioTransfer.client(), targetPortfolioTransaction); + + setOwnerValue(portfolioTransfer.client(), sourcePortfolioTransaction, + TransactionOwnerListEditingSupport.EditMode.OWNER, newSourcePortfolio); + sourcePortfolioTransaction = findPortfolioTransaction(newSourcePortfolio, sourcePortfolioProjectionUUID); + targetPortfolioTransaction = findPortfolioTransaction(portfolioTransfer.target(), + targetPortfolioProjectionUUID); + assertThat(portfolioTransfer.source().getTransactions().isEmpty(), is(true)); + assertThat(ledgerEntryValue(sourcePortfolioTransaction, "getUUID"), is(portfolioEntryUUID)); + assertThat(sourcePortfolioTransaction.getType(), is(PortfolioTransaction.Type.TRANSFER_OUT)); + assertSame(portfolioTransfer.target(), + sourcePortfolioTransaction.getCrossEntry().getCrossOwner(sourcePortfolioTransaction)); + assertPlanRefResolves(sourcePortfolioPlan, portfolioTransfer.client(), newSourcePortfolio, + sourcePortfolioProjectionUUID); + + setOwnerValue(portfolioTransfer.client(), sourcePortfolioTransaction, + TransactionOwnerListEditingSupport.EditMode.CROSSOWNER, newTargetPortfolio); + sourcePortfolioTransaction = findPortfolioTransaction(newSourcePortfolio, sourcePortfolioProjectionUUID); + targetPortfolioTransaction = findPortfolioTransaction(newTargetPortfolio, targetPortfolioProjectionUUID); + assertThat(portfolioTransfer.target().getTransactions().isEmpty(), is(true)); + assertThat(ledgerEntryValue(targetPortfolioTransaction, "getUUID"), is(portfolioEntryUUID)); + assertThat(targetPortfolioTransaction.getType(), is(PortfolioTransaction.Type.TRANSFER_IN)); + assertSame(newTargetPortfolio, + sourcePortfolioTransaction.getCrossEntry().getCrossOwner(sourcePortfolioTransaction)); + assertPlanRefResolves(targetPortfolioPlan, portfolioTransfer.client(), newTargetPortfolio, + targetPortfolioProjectionUUID); + assertThat(newSourcePortfolio.getTransactions().size(), is(1)); + assertThat(newTargetPortfolio.getTransactions().size(), is(1)); + assertLedgerStructurallyValid(portfolioTransfer.client()); + } + + /** + * Verifies that target-side transfer rows also use LedgerOwnerPatchHelper. + * Cross-owner edits must update the correct target side and keep plan references resolvable. + */ + @Test + public void testLedgerBackedTransferTargetRowsUseLedgerOwnerPatch() throws Exception + { + var transfer = ledgerAccountTransferFixture(); + var newSource = account("New Source From Target Row", CurrencyUnit.EUR); + var newTarget = account("New Target From Target Row", CurrencyUnit.EUR); + transfer.client().addAccount(newSource); + transfer.client().addAccount(newTarget); + + var sourceTransaction = transfer.source().getTransactions().get(0); + var targetTransaction = transfer.target().getTransactions().get(0); + var entryUUID = ledgerEntryValue(targetTransaction, "getUUID"); + var sourceProjectionUUID = sourceTransaction.getUUID(); + var targetProjectionUUID = targetTransaction.getUUID(); + var sourcePlan = addInvestmentPlanRef(transfer.client(), sourceTransaction); + var targetPlan = addInvestmentPlanRef(transfer.client(), targetTransaction); + + setOwnerValue(transfer.client(), targetTransaction, TransactionOwnerListEditingSupport.EditMode.OWNER, + newTarget); + sourceTransaction = findAccountTransaction(transfer.source(), sourceProjectionUUID); + targetTransaction = findAccountTransaction(newTarget, targetProjectionUUID); + assertThat(transfer.target().getTransactions().isEmpty(), is(true)); + assertThat(ledgerEntryValue(targetTransaction, "getUUID"), is(entryUUID)); + assertThat(targetTransaction.getType(), is(AccountTransaction.Type.TRANSFER_IN)); + assertSame(transfer.source(), targetTransaction.getCrossEntry().getCrossOwner(targetTransaction)); + assertPlanRefResolves(targetPlan, transfer.client(), newTarget, targetProjectionUUID); + + setOwnerValue(transfer.client(), targetTransaction, TransactionOwnerListEditingSupport.EditMode.CROSSOWNER, + newSource); + sourceTransaction = findAccountTransaction(newSource, sourceProjectionUUID); + targetTransaction = findAccountTransaction(newTarget, targetProjectionUUID); + assertThat(transfer.source().getTransactions().isEmpty(), is(true)); + assertThat(ledgerEntryValue(sourceTransaction, "getUUID"), is(entryUUID)); + assertThat(sourceTransaction.getType(), is(AccountTransaction.Type.TRANSFER_OUT)); + assertSame(newSource, targetTransaction.getCrossEntry().getCrossOwner(targetTransaction)); + assertPlanRefResolves(sourcePlan, transfer.client(), newSource, sourceProjectionUUID); + assertThat(newSource.getTransactions().size(), is(1)); + assertThat(newTarget.getTransactions().size(), is(1)); + assertLedgerStructurallyValid(transfer.client()); + + var reloadedTransfer = reloadXml(transfer.client()); + assertPlanRefResolves(reloadedTransfer.getPlans().get(0), reloadedTransfer, reloadedTransfer.getAccounts().get(2), + sourceProjectionUUID); + assertPlanRefResolves(reloadedTransfer.getPlans().get(1), reloadedTransfer, reloadedTransfer.getAccounts().get(3), + targetProjectionUUID); + + var portfolioTransfer = ledgerPortfolioTransferFixture(); + var newSourcePortfolio = new Portfolio("New Source Portfolio From Target Row"); + var newTargetPortfolio = new Portfolio("New Target Portfolio From Target Row"); + portfolioTransfer.client().addPortfolio(newSourcePortfolio); + portfolioTransfer.client().addPortfolio(newTargetPortfolio); + + var sourcePortfolioTransaction = portfolioTransfer.source().getTransactions().get(0); + var targetPortfolioTransaction = portfolioTransfer.target().getTransactions().get(0); + var portfolioEntryUUID = ledgerEntryValue(targetPortfolioTransaction, "getUUID"); + var sourcePortfolioProjectionUUID = sourcePortfolioTransaction.getUUID(); + var targetPortfolioProjectionUUID = targetPortfolioTransaction.getUUID(); + var sourcePortfolioPlan = addInvestmentPlanRef(portfolioTransfer.client(), sourcePortfolioTransaction); + var targetPortfolioPlan = addInvestmentPlanRef(portfolioTransfer.client(), targetPortfolioTransaction); + + setOwnerValue(portfolioTransfer.client(), targetPortfolioTransaction, + TransactionOwnerListEditingSupport.EditMode.OWNER, newTargetPortfolio); + sourcePortfolioTransaction = findPortfolioTransaction(portfolioTransfer.source(), + sourcePortfolioProjectionUUID); + targetPortfolioTransaction = findPortfolioTransaction(newTargetPortfolio, targetPortfolioProjectionUUID); + assertThat(portfolioTransfer.target().getTransactions().isEmpty(), is(true)); + assertThat(ledgerEntryValue(targetPortfolioTransaction, "getUUID"), is(portfolioEntryUUID)); + assertThat(targetPortfolioTransaction.getType(), is(PortfolioTransaction.Type.TRANSFER_IN)); + assertSame(portfolioTransfer.source(), + targetPortfolioTransaction.getCrossEntry().getCrossOwner(targetPortfolioTransaction)); + assertPlanRefResolves(targetPortfolioPlan, portfolioTransfer.client(), newTargetPortfolio, + targetPortfolioProjectionUUID); + + setOwnerValue(portfolioTransfer.client(), targetPortfolioTransaction, + TransactionOwnerListEditingSupport.EditMode.CROSSOWNER, newSourcePortfolio); + sourcePortfolioTransaction = findPortfolioTransaction(newSourcePortfolio, sourcePortfolioProjectionUUID); + targetPortfolioTransaction = findPortfolioTransaction(newTargetPortfolio, targetPortfolioProjectionUUID); + assertThat(portfolioTransfer.source().getTransactions().isEmpty(), is(true)); + assertThat(ledgerEntryValue(sourcePortfolioTransaction, "getUUID"), is(portfolioEntryUUID)); + assertThat(sourcePortfolioTransaction.getType(), is(PortfolioTransaction.Type.TRANSFER_OUT)); + assertSame(newSourcePortfolio, + targetPortfolioTransaction.getCrossEntry().getCrossOwner(targetPortfolioTransaction)); + assertPlanRefResolves(sourcePortfolioPlan, portfolioTransfer.client(), newSourcePortfolio, + sourcePortfolioProjectionUUID); + assertThat(newSourcePortfolio.getTransactions().size(), is(1)); + assertThat(newTargetPortfolio.getTransactions().size(), is(1)); + assertLedgerStructurallyValid(portfolioTransfer.client()); + + var reloadedPortfolioTransfer = reloadXml(portfolioTransfer.client()); + assertPlanRefResolves(reloadedPortfolioTransfer.getPlans().get(0), reloadedPortfolioTransfer, + reloadedPortfolioTransfer.getPortfolios().get(2), sourcePortfolioProjectionUUID); + assertPlanRefResolves(reloadedPortfolioTransfer.getPlans().get(1), reloadedPortfolioTransfer, + reloadedPortfolioTransfer.getPortfolios().get(3), targetPortfolioProjectionUUID); + } + + /** + * Verifies that owner inline editing is restored only for master-supported cross-entry families. + * Account-only, dividend, and delivery rows remain blocked because master did not expose the same edit. + */ + @Test + public void testLedgerBackedOwnerListEditingSupportKeepsUnsupportedFamiliesBlocked() throws Exception + { + var accountOnly = ledgerAccountOnlyFixture(AccountTransaction.Type.DEPOSIT); + assertThat(new TransactionOwnerListEditingSupport(accountOnly.client(), + TransactionOwnerListEditingSupport.EditMode.OWNER) + .canEdit(accountOnly.source().getTransactions().get(0)), + is(false)); + + var dividend = ledgerDividendFixture(); + assertThat(new TransactionOwnerListEditingSupport(dividend.client(), + TransactionOwnerListEditingSupport.EditMode.OWNER) + .canEdit(dividend.source().getTransactions().get(0)), + is(false)); + + var delivery = ledgerDeliveryFixture(PortfolioTransaction.Type.DELIVERY_INBOUND); + assertThat(new TransactionOwnerListEditingSupport(delivery.client(), + TransactionOwnerListEditingSupport.EditMode.OWNER) + .canEdit(delivery.portfolio().getTransactions().get(0)), + is(false)); + } + + /** + * Verifies that invalid owner choices are rejected before mutation. + * Currency mismatches and source-equals-target changes must not leave partial ledger owner moves. + */ + @Test + public void testLedgerBackedOwnerListEditingSupportRejectsInvalidOwnersBeforeMutation() throws Exception + { + var buy = ledgerBuySellFixture(PortfolioTransaction.Type.BUY); + var usdAccount = account("USD Account", CurrencyUnit.USD); + buy.client().addAccount(usdAccount); + + var accountTransaction = buy.account().getTransactions().get(0); + assertThrows(IllegalArgumentException.class, + () -> setOwnerValue(buy.client(), accountTransaction, + TransactionOwnerListEditingSupport.EditMode.OWNER, usdAccount)); + assertThat(buy.account().getTransactions().size(), is(1)); + + var transfer = ledgerAccountTransferFixture(); + var accountTransferException = assertThrows(IllegalArgumentException.class, + () -> setOwnerValue(transfer.client(), transfer.source().getTransactions().get(0), + TransactionOwnerListEditingSupport.EditMode.OWNER, transfer.target())); + assertThat(accountTransferException.getMessage(), is(LedgerDiagnosticCode.LEDGER_UI_012 + .message(Messages.LedgerTransactionOwnerListEditingSupportDistinctOwnersRequired))); + assertThat(transfer.source().getTransactions().size(), is(1)); + assertThat(transfer.target().getTransactions().size(), is(1)); + + var portfolioTransfer = ledgerPortfolioTransferFixture(); + var portfolioTransferException = assertThrows(IllegalArgumentException.class, + () -> setOwnerValue(portfolioTransfer.client(), + portfolioTransfer.source().getTransactions().get(0), + TransactionOwnerListEditingSupport.EditMode.OWNER, portfolioTransfer.target())); + assertThat(portfolioTransferException.getMessage(), is(LedgerDiagnosticCode.LEDGER_UI_012 + .message(Messages.LedgerTransactionOwnerListEditingSupportDistinctOwnersRequired))); + assertThat(portfolioTransfer.source().getTransactions().size(), is(1)); + assertThat(portfolioTransfer.target().getTransactions().size(), is(1)); + } + + private void assertLedgerAccountOnlyDialogRoute(AccountTransaction.Type type) throws Exception + { + var fixture = ledgerAccountOnlyFixture(type); + assertSupportedAccountEditRoute(fixture.client(), fixture.source(), fixture.source().getTransactions().get(0), + AccountTransactionDialog.class, type); + } + + private void assertLedgerDividendDialogRoute() throws Exception + { + var fixture = ledgerDividendFixture(); + assertSupportedAccountEditRoute(fixture.client(), fixture.source(), fixture.source().getTransactions().get(0), + AccountTransactionDialog.class, AccountTransaction.Type.DIVIDENDS); + } + + private void assertSupportedBuySellToDeliveryContextMenuRoute(Portfolio owner, PortfolioTransaction transaction) + { + var pair = new TransactionPair<>(owner, transaction); + + assertThat(TransactionContextMenu.supportsBuySellToDeliveryAction(List.of(pair)), is(true)); + assertThat(TransactionContextMenu.supportsDeliveryToBuySellAction(List.of(pair)), is(false)); + } + + private void assertSupportedDeliveryToBuySellContextMenuRoute(Portfolio owner, PortfolioTransaction transaction) + { + var pair = new TransactionPair<>(owner, transaction); + + assertThat(TransactionContextMenu.supportsDeliveryToBuySellAction(List.of(pair)), is(true)); + assertThat(TransactionContextMenu.supportsBuySellToDeliveryAction(List.of(pair)), is(false)); + } + + private void assertSupportedAccountEditRoute(Client client, Account owner, AccountTransaction transaction, + Class dialogType, Object... parameters) throws Exception + { + var action = createEditAccountTransactionAction(client, new TransactionPair<>(owner, transaction)); + + assertThat(action, instanceOf(OpenDialogAction.class)); + assertSame(dialogType, dialogType(action)); + assertArrayEquals(parameters, parameters(action)); + } + + private void assertSupportedPortfolioEditRoute(Client client, Portfolio owner, PortfolioTransaction transaction, + Class dialogType, Object... parameters) throws Exception + { + var action = createEditPortfolioTransactionAction(client, new TransactionPair<>(owner, transaction)); + + assertThat(action, instanceOf(OpenDialogAction.class)); + assertSame(dialogType, dialogType(action)); + assertArrayEquals(parameters, parameters(action)); + } + + private Action createEditAccountTransactionAction(Client client, TransactionPair transaction) + throws Exception + { + var method = TransactionContextMenu.class.getDeclaredMethod("createEditAccountTransactionAction", + TransactionPair.class); + method.setAccessible(true); + return (Action) method.invoke(new TransactionContextMenu(null), transaction); + } + + private Action createEditPortfolioTransactionAction(Client client, TransactionPair transaction) + throws Exception + { + var method = TransactionContextMenu.class.getDeclaredMethod("createEditPortfolioTransactionAction", + TransactionPair.class); + method.setAccessible(true); + return (Action) method.invoke(new TransactionContextMenu(null), transaction); + } + + private Class dialogType(Action action) throws Exception + { + return (Class) field(action, "type"); + } + + private Object[] parameters(Action action) throws Exception + { + var parameters = (Object[]) field(action, "parameters"); + return parameters != null ? parameters : new Object[0]; + } + + private Object field(Object object, String name) throws Exception + { + Field field = object.getClass().getDeclaredField(name); + field.setAccessible(true); + return field.get(object); + } + + private void assertInlineBuySellToDelivery(PortfolioTransaction.Type from, PortfolioTransaction.Type to) + throws Exception + { + var fixture = ledgerBuySellFixture(from); + var portfolioTransaction = fixture.portfolio().getTransactions().get(0); + var portfolioUUID = portfolioTransaction.getUUID(); + + assertThat(new TransactionTypeEditingSupport(fixture.client()).canEdit(portfolioTransaction), is(true)); + setTypeValue(new TransactionTypeEditingSupport(fixture.client()), portfolioTransaction, from, to); + + assertThat(fixture.account().getTransactions().isEmpty(), is(true)); + assertThat(fixture.portfolio().getTransactions().size(), is(1)); + assertThat(fixture.portfolio().getTransactions().get(0).getUUID(), is(portfolioUUID)); + assertThat(fixture.portfolio().getTransactions().get(0).getType(), is(to)); + assertThat(fixture.portfolio().getTransactions().get(0).getCrossEntry(), is(nullValue())); + } + + private void assertInlinePortfolioBuySellReversal(PortfolioTransaction.Type from, PortfolioTransaction.Type to, + AccountTransaction.Type accountType) throws Exception + { + var fixture = ledgerBuySellFixture(from); + var portfolioTransaction = fixture.portfolio().getTransactions().get(0); + var accountTransaction = fixture.account().getTransactions().get(0); + var portfolioUUID = portfolioTransaction.getUUID(); + var accountUUID = accountTransaction.getUUID(); + + assertThat(new TransactionTypeEditingSupport(fixture.client()).canEdit(portfolioTransaction), is(true)); + setTypeValue(new TransactionTypeEditingSupport(fixture.client()), portfolioTransaction, from, to); + + assertThat(fixture.account().getTransactions().size(), is(1)); + assertThat(fixture.portfolio().getTransactions().size(), is(1)); + assertThat(fixture.portfolio().getTransactions().get(0).getUUID(), is(portfolioUUID)); + assertThat(fixture.account().getTransactions().get(0).getUUID(), is(accountUUID)); + assertThat(fixture.portfolio().getTransactions().get(0).getType(), is(to)); + assertThat(fixture.account().getTransactions().get(0).getType(), is(accountType)); + assertSame(fixture.portfolio().getTransactions().get(0), fixture.account().getTransactions().get(0) + .getCrossEntry().getCrossTransaction(fixture.account().getTransactions().get(0))); + } + + private void assertInlineDeliveryReversal(PortfolioTransaction.Type from, PortfolioTransaction.Type to) + throws Exception + { + var fixture = ledgerDeliveryFixture(from); + var delivery = fixture.portfolio().getTransactions().get(0); + var portfolioUUID = delivery.getUUID(); + + assertThat(new TransactionTypeEditingSupport(fixture.client()).canEdit(delivery), is(true)); + setTypeValue(new TransactionTypeEditingSupport(fixture.client()), delivery, from, to); + + assertThat(fixture.account().getTransactions().isEmpty(), is(true)); + assertThat(fixture.portfolio().getTransactions().size(), is(1)); + assertThat(fixture.portfolio().getTransactions().get(0).getUUID(), is(portfolioUUID)); + assertThat(fixture.portfolio().getTransactions().get(0).getType(), is(to)); + assertThat(fixture.portfolio().getTransactions().get(0).getCrossEntry(), is(nullValue())); + assertLedgerStructurallyValid(fixture.client()); + } + + private void assertInlineDeliveryToBuySell(PortfolioTransaction.Type from, PortfolioTransaction.Type to, + AccountTransaction.Type accountType) throws Exception + { + var fixture = ledgerDeliveryFixture(from); + var delivery = fixture.portfolio().getTransactions().get(0); + var portfolioUUID = delivery.getUUID(); + + assertThat(new TransactionTypeEditingSupport(fixture.client()).canEdit(delivery), is(true)); + setTypeValue(new TransactionTypeEditingSupport(fixture.client()), delivery, from, to); + + assertThat(fixture.account().getTransactions().size(), is(1)); + assertThat(fixture.portfolio().getTransactions().size(), is(1)); + assertThat(fixture.portfolio().getTransactions().get(0).getUUID(), is(portfolioUUID)); + assertThat(fixture.portfolio().getTransactions().get(0).getType(), is(to)); + assertThat(fixture.account().getTransactions().get(0).getType(), is(accountType)); + assertSame(fixture.portfolio().getTransactions().get(0), fixture.account().getTransactions().get(0) + .getCrossEntry().getCrossTransaction(fixture.account().getTransactions().get(0))); + assertLedgerStructurallyValid(fixture.client()); + } + + private void assertInlineAccountTransferReversal(AccountTransaction.Type from, AccountTransaction.Type to) + throws Exception + { + var fixture = ledgerAccountTransferFixture(); + AccountTransaction transaction = from == AccountTransaction.Type.TRANSFER_OUT + ? fixture.source().getTransactions().get(0) + : fixture.target().getTransactions().get(0); + + assertThat(new TransactionTypeEditingSupport(fixture.client()).canEdit(transaction), is(true)); + setTypeValue(new TransactionTypeEditingSupport(fixture.client()), transaction, from, to); + + assertThat(fixture.source().getTransactions().size(), is(1)); + assertThat(fixture.target().getTransactions().size(), is(1)); + assertThat(fixture.source().getTransactions().get(0).getType(), is(AccountTransaction.Type.TRANSFER_IN)); + assertThat(fixture.target().getTransactions().get(0).getType(), is(AccountTransaction.Type.TRANSFER_OUT)); + assertSame(fixture.source().getTransactions().get(0), fixture.target().getTransactions().get(0) + .getCrossEntry().getCrossTransaction(fixture.target().getTransactions().get(0))); + } + + private void assertInlineAccountBuySellReversal(AccountTransaction.Type from, AccountTransaction.Type to, + PortfolioTransaction.Type portfolioType) throws Exception + { + var fixture = ledgerBuySellFixture(from == AccountTransaction.Type.BUY ? PortfolioTransaction.Type.BUY + : PortfolioTransaction.Type.SELL); + var accountTransaction = fixture.account().getTransactions().get(0); + var portfolioTransaction = fixture.portfolio().getTransactions().get(0); + var accountUUID = accountTransaction.getUUID(); + var portfolioUUID = portfolioTransaction.getUUID(); + + assertThat(new TransactionTypeEditingSupport(fixture.client()).canEdit(accountTransaction), is(true)); + setTypeValue(new TransactionTypeEditingSupport(fixture.client()), accountTransaction, from, to); + + assertThat(fixture.account().getTransactions().size(), is(1)); + assertThat(fixture.portfolio().getTransactions().size(), is(1)); + assertThat(fixture.account().getTransactions().get(0).getUUID(), is(accountUUID)); + assertThat(fixture.portfolio().getTransactions().get(0).getUUID(), is(portfolioUUID)); + assertThat(fixture.account().getTransactions().get(0).getType(), is(to)); + assertThat(fixture.portfolio().getTransactions().get(0).getType(), is(portfolioType)); + assertSame(fixture.portfolio().getTransactions().get(0), fixture.account().getTransactions().get(0) + .getCrossEntry().getCrossTransaction(fixture.account().getTransactions().get(0))); + } + + private void assertInlineAccountOnlyToggle(AccountTransaction.Type from, AccountTransaction.Type to) + throws Exception + { + var fixture = ledgerAccountOnlyFixture(from); + var transaction = fixture.source().getTransactions().get(0); + var projectionUUID = transaction.getUUID(); + + assertThat(new TransactionTypeEditingSupport(fixture.client()).canEdit(transaction), is(true)); + setTypeValue(new TransactionTypeEditingSupport(fixture.client()), transaction, from, to); + + assertThat(fixture.source().getTransactions().size(), is(1)); + assertThat(fixture.source().getTransactions().get(0).getUUID(), is(projectionUUID)); + assertThat(fixture.source().getTransactions().get(0).getType(), is(to)); + assertThat(fixture.source().getTransactions().get(0).getAmount(), is(Values.Amount.factorize(123))); + } + + private void assertForbiddenTypeEdit(Client client, Object element, Object current, Object target) + throws Exception + { + var support = new TransactionTypeEditingSupport(client); + setTypeItems(support, current, target); + assertThrows(UnsupportedOperationException.class, () -> support.setValue(element, 1)); + assertLedgerStructurallyValid(client); + } + + private void assertUnsupportedPostingForexBuySellTypeEdit(PortfolioTransaction.Type from, + PortfolioTransaction.Type to, AccountTransaction.Type accountTo) throws Exception + { + var fixture = ledgerBuySellFixture(from); + var accountTransaction = fixture.account().getTransactions().get(0); + var portfolioTransaction = fixture.portfolio().getTransactions().get(0); + var cashPosting = ledgerPosting(portfolioTransaction, "CASH"); + var accountUUID = accountTransaction.getUUID(); + var portfolioUUID = portfolioTransaction.getUUID(); + + cashPosting.getClass().getMethod("setForexAmount", Long.class).invoke(cashPosting, + Values.Amount.factorize(246)); + cashPosting.getClass().getMethod("setForexCurrency", String.class).invoke(cashPosting, CurrencyUnit.USD); + cashPosting.getClass().getMethod("setExchangeRate", java.math.BigDecimal.class).invoke(cashPosting, + java.math.BigDecimal.valueOf(0.5)); + + var support = new TransactionTypeEditingSupport(fixture.client()); + + assertThat(support.canEdit(accountTransaction), is(false)); + assertThat(supportsTypeTransition(support, accountTransaction, accountTransaction.getType(), accountTo), + is(false)); + assertThat(supportsTypeTransition(support, portfolioTransaction, from, to), is(false)); + + assertThat(fixture.account().getTransactions().size(), is(1)); + assertThat(fixture.portfolio().getTransactions().size(), is(1)); + assertThat(fixture.account().getTransactions().get(0).getUUID(), is(accountUUID)); + assertThat(fixture.portfolio().getTransactions().get(0).getUUID(), is(portfolioUUID)); + assertThat(fixture.account().getTransactions().get(0).getType().name(), is(from.name())); + assertThat(fixture.portfolio().getTransactions().get(0).getType(), is(from)); + assertThat(cashPosting.getClass().getMethod("getForexAmount").invoke(cashPosting), + is(Values.Amount.factorize(246))); + assertThat(cashPosting.getClass().getMethod("getForexCurrency").invoke(cashPosting), is(CurrencyUnit.USD)); + assertThat(cashPosting.getClass().getMethod("getExchangeRate").invoke(cashPosting), + is(java.math.BigDecimal.valueOf(0.5))); + } + + private void setTypeValue(TransactionTypeEditingSupport support, Object element, Object current, Object target) + throws Exception + { + setTypeItems(support, current, target); + support.setValue(element, 1); + } + + private void setTypeItems(TransactionTypeEditingSupport support, Object current, Object target) throws Exception + { + Field field = TransactionTypeEditingSupport.class.getDeclaredField("comboBoxItems"); + field.setAccessible(true); + field.set(support, new ArrayList<>(List.of(current, target))); + } + + private boolean supportsTypeTransition(TransactionTypeEditingSupport support, Transaction transaction, + Enum current, Enum target) throws Exception + { + Method method = TransactionTypeEditingSupport.class.getDeclaredMethod("supportsTransition", Transaction.class, + Enum.class, Enum.class); + method.setAccessible(true); + return (Boolean) method.invoke(support, transaction, current, target); + } + + private void setOwnerComboItems(TransactionOwnerListEditingSupport support, Object owner) throws Exception + { + Field field = TransactionOwnerListEditingSupport.class.getDeclaredField("comboBoxItems"); + field.setAccessible(true); + field.set(support, new ArrayList<>(List.of(owner))); + } + + private void setOwnerValue(Client client, Transaction transaction, TransactionOwnerListEditingSupport.EditMode mode, + Object owner) throws Exception + { + var support = new TransactionOwnerListEditingSupport(client, mode); + + assertThat(support.canEdit(transaction), is(true)); + setOwnerComboItems(support, owner); + support.setValue(transaction, 0); + } + + private void assertUnsupportedLedgerExDateEdit(Client client, AccountTransaction transaction) throws Exception + { + var originalExDate = transaction.getExDate(); + var entryUUID = ledgerEntryValue(transaction, "getUUID"); + var projectionUUID = transaction.getUUID(); + var support = new ExDateEditingSupport(client); + + assertThat(support.canEdit(transaction), is(false)); + var exception = assertThrows(UnsupportedOperationException.class, + () -> support.setValue(transaction, "2026-06-20")); + + assertThat(exception.getMessage(), is(LedgerDiagnosticCode.LEDGER_UI_009 + .message(Messages.LedgerExDateEditingSupportNoSafeEditorPolicyBlocked))); + assertThat(transaction.getExDate(), is(originalExDate)); + assertThat(ledgerEntryValue(transaction, "getUUID"), is(entryUUID)); + assertThat(transaction.getUUID(), is(projectionUUID)); + assertLedgerStructurallyValid(client); + } + + private AccountTransaction findAccountTransaction(Account account, String uuid) + { + return account.getTransactions().stream().filter(transaction -> transaction.getUUID().equals(uuid)).findFirst() + .orElseThrow(); + } + + private PortfolioTransaction findPortfolioTransaction(Portfolio portfolio, String uuid) + { + return portfolio.getTransactions().stream().filter(transaction -> transaction.getUUID().equals(uuid)) + .findFirst().orElseThrow(); + } + + private void assertPlanRefResolves(InvestmentPlan plan, Client client, Object owner, String projectionUUID) + { + var transactions = plan.getTransactions(client); + + assertThat(transactions.size(), is(1)); + assertSame(owner, transactions.get(0).getOwner()); + assertThat(transactions.get(0).getTransaction().getUUID(), is(projectionUUID)); + } + + private Object ledgerEntryValue(Transaction transaction, String method) throws Exception + { + Object entry = ledgerEntry(transaction); + return entry.getClass().getMethod(method).invoke(entry); + } + + private Object ledgerProjectionValue(Transaction transaction, String method) throws Exception + { + Object projection = transaction.getClass().getMethod("getLedgerProjectionRef").invoke(transaction); + return projection.getClass().getMethod(method).invoke(projection); + } + + private Object ledgerEntry(Transaction transaction) throws Exception + { + return transaction.getClass().getMethod("getLedgerEntry").invoke(transaction); + } + + private Object ledgerPosting(Transaction transaction, String type) throws Exception + { + var entry = ledgerEntry(transaction); + var postings = (Iterable) entry.getClass().getMethod("getPostings").invoke(entry); + + for (var posting : postings) + { + if (type.equals(posting.getClass().getMethod("getType").invoke(posting).toString())) + return posting; + } + + throw new AssertionError("Missing ledger posting of type " + type); + } + + private Client reloadXml(Client client) throws Exception + { + return ClientFactory.load(new ByteArrayInputStream(saveXml(client).getBytes(StandardCharsets.UTF_8))); + } + + private Client reloadProtobuf(Client client) throws Exception + { + var file = File.createTempFile("ledger-all-transactions-view", ".portfolio"); + + try + { + Files.write(file.toPath(), saveProtobuf(client)); + return ClientFactory.load(file, null, new NullProgressMonitor()); + } + finally + { + Files.deleteIfExists(file.toPath()); + } + } + + private String saveXml(Client client) throws Exception + { + var file = File.createTempFile("ledger-all-transactions-view", ".xml"); + + try + { + ClientFactory.save(client, file); + return Files.readString(file.toPath(), StandardCharsets.UTF_8); + } + finally + { + Files.deleteIfExists(file.toPath()); + } + } + + private byte[] saveProtobuf(Client client) throws Exception + { + var file = File.createTempFile("ledger-all-transactions-view", ".portfolio"); + + try + { + ClientFactory.saveAs(client, file, null, EnumSet.of(SaveFlag.BINARY, SaveFlag.COMPRESSED)); + return Files.readAllBytes(file.toPath()); + } + finally + { + Files.deleteIfExists(file.toPath()); + } + } + + private long ledgerPostingShares(Transaction transaction) throws Exception + { + var entry = ledgerEntry(transaction); + var postings = (List) entry.getClass().getMethod("getPostings").invoke(entry); + + return postings.stream().map(posting -> { + try + { + return Long.valueOf((long) posting.getClass().getMethod("getShares").invoke(posting)); + } + catch (ReflectiveOperationException e) + { + throw new IllegalStateException(e); + } + }).filter(shares -> shares.longValue() != 0).findFirst().orElseThrow().longValue(); + } + + private LocalDateTime ledgerPostingExDate(Transaction transaction) throws Exception + { + var entry = ledgerEntry(transaction); + var postings = (List) entry.getClass().getMethod("getPostings").invoke(entry); + + for (Object posting : postings) + { + var parameters = (List) posting.getClass().getMethod("getParameters").invoke(posting); + for (Object parameter : parameters) + { + if ("EX_DATE".equals(parameter.getClass().getMethod("getType").invoke(parameter).toString())) + return (LocalDateTime) parameter.getClass().getMethod("getValue").invoke(parameter); + } + } + + throw new IllegalStateException("Ledger EX_DATE parameter was not found"); + } + + private InvestmentPlan addInvestmentPlanRef(Client client, Transaction transaction) throws Exception + { + var plan = new InvestmentPlan("Plan"); + client.addPlan(plan); + + Object entry = ledgerEntry(transaction); + Object projectionRef = transaction.getClass().getMethod("getLedgerProjectionRef").invoke(transaction); + var executionRef = new InvestmentPlan.LedgerExecutionRef(); + + setField(executionRef, "ledgerEntryUUID", entry.getClass().getMethod("getUUID").invoke(entry)); + setField(executionRef, "projectionUUID", projectionRef.getClass().getMethod("getUUID").invoke(projectionRef)); + setField(executionRef, "projectionRole", projectionRef.getClass().getMethod("getRole").invoke(projectionRef)); + + plan.addLedgerExecutionRef(executionRef); + + return plan; + } + + private void setField(Object object, String name, Object value) throws Exception + { + Field field = object.getClass().getDeclaredField(name); + field.setAccessible(true); + field.set(object, value); + } + + + private void assertLedgerStructurallyValid(Client client) throws Exception + { + var ledger = Client.class.getMethod("getLedger").invoke(client); + Class validator = Class.forName("name.abuchen.portfolio.model.ledger.LedgerStructuralValidator", true, + Client.class.getClassLoader()); + Method validate = validator.getMethod("validate", ledger.getClass()); + Object result = validate.invoke(null, ledger); + assertThat((Boolean) result.getClass().getMethod("isOK").invoke(result), is(true)); + } + + private Fixture ledgerAccountTransferFixture() + { + var fixture = baseAccountFixture(); + + new LedgerAccountTransferTransactionCreator(fixture.client()).create(fixture.source(), fixture.target(), + DATE_TIME, Values.Amount.factorize(123), CurrencyUnit.EUR, Values.Amount.factorize(123), + CurrencyUnit.EUR, null, null, "note", "source"); + + return fixture; + } + + private Fixture ledgerAccountOnlyFixture(AccountTransaction.Type type) + { + var fixture = baseAccountFixture(); + + new LedgerAccountOnlyTransactionCreator(fixture.client()).create(fixture.source(), type, DATE_TIME, + Values.Amount.factorize(123), CurrencyUnit.EUR, null, List.of(), "note", "source"); + + return fixture; + } + + private Fixture ledgerAccountOnlyWithSecurityFixture(AccountTransaction.Type type) + { + var fixture = baseAccountFixtureWithSecurity(); + + new LedgerAccountOnlyTransactionCreator(fixture.client()).create(fixture.source(), type, DATE_TIME, + Values.Amount.factorize(123), CurrencyUnit.EUR, fixture.security(), List.of(), "note", + "source"); + + return fixture; + } + + private Fixture ledgerDividendFixture() + { + var fixture = baseAccountFixtureWithSecurity(); + + new LedgerDividendTransactionCreator(fixture.client()).create(fixture.source(), DATE_TIME, + Values.Amount.factorize(123), CurrencyUnit.EUR, fixture.security(), + Values.Share.factorize(5), DATE_TIME.plusDays(2), null, null, List.of(), "note", "source"); + + return fixture; + } + + private Fixture legacyDividendFixture() + { + var fixture = baseAccountFixtureWithSecurity(); + var transaction = new AccountTransaction(AccountTransaction.Type.DIVIDENDS); + + transaction.setDateTime(DATE_TIME); + transaction.setAmount(Values.Amount.factorize(123)); + transaction.setCurrencyCode(CurrencyUnit.EUR); + transaction.setSecurity(fixture.security()); + transaction.setShares(Values.Share.factorize(5)); + transaction.setExDate(DATE_TIME.plusDays(2)); + transaction.setNote("note"); + fixture.source().addTransaction(transaction); + + return fixture; + } + + private BuySellFixture ledgerBuySellFixture(PortfolioTransaction.Type type) + { + var fixture = baseSecurityFixture(); + + new LedgerBuySellTransactionCreator(fixture.client()).create(fixture.portfolio(), fixture.account(), type, + DATE_TIME, Values.Amount.factorize(123), CurrencyUnit.EUR, fixture.security(), + Values.Share.factorize(5), List.of(), "note", "source"); + + return fixture; + } + + private BuySellFixture ledgerDeliveryFixture(PortfolioTransaction.Type type) + { + var fixture = baseSecurityFixture(); + + new LedgerDeliveryTransactionCreator(fixture.client()).create(fixture.portfolio(), type, DATE_TIME, + Values.Amount.factorize(123), CurrencyUnit.EUR, fixture.security(), Values.Share.factorize(5), + null, null, List.of(), "note", "source"); + + return fixture; + } + + private PortfolioFixture ledgerPortfolioTransferFixture() + { + var fixture = basePortfolioFixture(); + + new LedgerPortfolioTransferTransactionCreator(fixture.client()).create(fixture.source(), fixture.target(), + fixture.security(), DATE_TIME, Values.Share.factorize(5), Values.Amount.factorize(123), + CurrencyUnit.EUR, "note", "source"); + + return fixture; + } + + private Fixture legacyAccountOnlyFixture(AccountTransaction.Type type) + { + var fixture = baseAccountFixture(); + var transaction = new AccountTransaction(type); + + transaction.setDateTime(DATE_TIME); + transaction.setAmount(Values.Amount.factorize(123)); + transaction.setCurrencyCode(CurrencyUnit.EUR); + transaction.setNote("note"); + fixture.source().addTransaction(transaction); + + return fixture; + } + + private LegacyAccountTransferFixture legacyAccountTransferFixture() + { + var fixture = baseAccountFixture(); + var transfer = new AccountTransferEntry(fixture.source(), fixture.target()); + + transfer.setDate(DATE_TIME); + transfer.setAmount(Values.Amount.factorize(123)); + transfer.setCurrencyCode(CurrencyUnit.EUR); + transfer.setNote("note"); + transfer.insert(); + + return new LegacyAccountTransferFixture(fixture.client(), fixture.source(), fixture.target(), transfer); + } + + private LegacyBuySellFixture legacyBuySellFixture(PortfolioTransaction.Type type) + { + var fixture = baseSecurityFixture(); + var entry = new BuySellEntry(fixture.portfolio(), fixture.account()); + + entry.setType(type); + entry.setDate(DATE_TIME); + entry.setSecurity(fixture.security()); + entry.setShares(Values.Share.factorize(5)); + entry.setAmount(Values.Amount.factorize(123)); + entry.setCurrencyCode(CurrencyUnit.EUR); + entry.setNote("note"); + entry.insert(); + + return new LegacyBuySellFixture(fixture.client(), fixture.account(), fixture.portfolio(), fixture.security(), + entry); + } + + private BuySellFixture legacyDeliveryFixture(PortfolioTransaction.Type type) + { + var fixture = baseSecurityFixture(); + var transaction = new PortfolioTransaction(type); + + transaction.setDateTime(DATE_TIME); + transaction.setSecurity(fixture.security()); + transaction.setShares(Values.Share.factorize(5)); + transaction.setAmount(Values.Amount.factorize(123)); + transaction.setCurrencyCode(CurrencyUnit.EUR); + transaction.setNote("note"); + fixture.portfolio().addTransaction(transaction); + + return fixture; + } + + private LegacyPortfolioTransferFixture legacyPortfolioTransferFixture() + { + var fixture = basePortfolioFixture(); + var transfer = new PortfolioTransferEntry(fixture.source(), fixture.target()); + + transfer.setDate(DATE_TIME); + transfer.setSecurity(fixture.security()); + transfer.setShares(Values.Share.factorize(5)); + transfer.setAmount(Values.Amount.factorize(123)); + transfer.setCurrencyCode(CurrencyUnit.EUR); + transfer.setNote("note"); + transfer.insert(); + + return new LegacyPortfolioTransferFixture(fixture.client(), fixture.source(), fixture.target(), + fixture.security(), transfer); + } + + private Fixture baseAccountFixture() + { + var client = new Client(); + var source = account("Source", CurrencyUnit.EUR); + var target = account("Target", CurrencyUnit.EUR); + + client.addAccount(source); + client.addAccount(target); + + return new Fixture(client, source, target, null); + } + + private Fixture baseAccountFixtureWithSecurity() + { + var client = new Client(); + var source = account("Source", CurrencyUnit.EUR); + var target = account("Target", CurrencyUnit.EUR); + var security = security(); + + client.addAccount(source); + client.addAccount(target); + client.addSecurity(security); + + return new Fixture(client, source, target, security); + } + + private BuySellFixture baseSecurityFixture() + { + var client = new Client(); + var account = account("Account", CurrencyUnit.EUR); + var portfolio = new Portfolio("Portfolio"); + var security = security(); + + portfolio.setReferenceAccount(account); + portfolio.setUpdatedAt(FIXTURE_UPDATED_AT); + + client.addAccount(account); + client.addPortfolio(portfolio); + client.addSecurity(security); + + return new BuySellFixture(client, account, portfolio, security); + } + + private PortfolioFixture basePortfolioFixture() + { + var client = new Client(); + var source = new Portfolio("Source"); + var target = new Portfolio("Target"); + var security = security(); + + source.setUpdatedAt(FIXTURE_UPDATED_AT); + target.setUpdatedAt(FIXTURE_UPDATED_AT); + + client.addPortfolio(source); + client.addPortfolio(target); + client.addSecurity(security); + + return new PortfolioFixture(client, source, target, security); + } + + private Account account(String name, String currency) + { + var account = new Account(name); + + account.setCurrencyCode(currency); + account.setUpdatedAt(FIXTURE_UPDATED_AT); + + return account; + } + + private Security security() + { + var security = new Security("Security", CurrencyUnit.EUR); + security.setUpdatedAt(FIXTURE_UPDATED_AT); + + return security; + } + + private record Fixture(Client client, Account source, Account target, Security security) + { + } + + private record BuySellFixture(Client client, Account account, Portfolio portfolio, Security security) + { + } + + private record PortfolioFixture(Client client, Portfolio source, Portfolio target, Security security) + { + } + + private record LegacyAccountTransferFixture(Client client, Account source, Account target, + AccountTransferEntry transfer) + { + } + + private record LegacyBuySellFixture(Client client, Account account, Portfolio portfolio, Security security, + BuySellEntry entry) + { + } + + private record LegacyPortfolioTransferFixture(Client client, Portfolio source, Portfolio target, Security security, + PortfolioTransferEntry transfer) + { + } +} diff --git a/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/views/TransactionContextMenuTest.java b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/views/TransactionContextMenuTest.java new file mode 100644 index 0000000000..207be23c00 --- /dev/null +++ b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/views/TransactionContextMenuTest.java @@ -0,0 +1,424 @@ +package name.abuchen.portfolio.ui.views; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.Test; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.AccountTransferEntry; +import name.abuchen.portfolio.model.BuySellEntry; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.ClientFactory; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.PortfolioTransaction; +import name.abuchen.portfolio.model.PortfolioTransferEntry; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.TransactionPair; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountOnlyTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountTransferTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerBuySellTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerDeliveryTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferTransactionCreator; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Values; + +/** + * Tests transaction context menu behavior for legacy and ledger-backed rows. + * These tests make sure batch actions delete whole ledger entries instead of only one visible runtime row. + */ +@SuppressWarnings("nls") +public class TransactionContextMenuTest +{ + private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 6, 7, 8, 9); + + /** + * Checks the UI scenario: ledger backed buy/sell selection supports convert to delivery menu action. + * The visible transaction row must stay consistent with the ledger entry. + * This protects restored master behavior from falling back to legacy mutation. + */ + @Test + public void testLedgerBackedBuySellSelectionSupportsConvertToDeliveryMenuAction() + { + var fixture = fixture(); + + new LedgerBuySellTransactionCreator(fixture.client()).create(fixture.portfolio(), fixture.account(), + PortfolioTransaction.Type.BUY, DATE_TIME, Values.Amount.factorize(123), CurrencyUnit.EUR, + fixture.security(), Values.Share.factorize(5), List.of(), "note", "source"); + + var transaction = fixture.portfolio().getTransactions().get(0); + + assertThat(TransactionContextMenu.supportsBuySellToDeliveryAction( + List.of(new TransactionPair<>(fixture.portfolio(), transaction))), is(true)); + assertThat(TransactionContextMenu.supportsDeliveryToBuySellAction( + List.of(new TransactionPair<>(fixture.portfolio(), transaction))), is(false)); + } + + /** + * Checks the UI scenario: ledger backed delivery selection supports convert to buy/sell menu action. + * The visible transaction row must stay consistent with the ledger entry. + * This protects restored master behavior from falling back to legacy mutation. + */ + @Test + public void testLedgerBackedDeliverySelectionSupportsConvertToBuySellMenuAction() + { + var fixture = fixture(); + + new LedgerDeliveryTransactionCreator(fixture.client()).create(fixture.portfolio(), + PortfolioTransaction.Type.DELIVERY_INBOUND, DATE_TIME, Values.Amount.factorize(123), + CurrencyUnit.EUR, fixture.security(), Values.Share.factorize(5), null, null, List.of(), "note", + "source"); + + var transaction = fixture.portfolio().getTransactions().get(0); + + assertThat(TransactionContextMenu.supportsDeliveryToBuySellAction( + List.of(new TransactionPair<>(fixture.portfolio(), transaction))), is(true)); + assertThat(TransactionContextMenu.supportsBuySellToDeliveryAction( + List.of(new TransactionPair<>(fixture.portfolio(), transaction))), is(false)); + } + + /** + * Checks the UI scenario: mixed security selection does not expose conversion menu actions. + * The visible transaction row must stay consistent with the ledger entry. + * This protects restored master behavior from falling back to legacy mutation. + */ + @Test + public void testMixedSecuritySelectionDoesNotExposeConversionMenuActions() + { + var fixture = fixture(); + + new LedgerBuySellTransactionCreator(fixture.client()).create(fixture.portfolio(), fixture.account(), + PortfolioTransaction.Type.BUY, DATE_TIME, Values.Amount.factorize(123), CurrencyUnit.EUR, + fixture.security(), Values.Share.factorize(5), List.of(), "note", "source"); + new LedgerDeliveryTransactionCreator(fixture.client()).create(fixture.portfolio(), + PortfolioTransaction.Type.DELIVERY_INBOUND, DATE_TIME.plusDays(1), + Values.Amount.factorize(123), CurrencyUnit.EUR, fixture.security(), + Values.Share.factorize(5), null, null, List.of(), "note", "source"); + + var pairs = fixture.portfolio().getTransactions().stream() // + .map(transaction -> new TransactionPair<>(fixture.portfolio(), transaction)) // + .toList(); + + assertThat(TransactionContextMenu.supportsBuySellToDeliveryAction(pairs), is(false)); + assertThat(TransactionContextMenu.supportsDeliveryToBuySellAction(pairs), is(false)); + } + + /** + * Checks the UI scenario: ledger backed buy/sell selection delete removes ledger entry once. + * The visible transaction row must stay consistent with the ledger entry. + * This protects restored master behavior from falling back to legacy mutation. + */ + @Test + public void testLedgerBackedBuySellSelectionDeleteRemovesLedgerEntryOnce() throws Exception + { + var fixture = fixture(); + var entry = new LedgerBuySellTransactionCreator(fixture.client()).create(fixture.portfolio(), fixture.account(), + PortfolioTransaction.Type.BUY, DATE_TIME, Values.Amount.factorize(123), CurrencyUnit.EUR, + fixture.security(), Values.Share.factorize(5), List.of(), "note", "source"); + var portfolioPair = new TransactionPair<>(fixture.portfolio(), entry.getPortfolioTransaction()); + var accountPair = new TransactionPair<>(fixture.account(), entry.getAccountTransaction()); + + assertThat(portfolioPair.getTransaction().getUUID().equals(accountPair.getTransaction().getUUID()), is(false)); + assertThat(portfolioPair.getLedgerEntryUUID(), is(accountPair.getLedgerEntryUUID())); + + TransactionContextMenu.deleteTransactions(fixture.client(), new Object[] { portfolioPair, accountPair }); + + assertTrue(fixture.account().getTransactions().isEmpty()); + assertTrue(fixture.portfolio().getTransactions().isEmpty()); + assertThat(reloadXml(fixture.client()).getAllTransactions().size(), is(0)); + } + + /** + * Checks the UI scenario: ledger backed account transfer selection delete removes ledger entry once. + * The visible transaction row must stay consistent with the ledger entry. + * This protects restored master behavior from falling back to legacy mutation. + */ + @Test + public void testLedgerBackedAccountTransferSelectionDeleteRemovesLedgerEntryOnce() throws Exception + { + var fixture = accountTransferFixture(); + var transfer = new LedgerAccountTransferTransactionCreator(fixture.client()).create(fixture.source(), + fixture.target(), DATE_TIME, Values.Amount.factorize(123), CurrencyUnit.EUR, + Values.Amount.factorize(123), CurrencyUnit.EUR, null, null, "note", "source"); + var sourcePair = new TransactionPair<>(fixture.source(), transfer.getSourceTransaction()); + var targetPair = new TransactionPair<>(fixture.target(), transfer.getTargetTransaction()); + + assertThat(sourcePair.getTransaction().getUUID().equals(targetPair.getTransaction().getUUID()), is(false)); + assertThat(sourcePair.getLedgerEntryUUID(), is(targetPair.getLedgerEntryUUID())); + + TransactionContextMenu.deleteTransactions(fixture.client(), new Object[] { sourcePair, targetPair }); + + assertTrue(fixture.source().getTransactions().isEmpty()); + assertTrue(fixture.target().getTransactions().isEmpty()); + assertThat(reloadXml(fixture.client()).getAllTransactions().size(), is(0)); + } + + /** + * Checks the UI scenario: ledger backed portfolio transfer selection delete removes ledger entry once. + * The visible transaction row must stay consistent with the ledger entry. + * This protects restored master behavior from falling back to legacy mutation. + */ + @Test + public void testLedgerBackedPortfolioTransferSelectionDeleteRemovesLedgerEntryOnce() throws Exception + { + var fixture = portfolioTransferFixture(); + var transfer = new LedgerPortfolioTransferTransactionCreator(fixture.client()).create(fixture.source(), + fixture.target(), fixture.security(), DATE_TIME, Values.Share.factorize(5), + Values.Amount.factorize(123), CurrencyUnit.EUR, "note", "source"); + var sourcePair = new TransactionPair<>(fixture.source(), transfer.getSourceTransaction()); + var targetPair = new TransactionPair<>(fixture.target(), transfer.getTargetTransaction()); + + assertThat(sourcePair.getTransaction().getUUID().equals(targetPair.getTransaction().getUUID()), is(false)); + assertThat(sourcePair.getLedgerEntryUUID(), is(targetPair.getLedgerEntryUUID())); + + TransactionContextMenu.deleteTransactions(fixture.client(), new Object[] { sourcePair, targetPair }); + + assertTrue(fixture.source().getTransactions().isEmpty()); + assertTrue(fixture.target().getTransactions().isEmpty()); + assertThat(reloadXml(fixture.client()).getAllTransactions().size(), is(0)); + } + + /** + * Checks the UI scenario: mixed selection deduplicates ledger entries and deletes legacy transactions. + * The visible transaction row must stay consistent with the ledger entry. + * This protects restored master behavior from falling back to legacy mutation. + */ + @Test + public void testMixedSelectionDeduplicatesLedgerEntriesAndDeletesLegacyTransactions() throws Exception + { + var fixture = fixture(); + var firstLedgerEntry = new LedgerBuySellTransactionCreator(fixture.client()).create(fixture.portfolio(), + fixture.account(), PortfolioTransaction.Type.BUY, DATE_TIME, Values.Amount.factorize(123), + CurrencyUnit.EUR, fixture.security(), Values.Share.factorize(5), List.of(), "note", + "source"); + var secondLedgerEntry = new LedgerBuySellTransactionCreator(fixture.client()).create(fixture.portfolio(), + fixture.account(), PortfolioTransaction.Type.BUY, DATE_TIME.plusDays(1), + Values.Amount.factorize(456), CurrencyUnit.EUR, fixture.security(), Values.Share.factorize(7), + List.of(), "note", "source"); + var legacyTransaction = new AccountTransaction(); + + legacyTransaction.setType(AccountTransaction.Type.DEPOSIT); + legacyTransaction.setDateTime(DATE_TIME.plusDays(2)); + legacyTransaction.setAmount(Values.Amount.factorize(99)); + legacyTransaction.setCurrencyCode(CurrencyUnit.EUR); + fixture.account().addTransaction(legacyTransaction); + + TransactionContextMenu.deleteTransactions(fixture.client(), + new Object[] { new TransactionPair<>(fixture.portfolio(), + firstLedgerEntry.getPortfolioTransaction()), + new TransactionPair<>(fixture.account(), + firstLedgerEntry.getAccountTransaction()), + new TransactionPair<>(fixture.portfolio(), + secondLedgerEntry.getPortfolioTransaction()), + new TransactionPair<>(fixture.account(), legacyTransaction) }); + + assertTrue(fixture.account().getTransactions().isEmpty()); + assertTrue(fixture.portfolio().getTransactions().isEmpty()); + assertThat(reloadXml(fixture.client()).getAllTransactions().size(), is(0)); + } + + /** + * Checks the UI scenario: ledger backed account only selection delete removes ledger entry. + * The visible transaction row must stay consistent with the ledger entry. + * This protects restored master behavior from falling back to legacy mutation. + */ + @Test + public void testLedgerBackedAccountOnlySelectionDeleteRemovesLedgerEntry() throws Exception + { + var fixture = fixture(); + var transaction = new LedgerAccountOnlyTransactionCreator(fixture.client()).create(fixture.account(), + AccountTransaction.Type.FEES, DATE_TIME, Values.Amount.factorize(123), CurrencyUnit.EUR, null, + List.of(), "note", "source"); + + TransactionContextMenu.deleteTransactions(fixture.client(), + new Object[] { new TransactionPair<>(fixture.account(), transaction) }); + + assertTrue(fixture.account().getTransactions().isEmpty()); + assertThat(reloadXml(fixture.client()).getAllTransactions().size(), is(0)); + } + + /** + * Checks the UI scenario: stale ledger backed pair still fails outside selection dedupe. + * The visible transaction row must stay consistent with the ledger entry. + * This protects restored master behavior from falling back to legacy mutation. + */ + @Test + public void testStaleLedgerBackedPairStillFailsOutsideSelectionDedupe() + { + var fixture = fixture(); + var entry = new LedgerBuySellTransactionCreator(fixture.client()).create(fixture.portfolio(), fixture.account(), + PortfolioTransaction.Type.BUY, DATE_TIME, Values.Amount.factorize(123), CurrencyUnit.EUR, + fixture.security(), Values.Share.factorize(5), List.of(), "note", "source"); + var accountPair = new TransactionPair<>(fixture.account(), entry.getAccountTransaction()); + var portfolioPair = new TransactionPair<>(fixture.portfolio(), entry.getPortfolioTransaction()); + + portfolioPair.deleteTransaction(fixture.client()); + + assertThrows(IllegalArgumentException.class, () -> accountPair.deleteTransaction(fixture.client())); + } + + /** + * Checks the UI scenario: legacy cross entry selection delete remains unchanged. + * The visible transaction row must stay consistent with the ledger entry. + * This protects restored master behavior from falling back to legacy mutation. + */ + @Test + public void testLegacyCrossEntrySelectionDeleteRemainsUnchanged() + { + var fixture = fixture(); + var entry = new BuySellEntry(fixture.portfolio(), fixture.account()); + + entry.setType(PortfolioTransaction.Type.BUY); + entry.setDate(DATE_TIME); + entry.setSecurity(fixture.security()); + entry.setShares(Values.Share.factorize(5)); + entry.setAmount(Values.Amount.factorize(123)); + entry.setCurrencyCode(CurrencyUnit.EUR); + entry.insert(); + + TransactionContextMenu.deleteTransactions(fixture.client(), + new Object[] { new TransactionPair<>(fixture.portfolio(), entry.getPortfolioTransaction()), + new TransactionPair<>(fixture.account(), entry.getAccountTransaction()) }); + + assertTrue(fixture.account().getTransactions().isEmpty()); + assertTrue(fixture.portfolio().getTransactions().isEmpty()); + } + + /** + * Checks the UI scenario: legacy account transfer selection delete remains unchanged. + * The visible transaction row must stay consistent with the ledger entry. + * This protects restored master behavior from falling back to legacy mutation. + */ + @Test + public void testLegacyAccountTransferSelectionDeleteRemainsUnchanged() + { + var fixture = accountTransferFixture(); + var transfer = new AccountTransferEntry(fixture.source(), fixture.target()); + + transfer.setDate(DATE_TIME); + transfer.setAmount(Values.Amount.factorize(123)); + transfer.setCurrencyCode(CurrencyUnit.EUR); + transfer.insert(); + + TransactionContextMenu.deleteTransactions(fixture.client(), + new Object[] { new TransactionPair<>(fixture.source(), transfer.getSourceTransaction()), + new TransactionPair<>(fixture.target(), transfer.getTargetTransaction()) }); + + assertTrue(fixture.source().getTransactions().isEmpty()); + assertTrue(fixture.target().getTransactions().isEmpty()); + } + + /** + * Checks the UI scenario: legacy portfolio transfer selection delete remains unchanged. + * The visible transaction row must stay consistent with the ledger entry. + * This protects restored master behavior from falling back to legacy mutation. + */ + @Test + public void testLegacyPortfolioTransferSelectionDeleteRemainsUnchanged() + { + var fixture = portfolioTransferFixture(); + var transfer = new PortfolioTransferEntry(fixture.source(), fixture.target()); + + transfer.setDate(DATE_TIME); + transfer.setSecurity(fixture.security()); + transfer.setShares(Values.Share.factorize(5)); + transfer.setAmount(Values.Amount.factorize(123)); + transfer.setCurrencyCode(CurrencyUnit.EUR); + transfer.insert(); + + TransactionContextMenu.deleteTransactions(fixture.client(), + new Object[] { new TransactionPair<>(fixture.source(), transfer.getSourceTransaction()), + new TransactionPair<>(fixture.target(), transfer.getTargetTransaction()) }); + + assertTrue(fixture.source().getTransactions().isEmpty()); + assertTrue(fixture.target().getTransactions().isEmpty()); + } + + private Client reloadXml(Client client) throws Exception + { + return ClientFactory.load(new ByteArrayInputStream(saveXml(client).getBytes(StandardCharsets.UTF_8))); + } + + private String saveXml(Client client) throws Exception + { + var file = File.createTempFile("ledger-pair-delete", ".xml"); + + try + { + ClientFactory.save(client, file); + return Files.readString(file.toPath(), StandardCharsets.UTF_8); + } + finally + { + Files.deleteIfExists(file.toPath()); + } + } + + private Fixture fixture() + { + var client = new Client(); + var account = new Account("Account"); + var portfolio = new Portfolio("Portfolio"); + var security = new Security("Security", CurrencyUnit.EUR); + + account.setCurrencyCode(CurrencyUnit.EUR); + portfolio.setReferenceAccount(account); + + client.addAccount(account); + client.addPortfolio(portfolio); + client.addSecurity(security); + + return new Fixture(client, account, portfolio, security); + } + + private AccountTransferFixture accountTransferFixture() + { + var client = new Client(); + var source = new Account("Source"); + var target = new Account("Target"); + + source.setCurrencyCode(CurrencyUnit.EUR); + target.setCurrencyCode(CurrencyUnit.EUR); + + client.addAccount(source); + client.addAccount(target); + + return new AccountTransferFixture(client, source, target); + } + + private PortfolioTransferFixture portfolioTransferFixture() + { + var client = new Client(); + var source = new Portfolio("Source"); + var target = new Portfolio("Target"); + var security = new Security("Security", CurrencyUnit.EUR); + + client.addPortfolio(source); + client.addPortfolio(target); + client.addSecurity(security); + + return new PortfolioTransferFixture(client, source, target, security); + } + + private record Fixture(Client client, Account account, Portfolio portfolio, Security security) + { + } + + private record AccountTransferFixture(Client client, Account source, Account target) + { + } + + private record PortfolioTransferFixture(Client client, Portfolio source, Portfolio target, Security security) + { + } +} diff --git a/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/views/actions/AccountTransferLegacyActionGuardTest.java b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/views/actions/AccountTransferLegacyActionGuardTest.java new file mode 100644 index 0000000000..92e2bb7132 --- /dev/null +++ b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/views/actions/AccountTransferLegacyActionGuardTest.java @@ -0,0 +1,990 @@ +package name.abuchen.portfolio.ui.views.actions; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; + +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import org.junit.Test; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.AccountTransferEntry; +import name.abuchen.portfolio.model.BuySellEntry; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.PortfolioTransaction; +import name.abuchen.portfolio.model.PortfolioTransferEntry; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.TransactionPair; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountOnlyTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountTransferTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerBuySellTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerDeliveryTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferTransactionCreator; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Values; +import name.abuchen.portfolio.ui.util.viewers.TransactionTypeEditingSupport; + +/** + * Tests that legacy account-transfer actions stay guarded for ledger-backed rows. + * These tests make sure unsupported UI actions do not mutate ledger-backed projections through legacy paths. + */ +@SuppressWarnings("nls") +public class AccountTransferLegacyActionGuardTest +{ + private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 6, 7, 8, 9); + + /** + * Checks the guardrail scenario: convert transfer to deposit removal splits ledger backed transfer through ledger truth. + * Runtime projections are views only and must not become persisted truth. + * This protects Ledger-V6 from duplicate or partial transaction state. + */ + @Test + public void testConvertTransferToDepositRemovalSplitsLedgerBackedTransferThroughLedgerTruth() + throws ReflectiveOperationException + { + var fixture = ledgerFixture(); + var sourceTransaction = fixture.source().getTransactions().get(0); + var targetTransaction = fixture.target().getTransactions().get(0); + var sourceUUID = sourceTransaction.getUUID(); + var targetUUID = targetTransaction.getUUID(); + + new ConvertTransferToDepositRemovalAction(fixture.client(), List.of(sourceTransaction)).run(); + + assertThat(ledgerEntryCount(fixture.client()), is(2)); + assertThat(fixture.source().getTransactions().size(), is(1)); + assertThat(fixture.target().getTransactions().size(), is(1)); + assertThat(fixture.source().getTransactions().get(0).getType(), is(AccountTransaction.Type.REMOVAL)); + assertThat(fixture.target().getTransactions().get(0).getType(), is(AccountTransaction.Type.DEPOSIT)); + assertThat(fixture.source().getTransactions().get(0).getUUID(), is(sourceUUID)); + assertThat(fixture.target().getTransactions().get(0).getUUID(), is(targetUUID)); + assertThat(fixture.source().getTransactions().get(0).getCrossEntry(), is(nullValue())); + assertThat(fixture.target().getTransactions().get(0).getCrossEntry(), is(nullValue())); + } + + /** + * Checks the guardrail scenario: convert transfer to deposit removal deduplicates ledger backed transfer selection. + * Runtime projections are views only and must not become persisted truth. + * This protects Ledger-V6 from duplicate or partial transaction state. + */ + @Test + public void testConvertTransferToDepositRemovalDeduplicatesLedgerBackedTransferSelection() + throws ReflectiveOperationException + { + var fixture = ledgerFixture(); + var sourceTransaction = fixture.source().getTransactions().get(0); + var targetTransaction = fixture.target().getTransactions().get(0); + + new ConvertTransferToDepositRemovalAction(fixture.client(), List.of(sourceTransaction, targetTransaction)) + .run(); + + assertThat(ledgerEntryCount(fixture.client()), is(2)); + assertThat(fixture.source().getTransactions().size(), is(1)); + assertThat(fixture.target().getTransactions().size(), is(1)); + assertThat(fixture.source().getTransactions().get(0).getType(), is(AccountTransaction.Type.REMOVAL)); + assertThat(fixture.target().getTransactions().get(0).getType(), is(AccountTransaction.Type.DEPOSIT)); + } + + /** + * Checks the guardrail scenario: convert transfer to deposit removal still converts legacy transfer. + * Runtime projections are views only and must not become persisted truth. + * This protects Ledger-V6 from duplicate or partial transaction state. + */ + @Test + public void testConvertTransferToDepositRemovalStillConvertsLegacyTransfer() + { + var fixture = legacyFixture(); + var sourceTransaction = fixture.transfer().getSourceTransaction(); + + new ConvertTransferToDepositRemovalAction(fixture.client(), List.of(sourceTransaction)).run(); + + assertThat(fixture.source().getTransactions().size(), is(1)); + assertThat(fixture.target().getTransactions().size(), is(1)); + assertThat(fixture.source().getTransactions().get(0).getType(), is(AccountTransaction.Type.REMOVAL)); + assertThat(fixture.target().getTransactions().get(0).getType(), is(AccountTransaction.Type.DEPOSIT)); + assertThat(fixture.source().getTransactions().get(0).getAmount(), is(Values.Amount.factorize(123))); + assertThat(fixture.target().getTransactions().get(0).getAmount(), is(Values.Amount.factorize(123))); + } + + /** + * Checks the guardrail scenario: convert transfer to deposit removal rejects non-transfer rows with a UI code. + * Runtime projections are views only and must not become persisted truth. + * This protects Ledger-V6 from duplicate or partial transaction state. + */ + @Test + public void testConvertTransferToDepositRemovalRejectsUnsupportedRowsWithDiagnostic() + { + var fixture = legacyFixture(); + var transaction = new AccountTransaction(AccountTransaction.Type.DEPOSIT); + + transaction.setDateTime(DATE_TIME); + transaction.setAmount(Values.Amount.factorize(123)); + transaction.setCurrencyCode(CurrencyUnit.EUR); + + var exception = assertThrows(IllegalArgumentException.class, + () -> new ConvertTransferToDepositRemovalAction(fixture.client(), List.of(transaction)).run()); + + assertThat(exception.getMessage(), containsString(LedgerDiagnosticCode.LEDGER_UI_018.prefix())); + } + + /** + * Checks the guardrail scenario: revert transfer action reverses ledger backed transfer through ledger truth. + * Runtime projections are views only and must not become persisted truth. + * This protects Ledger-V6 from duplicate or partial transaction state. + */ + @Test + public void testRevertTransferActionReversesLedgerBackedTransferThroughLedgerTruth() + throws ReflectiveOperationException + { + var fixture = ledgerFixture(); + var sourceTransaction = fixture.source().getTransactions().get(0); + var targetTransaction = fixture.target().getTransactions().get(0); + var sourceUUID = sourceTransaction.getUUID(); + var targetUUID = targetTransaction.getUUID(); + + new RevertTransferAction(fixture.client(), new TransactionPair<>(fixture.source(), sourceTransaction)).run(); + + assertThat(ledgerEntryCount(fixture.client()), is(1)); + assertThat(fixture.source().getTransactions().size(), is(1)); + assertThat(fixture.target().getTransactions().size(), is(1)); + assertThat(fixture.source().getTransactions().get(0).getUUID(), is(sourceUUID)); + assertThat(fixture.target().getTransactions().get(0).getUUID(), is(targetUUID)); + assertThat(fixture.source().getTransactions().get(0).getType(), is(AccountTransaction.Type.TRANSFER_IN)); + assertThat(fixture.target().getTransactions().get(0).getType(), is(AccountTransaction.Type.TRANSFER_OUT)); + assertSame(fixture.source().getTransactions().get(0), fixture.target().getTransactions().get(0) + .getCrossEntry().getCrossTransaction(fixture.target().getTransactions().get(0))); + } + + /** + * Checks the guardrail scenario: revert transfer action still reverts legacy transfer. + * Runtime projections are views only and must not become persisted truth. + * This protects Ledger-V6 from duplicate or partial transaction state. + */ + @Test + public void testRevertTransferActionStillRevertsLegacyTransfer() + { + var fixture = legacyFixture(); + var sourceTransaction = fixture.transfer().getSourceTransaction(); + var targetTransaction = fixture.transfer().getTargetTransaction(); + + new RevertTransferAction(fixture.client(), new TransactionPair<>(fixture.source(), sourceTransaction)).run(); + + assertSame(fixture.target(), fixture.transfer().getSourceAccount()); + assertSame(fixture.source(), fixture.transfer().getTargetAccount()); + assertSame(targetTransaction, fixture.transfer().getSourceTransaction()); + assertSame(sourceTransaction, fixture.transfer().getTargetTransaction()); + assertThat(sourceTransaction.getType(), is(AccountTransaction.Type.TRANSFER_IN)); + assertThat(targetTransaction.getType(), is(AccountTransaction.Type.TRANSFER_OUT)); + } + + /** + * Checks the guardrail scenario: transaction type editing support offers ledger backed transfer reversal. + * Runtime projections are views only and must not become persisted truth. + * This protects Ledger-V6 from duplicate or partial transaction state. + */ + @Test + public void testTransactionTypeEditingSupportOffersLedgerBackedTransferReversal() throws Exception + { + var fixture = ledgerFixture(); + var sourceTransaction = fixture.source().getTransactions().get(0); + var support = new TransactionTypeEditingSupport(fixture.client()); + + assertThat(support.canEdit(sourceTransaction), is(true)); + + setTypeValue(support, sourceTransaction, AccountTransaction.Type.TRANSFER_OUT, + AccountTransaction.Type.TRANSFER_IN); + + assertThat(fixture.source().getTransactions().get(0).getType(), is(AccountTransaction.Type.TRANSFER_IN)); + assertThat(fixture.target().getTransactions().get(0).getType(), is(AccountTransaction.Type.TRANSFER_OUT)); + } + + /** + * Checks the guardrail scenario: transaction type editing support still offers legacy transfer reversal. + * Runtime projections are views only and must not become persisted truth. + * This protects Ledger-V6 from duplicate or partial transaction state. + */ + @Test + public void testTransactionTypeEditingSupportStillOffersLegacyTransferReversal() + { + var fixture = legacyFixture(); + var sourceTransaction = fixture.transfer().getSourceTransaction(); + var support = new TransactionTypeEditingSupport(fixture.client()); + + assertThat(support.canEdit(sourceTransaction), is(true)); + } + + /** + * Checks the guardrail scenario: revert transfer action reverses ledger backed portfolio transfer through ledger truth. + * Runtime projections are views only and must not become persisted truth. + * This protects Ledger-V6 from duplicate or partial transaction state. + */ + @Test + public void testRevertTransferActionReversesLedgerBackedPortfolioTransferThroughLedgerTruth() + throws ReflectiveOperationException + { + var fixture = ledgerPortfolioFixture(); + var sourceTransaction = fixture.source().getTransactions().get(0); + var targetTransaction = fixture.target().getTransactions().get(0); + var sourceUUID = sourceTransaction.getUUID(); + var targetUUID = targetTransaction.getUUID(); + + new RevertTransferAction(fixture.client(), new TransactionPair<>(fixture.source(), sourceTransaction)).run(); + + assertThat(ledgerEntryCount(fixture.client()), is(1)); + assertThat(fixture.source().getTransactions().size(), is(1)); + assertThat(fixture.target().getTransactions().size(), is(1)); + assertThat(fixture.source().getTransactions().get(0).getUUID(), is(sourceUUID)); + assertThat(fixture.target().getTransactions().get(0).getUUID(), is(targetUUID)); + assertThat(fixture.source().getTransactions().get(0).getType(), is(PortfolioTransaction.Type.TRANSFER_IN)); + assertThat(fixture.target().getTransactions().get(0).getType(), is(PortfolioTransaction.Type.TRANSFER_OUT)); + assertSame(fixture.source().getTransactions().get(0), fixture.target().getTransactions().get(0) + .getCrossEntry().getCrossTransaction(fixture.target().getTransactions().get(0))); + } + + /** + * Checks the guardrail scenario: revert transfer action still reverts legacy portfolio transfer. + * Runtime projections are views only and must not become persisted truth. + * This protects Ledger-V6 from duplicate or partial transaction state. + */ + @Test + public void testRevertTransferActionStillRevertsLegacyPortfolioTransfer() + { + var fixture = legacyPortfolioFixture(); + var sourceTransaction = fixture.transfer().getSourceTransaction(); + var targetTransaction = fixture.transfer().getTargetTransaction(); + + new RevertTransferAction(fixture.client(), new TransactionPair<>(fixture.source(), sourceTransaction)).run(); + + assertSame(fixture.target(), fixture.transfer().getSourcePortfolio()); + assertSame(fixture.source(), fixture.transfer().getTargetPortfolio()); + assertSame(targetTransaction, fixture.transfer().getSourceTransaction()); + assertSame(sourceTransaction, fixture.transfer().getTargetTransaction()); + assertThat(sourceTransaction.getType(), is(PortfolioTransaction.Type.TRANSFER_IN)); + assertThat(targetTransaction.getType(), is(PortfolioTransaction.Type.TRANSFER_OUT)); + } + + /** + * Checks the guardrail scenario: transaction type editing support does not offer ledger backed portfolio transfer type edit. + * Runtime projections are views only and must not become persisted truth. + * This protects Ledger-V6 from duplicate or partial transaction state. + */ + @Test + public void testTransactionTypeEditingSupportDoesNotOfferLedgerBackedPortfolioTransferTypeEdit() + { + var fixture = ledgerPortfolioFixture(); + var sourceTransaction = fixture.source().getTransactions().get(0); + var support = new TransactionTypeEditingSupport(fixture.client()); + + assertThat(support.canEdit(sourceTransaction), is(false)); + } + + /** + * Checks the guardrail scenario: revert buy/sell action reverses ledger backed buy/sell through ledger truth. + * Runtime projections are views only and must not become persisted truth. + * This protects Ledger-V6 from duplicate or partial transaction state. + */ + @Test + public void testRevertBuySellActionReversesLedgerBackedBuySellThroughLedgerTruth() + throws ReflectiveOperationException + { + var fixture = ledgerBuySellFixture(PortfolioTransaction.Type.BUY); + var accountTransaction = fixture.account().getTransactions().get(0); + var portfolioTransaction = fixture.portfolio().getTransactions().get(0); + var accountUUID = accountTransaction.getUUID(); + var portfolioUUID = portfolioTransaction.getUUID(); + + new RevertBuySellAction(fixture.client(), new TransactionPair<>(fixture.portfolio(), portfolioTransaction)) + .run(); + + assertThat(ledgerEntryCount(fixture.client()), is(1)); + assertThat(fixture.account().getTransactions().size(), is(1)); + assertThat(fixture.portfolio().getTransactions().size(), is(1)); + assertThat(fixture.account().getTransactions().get(0).getType(), is(AccountTransaction.Type.SELL)); + assertThat(fixture.portfolio().getTransactions().get(0).getType(), is(PortfolioTransaction.Type.SELL)); + assertThat(fixture.account().getTransactions().get(0).getUUID(), is(accountUUID)); + assertThat(fixture.portfolio().getTransactions().get(0).getUUID(), is(portfolioUUID)); + assertSame(fixture.portfolio().getTransactions().get(0), fixture.account().getTransactions().get(0) + .getCrossEntry().getCrossTransaction(fixture.account().getTransactions().get(0))); + } + + /** + * Checks the guardrail scenario: revert buy/sell action still reverts legacy buy/sell. + * Runtime projections are views only and must not become persisted truth. + * This protects Ledger-V6 from duplicate or partial transaction state. + */ + @Test + public void testRevertBuySellActionStillRevertsLegacyBuySell() + { + var fixture = legacyBuySellFixture(PortfolioTransaction.Type.BUY); + var portfolioTransaction = fixture.entry().getPortfolioTransaction(); + + new RevertBuySellAction(fixture.client(), new TransactionPair<>(fixture.portfolio(), portfolioTransaction)) + .run(); + + assertThat(fixture.entry().getAccountTransaction().getType(), is(AccountTransaction.Type.SELL)); + assertThat(portfolioTransaction.getType(), is(PortfolioTransaction.Type.SELL)); + assertThat(portfolioTransaction.getAmount(), is(Values.Amount.factorize(123))); + } + + /** + * Checks the guardrail scenario: convert buy/sell to delivery converts ledger backed buy/sell through ledger truth. + * Runtime projections are views only and must not become persisted truth. + * This protects Ledger-V6 from duplicate or partial transaction state. + */ + @Test + public void testConvertBuySellToDeliveryConvertsLedgerBackedBuySellThroughLedgerTruth() + throws ReflectiveOperationException + { + var fixture = ledgerBuySellFixture(PortfolioTransaction.Type.SELL); + var portfolioTransaction = fixture.portfolio().getTransactions().get(0); + var portfolioProjectionUUID = portfolioTransaction.getUUID(); + + new ConvertBuySellToDeliveryAction(fixture.client(), + new TransactionPair<>(fixture.portfolio(), portfolioTransaction)).run(); + + assertThat(ledgerEntryCount(fixture.client()), is(1)); + assertThat(fixture.account().getTransactions().isEmpty(), is(true)); + assertThat(fixture.portfolio().getTransactions().size(), is(1)); + assertThat(fixture.portfolio().getTransactions().get(0).getUUID(), is(portfolioProjectionUUID)); + assertThat(fixture.portfolio().getTransactions().get(0).getType(), + is(PortfolioTransaction.Type.DELIVERY_OUTBOUND)); + assertThat(fixture.portfolio().getTransactions().get(0).getCrossEntry(), is(nullValue())); + } + + /** + * Checks the guardrail scenario: convert buy/sell to delivery still converts legacy buy/sell. + * Runtime projections are views only and must not become persisted truth. + * This protects Ledger-V6 from duplicate or partial transaction state. + */ + @Test + public void testConvertBuySellToDeliveryStillConvertsLegacyBuySell() + { + var fixture = legacyBuySellFixture(PortfolioTransaction.Type.BUY); + var portfolioTransaction = fixture.entry().getPortfolioTransaction(); + + new ConvertBuySellToDeliveryAction(fixture.client(), + new TransactionPair<>(fixture.portfolio(), portfolioTransaction)).run(); + + assertThat(fixture.portfolio().getTransactions().size(), is(1)); + assertThat(fixture.portfolio().getTransactions().get(0).getType(), + is(PortfolioTransaction.Type.DELIVERY_INBOUND)); + assertThat(fixture.account().getTransactions().isEmpty(), is(true)); + } + + /** + * Checks the guardrail scenario: convert delivery to buy/sell converts ledger backed delivery through ledger truth. + * Runtime projections are views only and must not become persisted truth. + * This protects Ledger-V6 from duplicate or partial transaction state. + */ + @Test + public void testConvertDeliveryToBuySellConvertsLedgerBackedDeliveryThroughLedgerTruth() + throws ReflectiveOperationException + { + var fixture = ledgerDeliveryFixture(); + var delivery = fixture.portfolio().getTransactions().get(0); + var portfolioProjectionUUID = delivery.getUUID(); + + new ConvertDeliveryToBuySellAction(fixture.client(), new TransactionPair<>(fixture.portfolio(), delivery)) + .run(); + + assertThat(ledgerEntryCount(fixture.client()), is(1)); + assertThat(fixture.account().getTransactions().size(), is(1)); + assertThat(fixture.portfolio().getTransactions().size(), is(1)); + assertThat(fixture.portfolio().getTransactions().get(0).getUUID(), is(portfolioProjectionUUID)); + assertThat(fixture.portfolio().getTransactions().get(0).getType(), is(PortfolioTransaction.Type.BUY)); + assertThat(fixture.account().getTransactions().get(0).getType(), is(AccountTransaction.Type.BUY)); + assertSame(fixture.portfolio().getTransactions().get(0), fixture.account().getTransactions().get(0) + .getCrossEntry().getCrossTransaction(fixture.account().getTransactions().get(0))); + } + + /** + * Checks the guardrail scenario: revert delivery action reverses ledger backed delivery through ledger truth. + * Runtime projections are views only and must not become persisted truth. + * This protects Ledger-V6 from duplicate or partial transaction state. + */ + @Test + public void testRevertDeliveryActionReversesLedgerBackedDeliveryThroughLedgerTruth() + throws ReflectiveOperationException + { + var fixture = ledgerDeliveryFixture(); + var delivery = fixture.portfolio().getTransactions().get(0); + var projectionUUID = delivery.getUUID(); + + new RevertDeliveryAction(fixture.client(), new TransactionPair<>(fixture.portfolio(), delivery)).run(); + + assertThat(ledgerEntryCount(fixture.client()), is(1)); + assertThat(fixture.portfolio().getTransactions().size(), is(1)); + assertThat(fixture.portfolio().getTransactions().get(0).getUUID(), is(projectionUUID)); + assertThat(fixture.portfolio().getTransactions().get(0).getType(), + is(PortfolioTransaction.Type.DELIVERY_OUTBOUND)); + assertThat(fixture.portfolio().getTransactions().get(0).getCrossEntry(), is(nullValue())); + assertThat(fixture.account().getTransactions().isEmpty(), is(true)); + } + + /** + * Checks the guardrail scenario: revert delivery action still reverts legacy delivery. + * Runtime projections are views only and must not become persisted truth. + * This protects Ledger-V6 from duplicate or partial transaction state. + */ + @Test + public void testRevertDeliveryActionStillRevertsLegacyDelivery() + { + var fixture = legacyDeliveryFixture(); + var delivery = fixture.portfolio().getTransactions().get(0); + + new RevertDeliveryAction(fixture.client(), new TransactionPair<>(fixture.portfolio(), delivery)).run(); + + assertThat(delivery.getType(), is(PortfolioTransaction.Type.DELIVERY_OUTBOUND)); + assertThat(delivery.getAmount(), is(Values.Amount.factorize(123))); + } + + /** + * Checks the guardrail scenario: revert deposit removal action reverses ledger backed account only through ledger truth. + * Runtime projections are views only and must not become persisted truth. + * This protects Ledger-V6 from duplicate or partial transaction state. + */ + @Test + public void testRevertDepositRemovalActionReversesLedgerBackedAccountOnlyThroughLedgerTruth() + throws ReflectiveOperationException + { + var fixture = ledgerAccountOnlyFixture(AccountTransaction.Type.DEPOSIT); + var transaction = fixture.source().getTransactions().get(0); + var projectionUUID = transaction.getUUID(); + + new RevertDepositRemovalAction(fixture.client(), new TransactionPair<>(fixture.source(), transaction)).run(); + + assertThat(ledgerEntryCount(fixture.client()), is(1)); + assertThat(fixture.source().getTransactions().size(), is(1)); + assertThat(fixture.source().getTransactions().get(0).getUUID(), is(projectionUUID)); + assertThat(fixture.source().getTransactions().get(0).getType(), is(AccountTransaction.Type.REMOVAL)); + assertThat(fixture.source().getTransactions().get(0).getAmount(), is(Values.Amount.factorize(123))); + } + + /** + * Checks the guardrail scenario: revert deposit removal action still reverts legacy account only. + * Runtime projections are views only and must not become persisted truth. + * This protects Ledger-V6 from duplicate or partial transaction state. + */ + @Test + public void testRevertDepositRemovalActionStillRevertsLegacyAccountOnly() + { + var fixture = legacyAccountOnlyFixture(AccountTransaction.Type.DEPOSIT); + var transaction = fixture.source().getTransactions().get(0); + + new RevertDepositRemovalAction(fixture.client(), new TransactionPair<>(fixture.source(), transaction)).run(); + + assertThat(transaction.getType(), is(AccountTransaction.Type.REMOVAL)); + assertThat(transaction.getAmount(), is(Values.Amount.factorize(123))); + } + + /** + * Checks the guardrail scenario: revert interest action reverses ledger backed account only through ledger truth. + * Runtime projections are views only and must not become persisted truth. + * This protects Ledger-V6 from duplicate or partial transaction state. + */ + @Test + public void testRevertInterestActionReversesLedgerBackedAccountOnlyThroughLedgerTruth() + throws ReflectiveOperationException + { + var fixture = ledgerAccountOnlyFixture(AccountTransaction.Type.INTEREST); + var transaction = fixture.source().getTransactions().get(0); + var projectionUUID = transaction.getUUID(); + + new RevertInterestAction(fixture.client(), new TransactionPair<>(fixture.source(), transaction)).run(); + + assertThat(ledgerEntryCount(fixture.client()), is(1)); + assertThat(fixture.source().getTransactions().size(), is(1)); + assertThat(fixture.source().getTransactions().get(0).getUUID(), is(projectionUUID)); + assertThat(fixture.source().getTransactions().get(0).getType(), is(AccountTransaction.Type.INTEREST_CHARGE)); + assertThat(fixture.source().getTransactions().get(0).getAmount(), is(Values.Amount.factorize(123))); + } + + /** + * Checks the guardrail scenario: revert interest action still reverts legacy account only. + * Runtime projections are views only and must not become persisted truth. + * This protects Ledger-V6 from duplicate or partial transaction state. + */ + @Test + public void testRevertInterestActionStillRevertsLegacyAccountOnly() + { + var fixture = legacyAccountOnlyFixture(AccountTransaction.Type.INTEREST); + var transaction = fixture.source().getTransactions().get(0); + + new RevertInterestAction(fixture.client(), new TransactionPair<>(fixture.source(), transaction)).run(); + + assertThat(transaction.getType(), is(AccountTransaction.Type.INTEREST_CHARGE)); + assertThat(transaction.getAmount(), is(Values.Amount.factorize(123))); + } + + /** + * Checks the guardrail scenario: transaction type editing support offers converter backed buy/sell delivery type edits. + * Runtime projections are views only and must not become persisted truth. + * This protects Ledger-V6 from duplicate or partial transaction state. + */ + @Test + public void testTransactionTypeEditingSupportOffersConverterBackedBuySellDeliveryTypeEdits() throws Exception + { + var buySell = ledgerBuySellFixture(PortfolioTransaction.Type.BUY); + var delivery = ledgerDeliveryFixture(); + var support = new TransactionTypeEditingSupport(buySell.client()); + + var portfolioTransaction = buySell.portfolio().getTransactions().get(0); + assertThat(support.canEdit(portfolioTransaction), is(true)); + + setTypeValue(support, portfolioTransaction, PortfolioTransaction.Type.BUY, + PortfolioTransaction.Type.DELIVERY_INBOUND); + + assertThat(buySell.portfolio().getTransactions().get(0).getType(), + is(PortfolioTransaction.Type.DELIVERY_INBOUND)); + assertThat(buySell.account().getTransactions().isEmpty(), is(true)); + + var deliverySupport = new TransactionTypeEditingSupport(delivery.client()); + var deliveryTransaction = delivery.portfolio().getTransactions().get(0); + assertThat(deliverySupport.canEdit(deliveryTransaction), is(true)); + + setTypeValue(deliverySupport, deliveryTransaction, PortfolioTransaction.Type.DELIVERY_INBOUND, + PortfolioTransaction.Type.BUY); + + assertThat(delivery.portfolio().getTransactions().get(0).getType(), is(PortfolioTransaction.Type.BUY)); + assertThat(delivery.account().getTransactions().get(0).getType(), is(AccountTransaction.Type.BUY)); + } + + /** + * Checks the guardrail scenario: transaction type editing support routes master security type edits through converters. + * Runtime projections are views only and must not become persisted truth. + * This protects Ledger-V6 from duplicate or partial transaction state. + */ + @Test + public void testTransactionTypeEditingSupportRoutesMasterSecurityTypeEditsThroughConverters() + throws Exception + { + assertInlineBuySellToDelivery(PortfolioTransaction.Type.BUY, PortfolioTransaction.Type.DELIVERY_INBOUND); + assertInlineBuySellToDelivery(PortfolioTransaction.Type.SELL, PortfolioTransaction.Type.DELIVERY_OUTBOUND); + + assertInlineDeliveryToBuySell(PortfolioTransaction.Type.DELIVERY_INBOUND, PortfolioTransaction.Type.BUY, + AccountTransaction.Type.BUY); + assertInlineDeliveryToBuySell(PortfolioTransaction.Type.DELIVERY_OUTBOUND, PortfolioTransaction.Type.SELL, + AccountTransaction.Type.SELL); + } + + /** + * Checks the guardrail scenario: transaction type editing support offers ledger backed account only type toggles. + * Runtime projections are views only and must not become persisted truth. + * This protects Ledger-V6 from duplicate or partial transaction state. + */ + @Test + public void testTransactionTypeEditingSupportOffersLedgerBackedAccountOnlyTypeToggles() throws Exception + { + var deposit = ledgerAccountOnlyFixture(AccountTransaction.Type.DEPOSIT); + var depositSupport = new TransactionTypeEditingSupport(deposit.client()); + var depositTransaction = deposit.source().getTransactions().get(0); + + assertThat(depositSupport.canEdit(depositTransaction), is(true)); + + setTypeValue(depositSupport, depositTransaction, AccountTransaction.Type.DEPOSIT, + AccountTransaction.Type.REMOVAL); + + assertThat(deposit.source().getTransactions().get(0).getType(), is(AccountTransaction.Type.REMOVAL)); + assertThat(deposit.source().getTransactions().get(0).getAmount(), is(Values.Amount.factorize(123))); + + var interest = ledgerAccountOnlyFixture(AccountTransaction.Type.INTEREST); + var interestSupport = new TransactionTypeEditingSupport(interest.client()); + var interestTransaction = interest.source().getTransactions().get(0); + + assertThat(interestSupport.canEdit(interestTransaction), is(true)); + + setTypeValue(interestSupport, interestTransaction, AccountTransaction.Type.INTEREST, + AccountTransaction.Type.INTEREST_CHARGE); + + assertThat(interest.source().getTransactions().get(0).getType(), is(AccountTransaction.Type.INTEREST_CHARGE)); + assertThat(interest.source().getTransactions().get(0).getAmount(), is(Values.Amount.factorize(123))); + } + + /** + * Checks the guardrail scenario: transaction type editing support routes all ledger backed account type edits through converters. + * Runtime projections are views only and must not become persisted truth. + * This protects Ledger-V6 from duplicate or partial transaction state. + */ + @Test + public void testTransactionTypeEditingSupportRoutesAllLedgerBackedAccountTypeEditsThroughConverters() + throws Exception + { + assertInlineAccountTransferReversal(AccountTransaction.Type.TRANSFER_OUT, + AccountTransaction.Type.TRANSFER_IN); + assertInlineAccountTransferReversal(AccountTransaction.Type.TRANSFER_IN, + AccountTransaction.Type.TRANSFER_OUT); + + assertInlineAccountOnlyToggle(AccountTransaction.Type.DEPOSIT, AccountTransaction.Type.REMOVAL); + assertInlineAccountOnlyToggle(AccountTransaction.Type.REMOVAL, AccountTransaction.Type.DEPOSIT); + assertInlineAccountOnlyToggle(AccountTransaction.Type.INTEREST, AccountTransaction.Type.INTEREST_CHARGE); + assertInlineAccountOnlyToggle(AccountTransaction.Type.INTEREST_CHARGE, AccountTransaction.Type.INTEREST); + } + + /** + * Checks the guardrail scenario: transaction type editing support rejects unsupported ledger backed type edits. + * Runtime projections are views only and must not become persisted truth. + * This protects Ledger-V6 from duplicate or partial transaction state. + */ + @Test + public void testTransactionTypeEditingSupportRejectsUnsupportedLedgerBackedTypeEdits() throws Exception + { + var delivery = ledgerDeliveryFixture(); + var support = new TransactionTypeEditingSupport(delivery.client()); + var transaction = delivery.portfolio().getTransactions().get(0); + + assertThat(support.canEdit(transaction), is(true)); + + setTypeItems(support, PortfolioTransaction.Type.DELIVERY_INBOUND, + PortfolioTransaction.Type.TRANSFER_IN); + + assertThrows(UnsupportedOperationException.class, () -> support.setValue(transaction, 1)); + + assertThat(delivery.portfolio().getTransactions().get(0), is(transaction)); + assertThat(transaction.getType(), is(PortfolioTransaction.Type.DELIVERY_INBOUND)); + assertThat(delivery.account().getTransactions().isEmpty(), is(true)); + } + + /** + * Checks the guardrail scenario: transaction type editing support blocks ledger backed fee and tax refund toggles. + * Runtime projections are views only and must not become persisted truth. + * This protects Ledger-V6 from duplicate or partial transaction state. + */ + @Test + public void testTransactionTypeEditingSupportBlocksLedgerBackedFeeTaxRefundToggles() + { + var fees = ledgerAccountOnlyFixture(AccountTransaction.Type.FEES); + assertThat(new TransactionTypeEditingSupport(fees.client()).canEdit(fees.source().getTransactions().get(0)), + is(false)); + + var feesRefund = ledgerAccountOnlyFixture(AccountTransaction.Type.FEES_REFUND); + assertThat(new TransactionTypeEditingSupport(feesRefund.client()) + .canEdit(feesRefund.source().getTransactions().get(0)), is(false)); + + var taxes = ledgerAccountOnlyFixture(AccountTransaction.Type.TAXES); + assertThat(new TransactionTypeEditingSupport(taxes.client()).canEdit(taxes.source().getTransactions().get(0)), + is(false)); + + var taxRefund = ledgerAccountOnlyFixture(AccountTransaction.Type.TAX_REFUND); + assertThat(new TransactionTypeEditingSupport(taxRefund.client()) + .canEdit(taxRefund.source().getTransactions().get(0)), is(false)); + } + + private Fixture ledgerFixture() + { + var fixture = baseFixture(); + + new LedgerAccountTransferTransactionCreator(fixture.client()).create(fixture.source(), fixture.target(), + DATE_TIME, Values.Amount.factorize(123), CurrencyUnit.EUR, Values.Amount.factorize(123), + CurrencyUnit.EUR, null, null, "note", "source"); + + return fixture; + } + + private Fixture ledgerAccountOnlyFixture(AccountTransaction.Type type) + { + var fixture = baseFixture(); + + new LedgerAccountOnlyTransactionCreator(fixture.client()).create(fixture.source(), type, DATE_TIME, + Values.Amount.factorize(123), CurrencyUnit.EUR, null, List.of(), "note", "source"); + + return fixture; + } + + private Fixture legacyAccountOnlyFixture(AccountTransaction.Type type) + { + var fixture = baseFixture(); + var transaction = new AccountTransaction(type); + + transaction.setDateTime(DATE_TIME); + transaction.setAmount(Values.Amount.factorize(123)); + transaction.setCurrencyCode(CurrencyUnit.EUR); + transaction.setNote("note"); + fixture.source().addTransaction(transaction); + + return fixture; + } + + private PortfolioFixture ledgerPortfolioFixture() + { + var fixture = basePortfolioFixture(); + + new LedgerPortfolioTransferTransactionCreator(fixture.client()).create(fixture.source(), fixture.target(), + fixture.security(), DATE_TIME, Values.Share.factorize(5), Values.Amount.factorize(123), + CurrencyUnit.EUR, "note", "source"); + + return fixture; + } + + private LegacyFixture legacyFixture() + { + var fixture = baseFixture(); + var transfer = new AccountTransferEntry(fixture.source(), fixture.target()); + + transfer.setDate(DATE_TIME); + transfer.setAmount(Values.Amount.factorize(123)); + transfer.setCurrencyCode(CurrencyUnit.EUR); + transfer.setNote("note"); + transfer.insert(); + + return new LegacyFixture(fixture.client(), fixture.source(), fixture.target(), transfer); + } + + private LegacyPortfolioFixture legacyPortfolioFixture() + { + var fixture = basePortfolioFixture(); + var transfer = new PortfolioTransferEntry(fixture.source(), fixture.target()); + + transfer.setDate(DATE_TIME); + transfer.setSecurity(fixture.security()); + transfer.setShares(Values.Share.factorize(5)); + transfer.setAmount(Values.Amount.factorize(123)); + transfer.setCurrencyCode(CurrencyUnit.EUR); + transfer.setNote("note"); + transfer.insert(); + + return new LegacyPortfolioFixture(fixture.client(), fixture.source(), fixture.target(), fixture.security(), + transfer); + } + + private BuySellFixture ledgerBuySellFixture(PortfolioTransaction.Type type) + { + var fixture = buySellBaseFixture(); + + new LedgerBuySellTransactionCreator(fixture.client()).create(fixture.portfolio(), fixture.account(), type, + DATE_TIME, Values.Amount.factorize(123), CurrencyUnit.EUR, fixture.security(), + Values.Share.factorize(5), List.of(), "note", "source"); + + return fixture; + } + + private BuySellFixture ledgerDeliveryFixture() + { + return ledgerDeliveryFixture(PortfolioTransaction.Type.DELIVERY_INBOUND); + } + + private BuySellFixture ledgerDeliveryFixture(PortfolioTransaction.Type type) + { + var fixture = buySellBaseFixture(); + + new LedgerDeliveryTransactionCreator(fixture.client()).create(fixture.portfolio(), + type, DATE_TIME, Values.Amount.factorize(123), CurrencyUnit.EUR, fixture.security(), + Values.Share.factorize(5), null, null, List.of(), "note", "source"); + + return fixture; + } + + private BuySellFixture legacyDeliveryFixture() + { + var fixture = buySellBaseFixture(); + var delivery = new PortfolioTransaction(PortfolioTransaction.Type.DELIVERY_INBOUND); + + delivery.setDateTime(DATE_TIME); + delivery.setSecurity(fixture.security()); + delivery.setShares(Values.Share.factorize(5)); + delivery.setAmount(Values.Amount.factorize(123)); + delivery.setCurrencyCode(CurrencyUnit.EUR); + delivery.setNote("note"); + fixture.portfolio().addTransaction(delivery); + + return fixture; + } + + private LegacyBuySellFixture legacyBuySellFixture(PortfolioTransaction.Type type) + { + var fixture = buySellBaseFixture(); + var entry = new BuySellEntry(fixture.portfolio(), fixture.account()); + + entry.setType(type); + entry.setDate(DATE_TIME); + entry.setSecurity(fixture.security()); + entry.setShares(Values.Share.factorize(5)); + entry.setAmount(Values.Amount.factorize(123)); + entry.setCurrencyCode(CurrencyUnit.EUR); + entry.setNote("note"); + entry.insert(); + + return new LegacyBuySellFixture(fixture.client(), fixture.account(), fixture.portfolio(), fixture.security(), + entry); + } + + private BuySellFixture buySellBaseFixture() + { + var client = new Client(); + var account = account("Account", CurrencyUnit.EUR); + var portfolio = new Portfolio("Portfolio"); + var security = new Security("Security", CurrencyUnit.EUR); + + portfolio.setReferenceAccount(account); + client.addAccount(account); + client.addPortfolio(portfolio); + client.addSecurity(security); + + return new BuySellFixture(client, account, portfolio, security); + } + + private Fixture baseFixture() + { + var client = new Client(); + var source = account("Source", CurrencyUnit.EUR); + var target = account("Target", CurrencyUnit.EUR); + + client.addAccount(source); + client.addAccount(target); + + return new Fixture(client, source, target); + } + + private PortfolioFixture basePortfolioFixture() + { + var client = new Client(); + var source = new Portfolio("Source"); + var target = new Portfolio("Target"); + var security = new Security("Security", CurrencyUnit.EUR); + + client.addPortfolio(source); + client.addPortfolio(target); + client.addSecurity(security); + + return new PortfolioFixture(client, source, target, security); + } + + private Account account(String name, String currency) + { + var account = new Account(name); + + account.setCurrencyCode(currency); + + return account; + } + + private int ledgerEntryCount(Client client) throws ReflectiveOperationException + { + try + { + var ledger = Client.class.getMethod("getLedger").invoke(client); + var entries = ledger.getClass().getMethod("getEntries").invoke(ledger); + return ((java.util.List) entries).size(); + } + catch (InvocationTargetException e) + { + throw new AssertionError(e.getCause()); + } + } + + private void assertInlineBuySellToDelivery(PortfolioTransaction.Type from, PortfolioTransaction.Type to) + throws Exception + { + var fixture = ledgerBuySellFixture(from); + var portfolioTransaction = fixture.portfolio().getTransactions().get(0); + var portfolioUUID = portfolioTransaction.getUUID(); + + setTypeValue(new TransactionTypeEditingSupport(fixture.client()), portfolioTransaction, from, to); + + assertThat(fixture.account().getTransactions().isEmpty(), is(true)); + assertThat(fixture.portfolio().getTransactions().size(), is(1)); + assertThat(fixture.portfolio().getTransactions().get(0).getUUID(), is(portfolioUUID)); + assertThat(fixture.portfolio().getTransactions().get(0).getType(), is(to)); + assertThat(fixture.portfolio().getTransactions().get(0).getCrossEntry(), is(nullValue())); + } + + private void assertInlineDeliveryToBuySell(PortfolioTransaction.Type from, PortfolioTransaction.Type to, + AccountTransaction.Type accountType) throws Exception + { + var fixture = ledgerDeliveryFixture(from); + var delivery = fixture.portfolio().getTransactions().get(0); + var portfolioUUID = delivery.getUUID(); + + setTypeValue(new TransactionTypeEditingSupport(fixture.client()), delivery, from, to); + + assertThat(fixture.account().getTransactions().size(), is(1)); + assertThat(fixture.portfolio().getTransactions().size(), is(1)); + assertThat(fixture.portfolio().getTransactions().get(0).getUUID(), is(portfolioUUID)); + assertThat(fixture.portfolio().getTransactions().get(0).getType(), is(to)); + assertThat(fixture.account().getTransactions().get(0).getType(), is(accountType)); + assertSame(fixture.portfolio().getTransactions().get(0), fixture.account().getTransactions().get(0) + .getCrossEntry().getCrossTransaction(fixture.account().getTransactions().get(0))); + } + + private void assertInlineAccountTransferReversal(AccountTransaction.Type from, AccountTransaction.Type to) + throws Exception + { + var fixture = ledgerFixture(); + AccountTransaction transaction = from == AccountTransaction.Type.TRANSFER_OUT + ? fixture.source().getTransactions().get(0) + : fixture.target().getTransactions().get(0); + + setTypeValue(new TransactionTypeEditingSupport(fixture.client()), transaction, from, to); + + assertThat(fixture.source().getTransactions().size(), is(1)); + assertThat(fixture.target().getTransactions().size(), is(1)); + assertThat(fixture.source().getTransactions().get(0).getType(), is(AccountTransaction.Type.TRANSFER_IN)); + assertThat(fixture.target().getTransactions().get(0).getType(), is(AccountTransaction.Type.TRANSFER_OUT)); + assertSame(fixture.source().getTransactions().get(0), fixture.target().getTransactions().get(0) + .getCrossEntry().getCrossTransaction(fixture.target().getTransactions().get(0))); + } + + private void assertInlineAccountOnlyToggle(AccountTransaction.Type from, AccountTransaction.Type to) + throws Exception + { + var fixture = ledgerAccountOnlyFixture(from); + var transaction = fixture.source().getTransactions().get(0); + var projectionUUID = transaction.getUUID(); + + setTypeValue(new TransactionTypeEditingSupport(fixture.client()), transaction, from, to); + + assertThat(fixture.source().getTransactions().size(), is(1)); + assertThat(fixture.source().getTransactions().get(0).getUUID(), is(projectionUUID)); + assertThat(fixture.source().getTransactions().get(0).getType(), is(to)); + assertThat(fixture.source().getTransactions().get(0).getAmount(), is(Values.Amount.factorize(123))); + } + + private void setTypeValue(TransactionTypeEditingSupport support, Object element, Object current, Object target) + throws Exception + { + setTypeItems(support, current, target); + support.setValue(element, 1); + } + + private void setTypeItems(TransactionTypeEditingSupport support, Object current, Object target) throws Exception + { + Field field = TransactionTypeEditingSupport.class.getDeclaredField("comboBoxItems"); + field.setAccessible(true); + field.set(support, new ArrayList<>(List.of(current, target))); + } + + private record Fixture(Client client, Account source, Account target) + { + } + + private record LegacyFixture(Client client, Account source, Account target, AccountTransferEntry transfer) + { + } + + private record PortfolioFixture(Client client, Portfolio source, Portfolio target, Security security) + { + } + + private record LegacyPortfolioFixture(Client client, Portfolio source, Portfolio target, Security security, + PortfolioTransferEntry transfer) + { + } + + private record BuySellFixture(Client client, Account account, Portfolio portfolio, Security security) + { + } + + private record LegacyBuySellFixture(Client client, Account account, Portfolio portfolio, Security security, + BuySellEntry entry) + { + } +} diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/AbstractSecurityTransactionModel.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/AbstractSecurityTransactionModel.java index fffa462fec..63ce5ced67 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/AbstractSecurityTransactionModel.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/AbstractSecurityTransactionModel.java @@ -157,38 +157,45 @@ protected void fillFromTransaction(PortfolioTransaction transaction) protected void writeToTransaction(PortfolioTransaction transaction) { transaction.clearUnits(); + buildUnits().forEach(transaction::addUnit); + } + + protected java.util.List buildUnits() + { + var units = new java.util.ArrayList(); if (fees != 0) - transaction.addUnit(new Transaction.Unit(Transaction.Unit.Type.FEE, // + units.add(new Transaction.Unit(Transaction.Unit.Type.FEE, // Money.of(getTransactionCurrencyCode(), fees))); if (taxes != 0) - transaction.addUnit(new Transaction.Unit(Transaction.Unit.Type.TAX, // + units.add(new Transaction.Unit(Transaction.Unit.Type.TAX, // Money.of(getTransactionCurrencyCode(), taxes))); boolean hasForex = !getTransactionCurrencyCode().equals(getSecurityCurrencyCode()); if (hasForex) { if (forexFees != 0) - transaction.addUnit(new Transaction.Unit(Transaction.Unit.Type.FEE, // + units.add(new Transaction.Unit(Transaction.Unit.Type.FEE, // Money.of(getTransactionCurrencyCode(), Math.round(forexFees * exchangeRate.doubleValue())), // Money.of(getSecurityCurrencyCode(), forexFees), // exchangeRate)); if (forexTaxes != 0) - transaction.addUnit(new Transaction.Unit(Transaction.Unit.Type.TAX, // + units.add(new Transaction.Unit(Transaction.Unit.Type.TAX, // Money.of(getTransactionCurrencyCode(), Math.round(forexTaxes * exchangeRate.doubleValue())), // Money.of(getSecurityCurrencyCode(), forexTaxes), // exchangeRate)); - transaction.addUnit(new Transaction.Unit(Transaction.Unit.Type.GROSS_VALUE, // + units.add(new Transaction.Unit(Transaction.Unit.Type.GROSS_VALUE, // Money.of(getTransactionCurrencyCode(), convertedGrossValue), // Money.of(getSecurityCurrencyCode(), grossValue), // exchangeRate)); } + return units; } @Override diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/AccountTransactionDialog.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/AccountTransactionDialog.java index 692b62c96e..b3ad3d6c65 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/AccountTransactionDialog.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/AccountTransactionDialog.java @@ -128,7 +128,7 @@ protected void createFormElements(Composite editArea) // NOSONAR var exDate = new ExDateInput(editArea); exDate.bindDate(Properties.exDate.name()); - exDate.setVisible(model().supportsSecurity() && model().getSecurity() != null + exDate.setVisible(model().supportsExDate() && model().getSecurity() != null && !AccountTransactionModel.EMPTY_SECURITY.equals(model().getSecurity())); // shares @@ -261,7 +261,7 @@ public void widgetSelected(SelectionEvent e) startingWith(dateTime.date.getControl()).thenRight(dateTime.time).thenRight(dateTime.button, 0); - if (model().supportsSecurity()) + if (model().supportsExDate()) { startingWith(dateTime.button).thenRight(exDate.checkBox).thenRight(exDate.date.getControl()); diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/AccountTransactionModel.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/AccountTransactionModel.java index 4f0d7945a8..40e1a2254d 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/AccountTransactionModel.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/AccountTransactionModel.java @@ -6,6 +6,8 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; +import java.util.ArrayList; +import java.util.List; import org.eclipse.core.databinding.validation.ValidationStatus; import org.eclipse.core.runtime.IStatus; @@ -17,6 +19,8 @@ import name.abuchen.portfolio.model.Portfolio; import name.abuchen.portfolio.model.Security; import name.abuchen.portfolio.model.Transaction; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountOnlyTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerDividendTransactionCreator; import name.abuchen.portfolio.money.CurrencyConverter; import name.abuchen.portfolio.money.CurrencyConverterImpl; import name.abuchen.portfolio.money.ExchangeRate; @@ -99,14 +103,56 @@ private void checkType() @Override public void applyChanges() { - if (security == null && supportsSecurity() && !supportsOptionalSecurity()) + if ((security == null || EMPTY_SECURITY.equals(security)) && supportsSecurity() && !supportsOptionalSecurity()) throw new UnsupportedOperationException(Messages.MsgMissingSecurity); if (account == null) throw new UnsupportedOperationException(Messages.MsgMissingAccount); + if (exDate != null && !supportsExDate()) + throw new UnsupportedOperationException(Messages.MsgExDateNotAllowed); + if (exDate != null && (security == null || EMPTY_SECURITY.equals(security))) throw new UnsupportedOperationException(Messages.MsgExDateNotAllowed); + var ledgerAccountOnlyCreator = new LedgerAccountOnlyTransactionCreator(client); + var ledgerDividendCreator = new LedgerDividendTransactionCreator(client); + + if (exDate != null && isLedgerAccountOnlyType() + && (sourceTransaction == null || ledgerAccountOnlyCreator.canUpdate(sourceTransaction))) + throw new UnsupportedOperationException(Messages.MsgExDateNotAllowed); + + if (sourceTransaction != null && ledgerDividendCreator.canUpdate(sourceTransaction)) + { + ledgerDividendCreator.update(sourceTransaction, account, type, LocalDateTime.of(date, time), total, + getAccountCurrencyCode(), security, shares, exDate, getDividendCashForex(), + getDividendCashForex() != null ? exchangeRate : null, buildUnits(), note, + sourceTransaction.getSource()); + return; + } + + if (sourceTransaction != null && ledgerAccountOnlyCreator.canUpdate(sourceTransaction)) + { + ledgerAccountOnlyCreator.update(sourceTransaction, account, type, LocalDateTime.of(date, time), total, + getAccountCurrencyCode(), !EMPTY_SECURITY.equals(security) ? security : null, buildUnits(), + note, sourceTransaction.getSource()); + return; + } + + if (sourceTransaction == null && isLedgerAccountOnlyType()) + { + ledgerAccountOnlyCreator.create(account, type, LocalDateTime.of(date, time), total, getAccountCurrencyCode(), + !EMPTY_SECURITY.equals(security) ? security : null, buildUnits(), note, null); + return; + } + + if (sourceTransaction == null && type == AccountTransaction.Type.DIVIDENDS) + { + ledgerDividendCreator.create(account, LocalDateTime.of(date, time), total, getAccountCurrencyCode(), + security, shares, exDate, getDividendCashForex(), + getDividendCashForex() != null ? exchangeRate : null, buildUnits(), note, null); + return; + } + AccountTransaction t; if (sourceTransaction != null && sourceAccount.equals(account)) @@ -142,11 +188,18 @@ public void applyChanges() t.clearUnits(); + buildUnits().forEach(t::addUnit); + } + + private List buildUnits() + { + var units = new ArrayList(); + if (fees != 0) - t.addUnit(new Transaction.Unit(Transaction.Unit.Type.FEE, Money.of(getAccountCurrencyCode(), fees))); + units.add(new Transaction.Unit(Transaction.Unit.Type.FEE, Money.of(getAccountCurrencyCode(), fees))); if (taxes != 0) - t.addUnit(new Transaction.Unit(Transaction.Unit.Type.TAX, Money.of(getAccountCurrencyCode(), taxes))); + units.add(new Transaction.Unit(Transaction.Unit.Type.TAX, Money.of(getAccountCurrencyCode(), taxes))); String fxCurrencyCode = getFxCurrencyCode(); if (!fxCurrencyCode.equals(account.getCurrencyCode())) @@ -155,20 +208,40 @@ public void applyChanges() Money.of(getAccountCurrencyCode(), grossAmount), // Money.of(getSecurityCurrencyCode(), fxGrossAmount), // getExchangeRate()); - t.addUnit(forex); + units.add(forex); if (fxFees != 0) - t.addUnit(new Transaction.Unit(Transaction.Unit.Type.FEE, // + units.add(new Transaction.Unit(Transaction.Unit.Type.FEE, // Money.of(getAccountCurrencyCode(), Math.round(fxFees * exchangeRate.doubleValue())), // Money.of(getSecurityCurrencyCode(), fxFees), // exchangeRate)); if (fxTaxes != 0) - t.addUnit(new Transaction.Unit(Transaction.Unit.Type.TAX, // + units.add(new Transaction.Unit(Transaction.Unit.Type.TAX, // Money.of(getAccountCurrencyCode(), Math.round(fxTaxes * exchangeRate.doubleValue())), // Money.of(getSecurityCurrencyCode(), fxTaxes), // exchangeRate)); } + + return units; + } + + private boolean isLedgerAccountOnlyType() + { + return switch (type) + { + case DEPOSIT, REMOVAL, INTEREST, INTEREST_CHARGE, FEES, FEES_REFUND, TAXES, TAX_REFUND -> true; + case BUY, SELL, TRANSFER_IN, TRANSFER_OUT, DIVIDENDS -> false; + }; + } + + private Money getDividendCashForex() + { + if (type != AccountTransaction.Type.DIVIDENDS || security == null || EMPTY_SECURITY.equals(security) + || getFxCurrencyCode().equals(getAccountCurrencyCode()) || exchangeRate.compareTo(BigDecimal.ZERO) == 0) + return null; + + return Money.of(getFxCurrencyCode(), Math.round(total / exchangeRate.doubleValue())); } @Override @@ -203,6 +276,11 @@ public boolean supportsSecurity() || type == Type.FEES_REFUND; } + boolean supportsExDate() + { + return type == Type.DIVIDENDS; + } + public boolean supportsOptionalSecurity() { return type == Type.TAXES // diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/AccountTransferModel.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/AccountTransferModel.java index 40fbbd6ab0..c4cb3a3171 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/AccountTransferModel.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/AccountTransferModel.java @@ -17,6 +17,7 @@ import name.abuchen.portfolio.model.Client; import name.abuchen.portfolio.model.Transaction; import name.abuchen.portfolio.model.TransactionOwner; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountTransferTransactionCreator; import name.abuchen.portfolio.money.ExchangeRate; import name.abuchen.portfolio.money.ExchangeRateTimeSeries; import name.abuchen.portfolio.money.Money; @@ -66,6 +67,29 @@ public void applyChanges() if (targetAccount == null) throw new UnsupportedOperationException(Messages.MsgAccountToMissing); + var ledgerTransferCreator = new LedgerAccountTransferTransactionCreator(client); + var dateTime = LocalDateTime.of(date, time); + var sourceAmount = sourceAccount.getCurrencyCode().equals(targetAccount.getCurrencyCode()) ? amount : fxAmount; + var sourceForex = sourceAccount.getCurrencyCode().equals(targetAccount.getCurrencyCode()) ? null + : Money.of(targetAccount.getCurrencyCode(), amount); + var sourceExchangeRate = sourceForex != null ? getInverseExchangeRate() : null; + + if (source != null && ledgerTransferCreator.isLedgerBacked(source)) + { + ledgerTransferCreator.update(source, sourceAccount, targetAccount, dateTime, sourceAmount, + sourceAccount.getCurrencyCode(), amount, targetAccount.getCurrencyCode(), sourceForex, + sourceExchangeRate, note, source.getSource()); + return; + } + + if (source == null) + { + ledgerTransferCreator.create(sourceAccount, targetAccount, dateTime, sourceAmount, + sourceAccount.getCurrencyCode(), amount, targetAccount.getCurrencyCode(), sourceForex, + sourceExchangeRate, note, null); + return; + } + AccountTransferEntry t; if (source != null && sourceAccount.equals(source.getOwner(source.getSourceTransaction())) @@ -96,7 +120,7 @@ public void applyChanges() } - t.setDate(LocalDateTime.of(date, time)); + t.setDate(dateTime); t.setNote(note); // if source and target account have the same currencies, no forex data @@ -167,7 +191,8 @@ public void presetFromSource(AccountTransferEntry entry) } else { - this.exchangeRate = BigDecimal.ONE; + this.exchangeRate = new LedgerAccountTransferTransactionCreator(client).getSourceExchangeRate(entry) + .orElse(BigDecimal.ONE); } } diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/BuySellModel.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/BuySellModel.java index 6b9e3f9a31..d12832a8ac 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/BuySellModel.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/BuySellModel.java @@ -1,5 +1,6 @@ package name.abuchen.portfolio.ui.dialogs.transactions; +import java.math.BigDecimal; import java.time.LocalDateTime; import name.abuchen.portfolio.model.Account; @@ -9,6 +10,8 @@ import name.abuchen.portfolio.model.PortfolioTransaction; import name.abuchen.portfolio.model.Transaction; import name.abuchen.portfolio.model.TransactionOwner; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerBuySellTransactionCreator; +import name.abuchen.portfolio.money.Values; import name.abuchen.portfolio.ui.Messages; /* package */class BuySellModel extends AbstractSecurityTransactionModel @@ -47,7 +50,69 @@ public void presetFromSource(Object entry) this.type = e.getPortfolioTransaction().getType(); this.portfolio = (Portfolio) e.getOwner(e.getPortfolioTransaction()); this.account = (Account) e.getOwner(e.getAccountTransaction()); - fillFromTransaction(e.getPortfolioTransaction()); + + if (new LedgerBuySellTransactionCreator(client).isLedgerBacked(e)) + fillFromLedgerBackedTransaction(e.getPortfolioTransaction()); + else + fillFromTransaction(e.getPortfolioTransaction()); + } + + private void fillFromLedgerBackedTransaction(PortfolioTransaction transaction) + { + this.security = transaction.getSecurity(); + + var transactionDate = transaction.getDateTime(); + this.date = transactionDate.toLocalDate(); + this.time = transactionDate.toLocalTime(); + + this.shares = transaction.getShares(); + this.total = transaction.getAmount(); + this.note = transaction.getNote(); + + this.exchangeRate = BigDecimal.ONE; + this.grossValue = 0; + this.convertedGrossValue = 0; + this.fees = 0; + this.taxes = 0; + this.forexFees = 0; + this.forexTaxes = 0; + + transaction.getUnits().forEach(unit -> { + switch (unit.getType()) + { + case GROSS_VALUE: + this.exchangeRate = unit.getExchangeRate(); + this.grossValue = unit.getForex() != null ? unit.getForex().getAmount() + : unit.getAmount().getAmount(); + break; + case FEE: + if (unit.getForex() != null) + this.forexFees += unit.getForex().getAmount(); + else + this.fees += unit.getAmount().getAmount(); + break; + case TAX: + if (unit.getForex() != null) + this.forexTaxes += unit.getForex().getAmount(); + else + this.taxes += unit.getAmount().getAmount(); + break; + default: + throw new UnsupportedOperationException(); + } + }); + + if (grossValue == 0) + { + this.convertedGrossValue = calculateConvertedGrossValue(); + this.grossValue = exchangeRate.compareTo(BigDecimal.ZERO) == 0 ? 0 + : Math.round(convertedGrossValue / exchangeRate.doubleValue()); + } + + if (shares != 0) + this.quote = BigDecimal.valueOf(grossValue * Values.Share.factor() / (shares * Values.Amount.divider())); + + setExchangeRate(exchangeRate); } @Override @@ -64,6 +129,24 @@ public void applyChanges() if (account == null) throw new UnsupportedOperationException(Messages.MsgMissingReferenceAccount); + var ledgerCreator = new LedgerBuySellTransactionCreator(client); + var dateTime = LocalDateTime.of(date, time); + var units = buildUnits(); + + if (source != null && ledgerCreator.isLedgerBacked(source)) + { + ledgerCreator.update(source, portfolio, account, type, dateTime, total, account.getCurrencyCode(), security, + shares, units, note, source.getSource()); + return; + } + + if (source == null) + { + ledgerCreator.create(portfolio, account, type, dateTime, total, account.getCurrencyCode(), security, shares, + units, note, null); + return; + } + BuySellEntry entry; if (source != null && source.getOwner(source.getPortfolioTransaction()).equals(portfolio) @@ -91,7 +174,7 @@ public void applyChanges() } } - entry.setDate(LocalDateTime.of(date, time)); + entry.setDate(dateTime); entry.setCurrencyCode(account.getCurrencyCode()); entry.setSecurity(security); entry.setShares(shares); diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/LedgerNativeComponentInspectorDialog.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/LedgerNativeComponentInspectorDialog.java new file mode 100644 index 0000000000..8e6c79b250 --- /dev/null +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/LedgerNativeComponentInspectorDialog.java @@ -0,0 +1,223 @@ +package name.abuchen.portfolio.ui.dialogs.transactions; + +import java.util.List; +import java.util.function.Function; + +import org.eclipse.jface.dialogs.Dialog; +import org.eclipse.jface.dialogs.IDialogConstants; +import org.eclipse.jface.layout.GridDataFactory; +import org.eclipse.jface.layout.GridLayoutFactory; +import org.eclipse.jface.viewers.ArrayContentProvider; +import org.eclipse.jface.viewers.ColumnLabelProvider; +import org.eclipse.jface.viewers.TableViewer; +import org.eclipse.jface.viewers.TableViewerColumn; +import org.eclipse.swt.SWT; +import org.eclipse.swt.custom.ScrolledComposite; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Group; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Shell; + +import name.abuchen.portfolio.model.ledger.compatibility.LedgerNativeComponentInspectorModel; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerNativeComponentInspectorModel.HeaderField; +import name.abuchen.portfolio.ui.Messages; + +/** + * Shows a read-only inspection view for one Ledger entry. + * The dialog displays persisted Ledger facts and optional Java-only leg configuration metadata. + * It does not provide editing support and does not route through legacy transaction setters. + */ +public class LedgerNativeComponentInspectorDialog extends Dialog +{ + private static final int DIALOG_WIDTH = 1050; + private static final int DIALOG_HEIGHT = 720; + + private final LedgerNativeComponentInspectorModel model; + + public LedgerNativeComponentInspectorDialog(Shell parentShell, LedgerNativeComponentInspectorModel model) + { + super(parentShell); + this.model = model; + } + + @Override + protected boolean isResizable() + { + return true; + } + + @Override + protected void configureShell(Shell shell) + { + super.configureShell(shell); + shell.setText(Messages.LedgerNativeComponentInspectorDialogTitle); + } + + @Override + protected void createButtonsForButtonBar(Composite parent) + { + createButton(parent, IDialogConstants.CLOSE_ID, IDialogConstants.CLOSE_LABEL, true); + } + + @Override + protected void buttonPressed(int buttonId) + { + if (buttonId == IDialogConstants.CLOSE_ID) + close(); + else + super.buttonPressed(buttonId); + } + + @Override + protected Control createDialogArea(Composite parent) + { + ScrolledComposite scrolled = new ScrolledComposite(parent, SWT.V_SCROLL | SWT.H_SCROLL); + GridDataFactory.fillDefaults().grab(true, true).hint(DIALOG_WIDTH, DIALOG_HEIGHT).applyTo(scrolled); + + Composite container = new Composite(scrolled, SWT.NONE); + GridLayoutFactory.fillDefaults().numColumns(1).margins(8, 8).spacing(8, 8).applyTo(container); + + createTable(container, Messages.LedgerNativeComponentInspectorHeader, model.getHeaderRows(), + new String[] { Messages.LedgerNativeComponentInspectorField, + Messages.LedgerNativeComponentInspectorValue }, + row -> new String[] { headerLabel(row.field()), row.value() }); + + createTable(container, Messages.LedgerNativeComponentInspectorEntryParameters, model.getEntryParameters(), + new String[] { Messages.LedgerNativeComponentInspectorParameter, + Messages.LedgerNativeComponentInspectorCode, + Messages.LedgerNativeComponentInspectorValueKind, + Messages.LedgerNativeComponentInspectorValue, + Messages.LedgerNativeComponentInspectorDomain }, + row -> new String[] { row.parameter(), row.code(), row.valueKind(), row.value(), + row.domain() }); + + createLegTable(container); + + createTable(container, Messages.LedgerNativeComponentInspectorPostings, model.getPostings(), + new String[] { Messages.LedgerNativeComponentInspectorPostingUUID, + Messages.LedgerNativeComponentInspectorPostingType, + Messages.ColumnAmount, Messages.ColumnCurrency, + Messages.ColumnSecurity, Messages.ColumnShares, Messages.ColumnAccount, + Messages.ColumnPortfolio }, + row -> new String[] { row.postingUUID(), row.postingType(), row.amount(), row.currency(), + row.security(), row.shares(), row.account(), row.portfolio() }); + + createTable(container, Messages.LedgerNativeComponentInspectorPostingParameters, model.getPostingParameters(), + new String[] { Messages.LedgerNativeComponentInspectorPostingUUID, + Messages.LedgerNativeComponentInspectorPostingType, + Messages.LedgerNativeComponentInspectorParameter, + Messages.LedgerNativeComponentInspectorCode, + Messages.LedgerNativeComponentInspectorValueKind, + Messages.LedgerNativeComponentInspectorValue, + Messages.LedgerNativeComponentInspectorDomain }, + row -> new String[] { row.postingUUID(), row.postingType(), row.parameter(), row.code(), + row.valueKind(), row.value(), row.domain() }); + + createTable(container, Messages.LedgerNativeComponentInspectorProjectionRefs, model.getProjectionRefs(), + new String[] { Messages.LedgerNativeComponentInspectorProjectionRole, + Messages.LedgerNativeComponentInspectorOwner, + Messages.LedgerNativeComponentInspectorProjectionUUID, + Messages.LedgerNativeComponentInspectorPrimaryPostingUUID, + Messages.LedgerNativeComponentInspectorPostingGroupUUID }, + row -> new String[] { row.projectionRole(), row.owner(), row.projectionUUID(), + row.primaryPostingUUID(), row.postingGroupUUID() }); + + scrolled.setContent(container); + scrolled.setExpandHorizontal(true); + scrolled.setExpandVertical(true); + scrolled.setMinSize(container.computeSize(SWT.DEFAULT, SWT.DEFAULT)); + + return scrolled; + } + + private void createLegTable(Composite parent) + { + Group group = new Group(parent, SWT.NONE); + group.setText(Messages.LedgerNativeComponentInspectorFunctionalLegs); + GridDataFactory.fillDefaults().grab(true, false).applyTo(group); + GridLayoutFactory.fillDefaults().margins(6, 6).applyTo(group); + + if (!model.isNativeEntryDefinitionAvailable()) + { + Label label = new Label(group, SWT.WRAP); + label.setText(Messages.LedgerNativeComponentInspectorNoNativeDefinition); + GridDataFactory.fillDefaults().grab(true, false).applyTo(label); + } + + createTable(group, null, model.getLegs(), + new String[] { Messages.LedgerNativeComponentInspectorLegRole, + Messages.LedgerNativeComponentInspectorPostingType, + Messages.LedgerNativeComponentInspectorCardinality, + Messages.LedgerNativeComponentInspectorProjectionRole, + Messages.LedgerNativeComponentInspectorPrimaryExpected, + Messages.LedgerNativeComponentInspectorGroupExpected }, + row -> new String[] { row.legRole(), row.postingType(), row.cardinality(), + row.projectionRole(), row.primaryExpected(), row.groupExpected() }); + } + + private void createTable(Composite parent, String title, List input, String[] columns, + Function values) + { + Composite group; + if (title != null) + { + Group titledGroup = new Group(parent, SWT.NONE); + titledGroup.setText(title); + group = titledGroup; + } + else + { + group = new Composite(parent, SWT.NONE); + } + + GridDataFactory.fillDefaults().grab(true, false).applyTo(group); + GridLayoutFactory.fillDefaults().margins(title != null ? 6 : 0, title != null ? 6 : 0).applyTo(group); + + TableViewer viewer = new TableViewer(group, SWT.BORDER | SWT.FULL_SELECTION | SWT.MULTI); + viewer.getTable().setHeaderVisible(true); + viewer.getTable().setLinesVisible(true); + GridDataFactory.fillDefaults().grab(true, false).hint(SWT.DEFAULT, Math.min(220, 46 + 24 * Math.max(1, input.size()))) + .applyTo(viewer.getTable()); + + for (int columnIndex = 0; columnIndex < columns.length; columnIndex++) + { + final int index = columnIndex; + TableViewerColumn column = new TableViewerColumn(viewer, SWT.NONE); + column.getColumn().setText(columns[columnIndex]); + column.getColumn().setWidth(160); + column.getColumn().setResizable(true); + column.setLabelProvider(new ColumnLabelProvider() + { + @SuppressWarnings("unchecked") + @Override + public String getText(Object element) + { + String[] rowValues = values.apply((T) element); + return index < rowValues.length ? rowValues[index] : ""; //$NON-NLS-1$ + } + }); + } + + viewer.setContentProvider(ArrayContentProvider.getInstance()); + viewer.setInput(input); + } + + private static String headerLabel(HeaderField field) + { + return switch (field) + { + case ENTRY_TYPE -> Messages.LedgerNativeComponentInspectorEntryType; + case ENTRY_UUID -> Messages.LedgerNativeComponentInspectorEntryUUID; + case DATE_TIME -> Messages.LedgerNativeComponentInspectorDateTime; + case NOTE -> Messages.ColumnNote; + case SOURCE -> Messages.ColumnSource; + case SHAPE -> Messages.LedgerNativeComponentInspectorShape; + case NATIVE_TARGETED -> Messages.LedgerNativeComponentInspectorNativeTargeted; + case SELECTED_PROJECTION_ROLE -> Messages.LedgerNativeComponentInspectorSelectedProjectionRole; + case SELECTED_PROJECTION_UUID -> Messages.LedgerNativeComponentInspectorSelectedProjectionUUID; + case SELECTED_PRIMARY_POSTING_UUID -> Messages.LedgerNativeComponentInspectorSelectedPrimaryPostingUUID; + case SELECTED_POSTING_GROUP_UUID -> Messages.LedgerNativeComponentInspectorSelectedPostingGroupUUID; + }; + } +} diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/SecurityDeliveryModel.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/SecurityDeliveryModel.java index 605811a1df..11afdda523 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/SecurityDeliveryModel.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/SecurityDeliveryModel.java @@ -7,6 +7,7 @@ import name.abuchen.portfolio.model.PortfolioTransaction; import name.abuchen.portfolio.model.PortfolioTransaction.Type; import name.abuchen.portfolio.model.TransactionPair; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerDeliveryTransactionCreator; import name.abuchen.portfolio.money.CurrencyUnit; import name.abuchen.portfolio.ui.Messages; @@ -68,6 +69,23 @@ public void applyChanges() if (portfolio.getReferenceAccount() == null) throw new UnsupportedOperationException(Messages.MsgMissingReferenceAccount); + var ledgerDeliveryCreator = new LedgerDeliveryTransactionCreator(client); + + if (source != null && ledgerDeliveryCreator.canUpdate(source.getTransaction())) + { + ledgerDeliveryCreator.update(source.getTransaction(), portfolio, type, LocalDateTime.of(date, time), total, + getTransactionCurrencyCode(), security, shares, null, null, buildUnits(), note, + source.getTransaction().getSource()); + return; + } + + if (source == null) + { + ledgerDeliveryCreator.create(portfolio, type, LocalDateTime.of(date, time), total, + getTransactionCurrencyCode(), security, shares, null, null, buildUnits(), note, null); + return; + } + TransactionPair entry; if (source != null && source.getOwner().equals(portfolio)) diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/SecurityTransferModel.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/SecurityTransferModel.java index 52773e9a2d..564e90f98d 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/SecurityTransferModel.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/SecurityTransferModel.java @@ -15,6 +15,7 @@ import name.abuchen.portfolio.model.Security; import name.abuchen.portfolio.model.Transaction; import name.abuchen.portfolio.model.TransactionOwner; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferTransactionCreator; import name.abuchen.portfolio.money.CurrencyConverter; import name.abuchen.portfolio.money.CurrencyConverterImpl; import name.abuchen.portfolio.money.Values; @@ -68,6 +69,24 @@ public void applyChanges() if (targetPortfolio == null) throw new UnsupportedOperationException(Messages.MsgPortfolioToMissing); + var ledgerCreator = new LedgerPortfolioTransferTransactionCreator(client); + var dateTime = LocalDateTime.of(date, time); + var sourceText = source != null ? source.getSource() : null; + + if (source != null && ledgerCreator.isLedgerBacked(source)) + { + ledgerCreator.update(source, sourcePortfolio, targetPortfolio, security, dateTime, shares, amount, + security.getCurrencyCode(), note, sourceText); + return; + } + + if (source == null) + { + ledgerCreator.create(sourcePortfolio, targetPortfolio, security, dateTime, shares, amount, + security.getCurrencyCode(), note, null); + return; + } + PortfolioTransferEntry t; if (source != null && sourcePortfolio.equals(source.getOwner(source.getSourceTransaction())) @@ -97,7 +116,7 @@ public void applyChanges() } t.setSecurity(security); - t.setDate(LocalDateTime.of(date, time)); + t.setDate(dateTime); t.setShares(shares); t.setAmount(amount); t.setCurrencyCode(security.getCurrencyCode()); @@ -187,8 +206,10 @@ public void presetFromSource(PortfolioTransferEntry entry) this.date = transactionDate.toLocalDate(); this.time = transactionDate.toLocalTime(); this.shares = entry.getSourceTransaction().getShares(); - this.quote = entry.getSourceTransaction().getGrossPricePerShare().toBigDecimal(); this.amount = entry.getTargetTransaction().getAmount(); + this.quote = shares != 0 + ? BigDecimal.valueOf(amount * Values.Share.factor() / (shares * Values.Amount.divider())) + : BigDecimal.ZERO; this.note = entry.getSourceTransaction().getNote(); } diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/preferences/PreferencesInitializer.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/preferences/PreferencesInitializer.java index 9ef493d920..a83acc715e 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/preferences/PreferencesInitializer.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/preferences/PreferencesInitializer.java @@ -31,8 +31,8 @@ public void initializeDefaultPreferences() store.setDefault(UIConstants.Preferences.STORE_SETTINGS_NEXT_TO_FILE, false); store.setDefault(UIConstants.Preferences.DOUBLE_CLICK_CELL_TO_EDIT, true); store.setDefault(UIConstants.Preferences.ENABLE_SWTCHART_PIECHARTS, - Platform.getOS().equals(Platform.OS_LINUX) || (Platform.getOS().equals(Platform.OS_MACOSX) - && Platform.getOSArch().equals(Platform.ARCH_X86_64) + Platform.OS_LINUX.equals(Platform.getOS()) || (Platform.OS_MACOSX.equals(Platform.getOS()) + && Platform.ARCH_X86_64.equals(Platform.getOSArch()) && compareOSVersion("13.0") >= 0)); //$NON-NLS-1$ store.setDefault(UIConstants.Preferences.ALPHAVANTAGE_CALL_FREQUENCY_LIMIT, 5); store.setDefault(UIConstants.Preferences.CALENDAR, "default"); //$NON-NLS-1$ diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/ColumnEditingSupport.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/ColumnEditingSupport.java index f1689f0e22..cf500518ea 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/ColumnEditingSupport.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/ColumnEditingSupport.java @@ -18,6 +18,9 @@ import org.eclipse.swt.widgets.Composite; import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.Transaction; +import name.abuchen.portfolio.model.TransactionPair; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerNativeComponentInspectorModel; import name.abuchen.portfolio.ui.PortfolioPlugin; import name.abuchen.portfolio.ui.UIConstants; import name.abuchen.portfolio.ui.editor.EditorActivationState; @@ -71,7 +74,18 @@ public CellEditor createEditor(Composite composite) public boolean canEdit(Object element) { - return true; + return !isLedgerNativeTargetedProjection(element); + } + + protected final boolean isLedgerNativeTargetedProjection(Object element) + { + if (element instanceof TransactionPair pair) + return LedgerNativeComponentInspectorModel.isLedgerNativeTargetedProjection(pair.getTransaction()); + + if (element instanceof Transaction) + return LedgerNativeComponentInspectorModel.isLedgerNativeTargetedProjection(element); + + return false; } /** diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/DateTimeEditingSupport.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/DateTimeEditingSupport.java index 3a91196e3f..f90298ef27 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/DateTimeEditingSupport.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/DateTimeEditingSupport.java @@ -22,6 +22,10 @@ public class DateTimeEditingSupport extends PropertyEditingSupport DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM), DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT), // DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG), // + DateTimeFormatter.ofPattern("d.M.yyyy, H:mm"), //$NON-NLS-1$ + DateTimeFormatter.ofPattern("d.M.yy, H:mm"), //$NON-NLS-1$ + DateTimeFormatter.ofPattern("d.M.yyyy H:mm"), //$NON-NLS-1$ + DateTimeFormatter.ofPattern("d.M.yy H:mm"), //$NON-NLS-1$ DateTimeFormatter.ofPattern("d.M.yyyy HH:mm"), //$NON-NLS-1$ DateTimeFormatter.ofPattern("d.M.yy HH:mm"), //$NON-NLS-1$ DateTimeFormatter.ISO_DATE_TIME }; @@ -61,6 +65,8 @@ public final Object getValue(Object element) throws Exception @Override public final void setValue(Object element, Object value) throws Exception { + checkLedgerInlineField(element); + String inputValue = String.valueOf(value).trim(); LocalDateTime newValue = null; diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/ExDateEditingSupport.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/ExDateEditingSupport.java index 7be9c09a62..0d5ec84196 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/ExDateEditingSupport.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/ExDateEditingSupport.java @@ -12,8 +12,20 @@ import org.eclipse.swt.widgets.Composite; import org.eclipse.swt.widgets.Text; +import name.abuchen.portfolio.model.Account; import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.AccountTransferEntry; import name.abuchen.portfolio.model.Adaptor; +import name.abuchen.portfolio.model.BuySellEntry; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountOnlyTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountTransferTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerBuySellTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerDividendTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerInlineEditingField; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerInlineEditingPolicy; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerUnitPostingPatch; import name.abuchen.portfolio.money.Values; import name.abuchen.portfolio.ui.Messages; @@ -27,11 +39,31 @@ public class ExDateEditingSupport extends ColumnEditingSupport DateTimeFormatter.ofPattern("d.M.yy"), //$NON-NLS-1$ DateTimeFormatter.ISO_DATE }; + private final Client client; + + public ExDateEditingSupport() + { + this(null); + } + + public ExDateEditingSupport(Client client) + { + this.client = client; + } + @Override public boolean canEdit(Object element) { + if (isLedgerNativeTargetedProjection(element)) + return false; + if (!LedgerInlineEditingPolicy.isEditable(element, LedgerInlineEditingField.EX_DATE)) + return false; + var tx = Adaptor.adapt(AccountTransaction.class, element); - return tx != null && tx.getSecurity() != null; + if (tx == null || tx.getSecurity() == null) + return false; + + return canUpdateLedgerBackedDividend(tx) || !isLedgerBackedAccountTransaction(tx); } @Override @@ -57,7 +89,7 @@ public void setValue(Object element, Object value) { if (oldValue != null) { - tx.setExDate(null); + updateExDate(tx, null); notify(element, null, oldValue); } return; @@ -84,11 +116,73 @@ public void setValue(Object element, Object value) if (!newValue.equals(oldValue)) { - tx.setExDate(newValue); + updateExDate(tx, newValue); notify(element, newValue, oldValue); } } + private void updateExDate(AccountTransaction transaction, LocalDateTime exDate) + { + if (!LedgerInlineEditingPolicy.isEditable(transaction, LedgerInlineEditingField.EX_DATE)) + throw new UnsupportedOperationException( + LedgerDiagnosticCode.LEDGER_UI_009 + .message(Messages.LedgerExDateEditingSupportNoSafeEditorPolicyBlocked)); + + if (updateLedgerBackedDividend(transaction, exDate)) + return; + + if (isLedgerBackedAccountTransaction(transaction)) + throw new UnsupportedOperationException( + LedgerDiagnosticCode.LEDGER_UI_010 + .message(Messages.LedgerExDateEditingSupportNoSafeEditorLedgerBacked)); + + transaction.setExDate(exDate); + } + + private boolean updateLedgerBackedDividend(AccountTransaction transaction, LocalDateTime exDate) + { + if (client == null) + return false; + + if (!canUpdateLedgerBackedDividend(transaction)) + return false; + + new LedgerDividendTransactionCreator(client).update(transaction, ownerOf(transaction), transaction.getType(), + transaction.getDateTime(), transaction.getAmount(), transaction.getCurrencyCode(), + transaction.getSecurity(), transaction.getShares(), exDate, null, + LedgerUnitPostingPatch.none(), transaction.getNote(), transaction.getSource()); + return true; + } + + private boolean canUpdateLedgerBackedDividend(AccountTransaction transaction) + { + return client != null && new LedgerDividendTransactionCreator(client).canUpdate(transaction); + } + + private Account ownerOf(AccountTransaction transaction) + { + return client.getAccounts().stream().filter(account -> account.getTransactions().contains(transaction)) + .findFirst() + .orElseThrow(() -> new IllegalStateException( + Messages.LedgerExDateEditingSupportDividendOwnerNotFound)); + } + + private boolean isLedgerBackedAccountTransaction(AccountTransaction transaction) + { + if (client == null) + return false; + + if (new LedgerAccountOnlyTransactionCreator(client).canUpdate(transaction)) + return true; + + if (transaction.getCrossEntry() instanceof BuySellEntry entry + && new LedgerBuySellTransactionCreator(client).isLedgerBacked(entry)) + return true; + + return transaction.getCrossEntry() instanceof AccountTransferEntry entry + && new LedgerAccountTransferTransactionCreator(client).isLedgerBacked(entry); + } + @Override public CellEditor createEditor(Composite composite) { diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/PropertyEditingSupport.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/PropertyEditingSupport.java index ffe52e9bbc..f4121f63d9 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/PropertyEditingSupport.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/PropertyEditingSupport.java @@ -3,20 +3,27 @@ import java.beans.IntrospectionException; import java.beans.Introspector; import java.beans.PropertyDescriptor; +import java.text.MessageFormat; import java.util.function.Predicate; import name.abuchen.portfolio.model.Adaptor; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerInlineEditingField; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerInlineEditingPolicy; +import name.abuchen.portfolio.ui.Messages; public abstract class PropertyEditingSupport extends ColumnEditingSupport { private Class subjectType; private PropertyDescriptor descriptor; private Predicate canEditCheck; + private LedgerInlineEditingField ledgerInlineEditingField; public PropertyEditingSupport(Class subjectType, String attributeName) { this.subjectType = subjectType; this.descriptor = descriptorFor(subjectType, attributeName); + this.ledgerInlineEditingField = ledgerInlineEditingField(attributeName); } public PropertyEditingSupport setCanEditCheck(Predicate canEditCheck) @@ -33,7 +40,22 @@ protected PropertyDescriptor descriptor() @Override public boolean canEdit(Object element) { - return adapt(element) != null && (canEditCheck == null || canEditCheck.test(element)); + return !isLedgerNativeTargetedProjection(element) && adapt(element) != null + && canEditLedgerInlineField(element) + && (canEditCheck == null || canEditCheck.test(element)); + } + + protected final void checkLedgerInlineField(Object element) + { + if (!canEditLedgerInlineField(element)) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_UI_011 + .message(MessageFormat.format(Messages.LedgerPropertyEditingSupportUnsupportedInlineEdit, + ledgerInlineEditingField))); + } + + private boolean canEditLedgerInlineField(Object element) + { + return ledgerInlineEditingField == null || LedgerInlineEditingPolicy.isEditable(element, ledgerInlineEditingField); } protected Object adapt(Object element) @@ -41,6 +63,18 @@ protected Object adapt(Object element) return Adaptor.adapt(subjectType, element); } + private LedgerInlineEditingField ledgerInlineEditingField(String attributeName) + { + return switch (attributeName) + { + case "dateTime" -> LedgerInlineEditingField.DATE; //$NON-NLS-1$ + case "note" -> LedgerInlineEditingField.NOTE; //$NON-NLS-1$ + case "source" -> LedgerInlineEditingField.SOURCE; //$NON-NLS-1$ + case "shares" -> LedgerInlineEditingField.SHARES; //$NON-NLS-1$ + default -> null; + }; + } + protected PropertyDescriptor descriptorFor(Class subjectType, String attributeName) { try diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/StringEditingSupport.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/StringEditingSupport.java index c984a08d54..f56772916b 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/StringEditingSupport.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/StringEditingSupport.java @@ -28,6 +28,8 @@ public final Object getValue(Object element) throws Exception @Override public final void setValue(Object element, Object value) throws Exception { + checkLedgerInlineField(element); + Object subject = adapt(element); String newValue = (String) value; diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/TransactionOwnerListEditingSupport.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/TransactionOwnerListEditingSupport.java index 3c33d10c6d..eefdc039e1 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/TransactionOwnerListEditingSupport.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/TransactionOwnerListEditingSupport.java @@ -1,5 +1,6 @@ package name.abuchen.portfolio.ui.util.viewers; +import java.text.MessageFormat; import java.util.List; import java.util.function.BiFunction; import java.util.stream.Collectors; @@ -10,12 +11,23 @@ import org.eclipse.swt.widgets.Composite; import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.AccountTransferEntry; +import name.abuchen.portfolio.model.BuySellEntry; import name.abuchen.portfolio.model.Client; import name.abuchen.portfolio.model.CrossEntry; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.PortfolioTransaction; +import name.abuchen.portfolio.model.PortfolioTransferEntry; import name.abuchen.portfolio.model.Transaction; import name.abuchen.portfolio.model.TransactionOwner; import name.abuchen.portfolio.model.TransactionPair; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountTransferTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerBuySellTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerOwnerPatchHelper; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferTransactionCreator; +import name.abuchen.portfolio.ui.Messages; /** * Creates a cell editor with a combo box of accounts or portfolios. @@ -76,16 +88,49 @@ else if (element instanceof TransactionPair pair) return null; } - private CrossEntry getCrossEntry(Object element) + @Override + public boolean canEdit(Object element) { - Transaction t = getTransaction(element); - return t != null ? t.getCrossEntry() : null; + Transaction transaction = getTransaction(element); + if (isLedgerNativeTargetedProjection(transaction)) + return false; + + return transaction != null && transaction.getCrossEntry() != null && canEdit(transaction); } - @Override - public boolean canEdit(Object element) + private boolean isLedgerBacked(Transaction transaction) { - return getCrossEntry(element) != null; + if (transaction instanceof AccountTransaction + && transaction.getCrossEntry() instanceof AccountTransferEntry entry) + return new LedgerAccountTransferTransactionCreator(client).isLedgerBacked(entry); + + if (transaction instanceof PortfolioTransaction + && transaction.getCrossEntry() instanceof PortfolioTransferEntry entry) + return new LedgerPortfolioTransferTransactionCreator(client).isLedgerBacked(entry); + + if (transaction.getCrossEntry() instanceof BuySellEntry entry) + return new LedgerBuySellTransactionCreator(client).isLedgerBacked(entry); + + return false; + } + + private boolean canEdit(Transaction transaction) + { + CrossEntry crossEntry = transaction.getCrossEntry(); + + if (crossEntry instanceof AccountTransferEntry entry) + return !new LedgerAccountTransferTransactionCreator(client).isLedgerBacked(entry) + || new LedgerAccountTransferTransactionCreator(client).canUpdate(entry); + + if (crossEntry instanceof PortfolioTransferEntry entry) + return !new LedgerPortfolioTransferTransactionCreator(client).isLedgerBacked(entry) + || new LedgerPortfolioTransferTransactionCreator(client).canUpdate(entry); + + if (crossEntry instanceof BuySellEntry entry) + return !new LedgerBuySellTransactionCreator(client).isLedgerBacked(entry) + || new LedgerBuySellTransactionCreator(client).canUpdate(entry); + + return true; } @Override @@ -193,6 +238,15 @@ public final void setValue(Object element, Object value) throws Exception if (newValue.equals(oldValue)) return; + validateOwnerChange(crossEntry, transaction, newValue); + + if (isLedgerBacked(transaction)) + { + setLedgerBackedValue(crossEntry, transaction, newValue); + notify(element, newValue, oldValue); + return; + } + // since we are editing the transaction owner, we must first remove the // transaction and then re-insert it @@ -205,4 +259,97 @@ public final void setValue(Object element, Object value) throws Exception notify(element, newValue, oldValue); } + + private void validateOwnerChange(CrossEntry crossEntry, Transaction transaction, TransactionOwner newValue) + { + TransactionOwner ownerToEdit = editMode.getOwner(crossEntry, transaction); + + if (!ownerToEdit.getClass().isInstance(newValue)) + throw new IllegalArgumentException("unsupported owner type " + newValue); //$NON-NLS-1$ + + if (ownerToEdit instanceof Account account && newValue instanceof Account newAccount + && !account.getCurrencyCode().equals(newAccount.getCurrencyCode())) + throw new IllegalArgumentException("account owner changes require identical currencies"); //$NON-NLS-1$ + + if (crossEntry.getOwner(transaction).getClass().equals(crossEntry.getCrossOwner(transaction).getClass())) + { + TransactionOwner otherOwner = editMode == EditMode.OWNER ? crossEntry.getCrossOwner(transaction) + : crossEntry.getOwner(transaction); + + if (newValue.equals(otherOwner)) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_UI_012 + .message(Messages.LedgerTransactionOwnerListEditingSupportDistinctOwnersRequired)); + } + } + + private void setLedgerBackedValue(CrossEntry crossEntry, Transaction transaction, TransactionOwner newValue) + { + if (crossEntry instanceof BuySellEntry entry) + { + updateBuySell(entry, newValue); + return; + } + + if (crossEntry instanceof AccountTransferEntry entry && newValue instanceof Account account) + { + updateAccountTransfer(entry, editMode.getOwner(crossEntry, transaction), account); + return; + } + + if (crossEntry instanceof PortfolioTransferEntry entry && newValue instanceof Portfolio portfolio) + { + updatePortfolioTransfer(entry, editMode.getOwner(crossEntry, transaction), portfolio); + return; + } + + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_UI_013 + .message(MessageFormat.format( + Messages.LedgerTransactionOwnerListEditingSupportUnsupportedOwnerEdit, + crossEntry))); + } + + private void updateBuySell(BuySellEntry entry, TransactionOwner newValue) + { + var helper = new LedgerOwnerPatchHelper(client); + if (newValue instanceof Account newAccount) + helper.moveBuySellAccountSide(entry, newAccount); + else if (newValue instanceof Portfolio newPortfolio) + helper.moveBuySellPortfolioSide(entry, newPortfolio); + else + throw new IllegalArgumentException( + LedgerDiagnosticCode.LEDGER_UI_014.message(MessageFormat.format( + Messages.LedgerTransactionOwnerListEditingSupportUnsupportedOwnerType, + newValue))); + } + + private void updateAccountTransfer(AccountTransferEntry entry, TransactionOwner oldValue, Account newAccount) + { + var helper = new LedgerOwnerPatchHelper(client); + + if (oldValue.equals(entry.getSourceAccount())) + helper.moveAccountTransferSource(entry, newAccount); + else if (oldValue.equals(entry.getTargetAccount())) + helper.moveAccountTransferTarget(entry, newAccount); + else + throw new IllegalArgumentException( + LedgerDiagnosticCode.LEDGER_UI_015.message(MessageFormat.format( + Messages.LedgerTransactionOwnerListEditingSupportAccountTransferOwnerNotFound, + oldValue))); + } + + private void updatePortfolioTransfer(PortfolioTransferEntry entry, TransactionOwner oldValue, + Portfolio newPortfolio) + { + var helper = new LedgerOwnerPatchHelper(client); + + if (oldValue.equals(entry.getSourcePortfolio())) + helper.movePortfolioTransferSource(entry, newPortfolio); + else if (oldValue.equals(entry.getTargetPortfolio())) + helper.movePortfolioTransferTarget(entry, newPortfolio); + else + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_UI_016 + .message(MessageFormat.format( + Messages.LedgerTransactionOwnerListEditingSupportPortfolioTransferOwnerNotFound, + oldValue))); + } } diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/TransactionTypeEditingSupport.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/TransactionTypeEditingSupport.java index bfb29e13be..d523700e92 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/TransactionTypeEditingSupport.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/TransactionTypeEditingSupport.java @@ -12,16 +12,37 @@ import name.abuchen.portfolio.model.Account; import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.AccountTransferEntry; +import name.abuchen.portfolio.model.BuySellEntry; import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.LedgerAccountTypeToggleConverter; +import name.abuchen.portfolio.model.LedgerBuySellDeliveryConverter; +import name.abuchen.portfolio.model.LedgerBuySellReversalConverter; +import name.abuchen.portfolio.model.LedgerDeliveryDirectionConverter; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.LedgerPortfolioCompositeTypeConverter; +import name.abuchen.portfolio.model.LedgerTransferDirectionConverter; import name.abuchen.portfolio.model.Portfolio; import name.abuchen.portfolio.model.PortfolioTransaction; +import name.abuchen.portfolio.model.PortfolioTransferEntry; import name.abuchen.portfolio.model.Transaction; import name.abuchen.portfolio.model.TransactionPair; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountOnlyTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountTransferTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerBuySellTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerDeliveryTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerDividendTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerInlineEditingField; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerInlineEditingPolicy; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferTransactionCreator; +import name.abuchen.portfolio.ui.Messages; import name.abuchen.portfolio.ui.views.actions.ConvertBuySellToDeliveryAction; import name.abuchen.portfolio.ui.views.actions.ConvertDeliveryToBuySellAction; +import name.abuchen.portfolio.ui.views.actions.ConvertPortfolioCompositeTypeAction; import name.abuchen.portfolio.ui.views.actions.RevertBuySellAction; import name.abuchen.portfolio.ui.views.actions.RevertDeliveryAction; import name.abuchen.portfolio.ui.views.actions.RevertDepositRemovalAction; +import name.abuchen.portfolio.ui.views.actions.RevertFeeTaxAction; import name.abuchen.portfolio.ui.views.actions.RevertInterestAction; import name.abuchen.portfolio.ui.views.actions.RevertTransferAction; @@ -38,7 +59,7 @@ public class TransactionTypeEditingSupport extends ColumnEditingSupport new Class[] { ConvertBuySellToDeliveryAction.class }, PortfolioTransaction.Type.BUY, PortfolioTransaction.Type.DELIVERY_OUTBOUND, - new Class[] { RevertBuySellAction.class, ConvertBuySellToDeliveryAction.class }, + new Class[] { ConvertPortfolioCompositeTypeAction.class }, PortfolioTransaction.Type.SELL, PortfolioTransaction.Type.BUY, new Class[] { RevertBuySellAction.class }, @@ -47,7 +68,7 @@ public class TransactionTypeEditingSupport extends ColumnEditingSupport new Class[] { ConvertBuySellToDeliveryAction.class }, PortfolioTransaction.Type.SELL, PortfolioTransaction.Type.DELIVERY_INBOUND, - new Class[] { RevertBuySellAction.class, ConvertBuySellToDeliveryAction.class }, + new Class[] { ConvertPortfolioCompositeTypeAction.class }, PortfolioTransaction.Type.DELIVERY_INBOUND, PortfolioTransaction.Type.DELIVERY_OUTBOUND, new Class[] { RevertDeliveryAction.class }, @@ -56,7 +77,7 @@ public class TransactionTypeEditingSupport extends ColumnEditingSupport new Class[] { ConvertDeliveryToBuySellAction.class }, PortfolioTransaction.Type.DELIVERY_INBOUND, PortfolioTransaction.Type.SELL, - new Class[] { RevertDeliveryAction.class, ConvertDeliveryToBuySellAction.class }, + new Class[] { ConvertPortfolioCompositeTypeAction.class }, PortfolioTransaction.Type.DELIVERY_OUTBOUND, PortfolioTransaction.Type.DELIVERY_INBOUND, new Class[] { RevertDeliveryAction.class }, @@ -65,7 +86,7 @@ public class TransactionTypeEditingSupport extends ColumnEditingSupport new Class[] { ConvertDeliveryToBuySellAction.class }, PortfolioTransaction.Type.DELIVERY_OUTBOUND, PortfolioTransaction.Type.BUY, - new Class[] { RevertDeliveryAction.class, ConvertDeliveryToBuySellAction.class }, + new Class[] { ConvertPortfolioCompositeTypeAction.class }, AccountTransaction.Type.SELL, AccountTransaction.Type.BUY, new Class[] { RevertBuySellAction.class }, @@ -89,7 +110,19 @@ public class TransactionTypeEditingSupport extends ColumnEditingSupport new Class[] { RevertInterestAction.class }, AccountTransaction.Type.INTEREST_CHARGE, AccountTransaction.Type.INTEREST, - new Class[] { RevertInterestAction.class } }; + new Class[] { RevertInterestAction.class }, + + AccountTransaction.Type.FEES, AccountTransaction.Type.FEES_REFUND, + new Class[] { RevertFeeTaxAction.class }, + + AccountTransaction.Type.FEES_REFUND, AccountTransaction.Type.FEES, + new Class[] { RevertFeeTaxAction.class }, + + AccountTransaction.Type.TAXES, AccountTransaction.Type.TAX_REFUND, + new Class[] { RevertFeeTaxAction.class }, + + AccountTransaction.Type.TAX_REFUND, AccountTransaction.Type.TAXES, + new Class[] { RevertFeeTaxAction.class } }; private final Client client; @@ -158,11 +191,16 @@ else if (t instanceof PortfolioTransaction pt) public boolean canEdit(Object element) { Transaction t = getTransaction(element); + if (isLedgerNativeTargetedProjection(t)) + return false; + if (!LedgerInlineEditingPolicy.isEditable(t, LedgerInlineEditingField.TYPE)) + return false; + Enum type = getTypeValue(t); for (int ii = 0; ii < TRANSITIONS.length; ii += 3) { - if (TRANSITIONS[ii] == type) + if (TRANSITIONS[ii] == type && supportsTransition(t, type, (Enum) TRANSITIONS[ii + 1])) return true; } @@ -187,7 +225,7 @@ public final void prepareEditor(Object element) for (int ii = 0; ii < TRANSITIONS.length; ii += 3) { - if (TRANSITIONS[ii] == type) + if (TRANSITIONS[ii] == type && supportsTransition(t, type, (Enum) TRANSITIONS[ii + 1])) comboBoxItems.add(TRANSITIONS[ii + 1]); } @@ -219,6 +257,14 @@ public final void setValue(Object element, Object value) throws Exception if (newValue.equals(oldValue)) return; + if (!LedgerInlineEditingPolicy.isEditable(t, LedgerInlineEditingField.TYPE)) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_UI_001 + .message(Messages.LedgerTransactionTypeEditingSupportPolicyBlockedTransition)); + + if (!supportsTransition(t, oldValue, newValue)) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_UI_002 + .message(Messages.LedgerTransactionTypeEditingSupportUnsupportedTransition)); + TransactionPair pair = getTransactionPair(element); Class[] transition = getTransition(oldValue, newValue); @@ -240,4 +286,172 @@ private Class[] getTransition(Enum fromValue, Enum toValue) throw new IllegalArgumentException("transition from " + fromValue + " to " + toValue + " not found"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ } + + private boolean supportsTransition(Transaction transaction, Enum fromValue, Enum toValue) + { + if (!isLedgerBacked(transaction)) + return true; + + if (isLedgerBackedAccountTransfer(transaction)) + return (fromValue == AccountTransaction.Type.TRANSFER_IN && toValue == AccountTransaction.Type.TRANSFER_OUT + || fromValue == AccountTransaction.Type.TRANSFER_OUT + && toValue == AccountTransaction.Type.TRANSFER_IN) + && new LedgerTransferDirectionConverter(client) + .canReverseSafely((AccountTransferEntry) transaction.getCrossEntry()); + + if (isLedgerBackedPortfolioTransfer(transaction)) + return (fromValue == PortfolioTransaction.Type.TRANSFER_IN + && toValue == PortfolioTransaction.Type.TRANSFER_OUT + || fromValue == PortfolioTransaction.Type.TRANSFER_OUT + && toValue == PortfolioTransaction.Type.TRANSFER_IN) + && new LedgerTransferDirectionConverter(client) + .canReverseSafely((PortfolioTransferEntry) transaction.getCrossEntry()); + + if (isLedgerBackedBuySell(transaction)) + { + if (fromValue == AccountTransaction.Type.BUY) + return toValue == AccountTransaction.Type.SELL + && new LedgerBuySellReversalConverter(client) + .canReverseSafely((BuySellEntry) transaction.getCrossEntry()); + if (fromValue == AccountTransaction.Type.SELL) + return toValue == AccountTransaction.Type.BUY + && new LedgerBuySellReversalConverter(client) + .canReverseSafely((BuySellEntry) transaction.getCrossEntry()); + if (fromValue == PortfolioTransaction.Type.BUY) + return toValue == PortfolioTransaction.Type.SELL + && new LedgerBuySellReversalConverter(client) + .canReverseSafely((BuySellEntry) transaction.getCrossEntry()) + || toValue == PortfolioTransaction.Type.DELIVERY_INBOUND + && new LedgerBuySellDeliveryConverter(client) + .canConvertSafely(portfolioPair(transaction)) + || toValue == PortfolioTransaction.Type.DELIVERY_OUTBOUND + && new LedgerPortfolioCompositeTypeConverter(client) + .canConvertSafely(portfolioPair(transaction)); + if (fromValue == PortfolioTransaction.Type.SELL) + return toValue == PortfolioTransaction.Type.BUY + && new LedgerBuySellReversalConverter(client) + .canReverseSafely((BuySellEntry) transaction.getCrossEntry()) + || toValue == PortfolioTransaction.Type.DELIVERY_OUTBOUND + && new LedgerBuySellDeliveryConverter(client) + .canConvertSafely(portfolioPair(transaction)) + || toValue == PortfolioTransaction.Type.DELIVERY_INBOUND + && new LedgerPortfolioCompositeTypeConverter(client) + .canConvertSafely(portfolioPair(transaction)); + } + + if (isLedgerBackedDelivery(transaction)) + { + if (fromValue == PortfolioTransaction.Type.DELIVERY_INBOUND) + return toValue == PortfolioTransaction.Type.DELIVERY_OUTBOUND + && new LedgerDeliveryDirectionConverter(client) + .canReverseSafely(portfolioPair(transaction)) + || toValue == PortfolioTransaction.Type.BUY + && new LedgerBuySellDeliveryConverter(client) + .canConvertDeliveryToBuySellSafely( + portfolioPair(transaction)) + || toValue == PortfolioTransaction.Type.SELL + && new LedgerPortfolioCompositeTypeConverter(client) + .canConvertSafely(portfolioPair(transaction)); + if (fromValue == PortfolioTransaction.Type.DELIVERY_OUTBOUND) + return toValue == PortfolioTransaction.Type.DELIVERY_INBOUND + && new LedgerDeliveryDirectionConverter(client) + .canReverseSafely(portfolioPair(transaction)) + || toValue == PortfolioTransaction.Type.SELL + && new LedgerBuySellDeliveryConverter(client) + .canConvertDeliveryToBuySellSafely( + portfolioPair(transaction)) + || toValue == PortfolioTransaction.Type.BUY + && new LedgerPortfolioCompositeTypeConverter(client) + .canConvertSafely(portfolioPair(transaction)); + } + + if (isLedgerBackedAccountOnly(transaction)) + { + if (fromValue == AccountTransaction.Type.DEPOSIT) + return toValue == AccountTransaction.Type.REMOVAL && new LedgerAccountTypeToggleConverter(client) + .canToggleSafely(accountPair(transaction)); + if (fromValue == AccountTransaction.Type.REMOVAL) + return toValue == AccountTransaction.Type.DEPOSIT && new LedgerAccountTypeToggleConverter(client) + .canToggleSafely(accountPair(transaction)); + if (fromValue == AccountTransaction.Type.INTEREST) + return toValue == AccountTransaction.Type.INTEREST_CHARGE + && new LedgerAccountTypeToggleConverter(client).canToggleSafely( + accountPair(transaction)); + if (fromValue == AccountTransaction.Type.INTEREST_CHARGE) + return toValue == AccountTransaction.Type.INTEREST && new LedgerAccountTypeToggleConverter(client) + .canToggleSafely(accountPair(transaction)); + if (fromValue == AccountTransaction.Type.FEES) + return toValue == AccountTransaction.Type.FEES_REFUND && new LedgerAccountTypeToggleConverter(client) + .canToggleSafely(accountPair(transaction)); + if (fromValue == AccountTransaction.Type.FEES_REFUND) + return toValue == AccountTransaction.Type.FEES && new LedgerAccountTypeToggleConverter(client) + .canToggleSafely(accountPair(transaction)); + if (fromValue == AccountTransaction.Type.TAXES) + return toValue == AccountTransaction.Type.TAX_REFUND && new LedgerAccountTypeToggleConverter(client) + .canToggleSafely(accountPair(transaction)); + if (fromValue == AccountTransaction.Type.TAX_REFUND) + return toValue == AccountTransaction.Type.TAXES && new LedgerAccountTypeToggleConverter(client) + .canToggleSafely(accountPair(transaction)); + } + + return false; + } + + @SuppressWarnings("unchecked") + private TransactionPair accountPair(Transaction transaction) + { + return (TransactionPair) getTransactionPair(transaction); + } + + @SuppressWarnings("unchecked") + private TransactionPair portfolioPair(Transaction transaction) + { + return (TransactionPair) getTransactionPair(transaction); + } + + private boolean isLedgerBacked(Transaction transaction) + { + return isLedgerBackedAccountTransfer(transaction) || isLedgerBackedPortfolioTransfer(transaction) + || isLedgerBackedBuySell(transaction) || isLedgerBackedDelivery(transaction) + || isLedgerBackedAccountOnly(transaction) || isLedgerBackedDividend(transaction); + } + + private boolean isLedgerBackedAccountTransfer(Transaction transaction) + { + return transaction instanceof AccountTransaction + && transaction.getCrossEntry() instanceof AccountTransferEntry entry + && new LedgerAccountTransferTransactionCreator(client).isLedgerBacked(entry); + } + + private boolean isLedgerBackedPortfolioTransfer(Transaction transaction) + { + return transaction instanceof PortfolioTransaction + && transaction.getCrossEntry() instanceof PortfolioTransferEntry entry + && new LedgerPortfolioTransferTransactionCreator(client).isLedgerBacked(entry); + } + + private boolean isLedgerBackedBuySell(Transaction transaction) + { + return transaction.getCrossEntry() instanceof BuySellEntry entry + && new LedgerBuySellTransactionCreator(client).isLedgerBacked(entry); + } + + private boolean isLedgerBackedDelivery(Transaction transaction) + { + return transaction instanceof PortfolioTransaction portfolioTransaction + && new LedgerDeliveryTransactionCreator(client).canUpdate(portfolioTransaction); + } + + private boolean isLedgerBackedAccountOnly(Transaction transaction) + { + return transaction instanceof AccountTransaction accountTransaction + && new LedgerAccountOnlyTransactionCreator(client).canUpdate(accountTransaction); + } + + private boolean isLedgerBackedDividend(Transaction transaction) + { + return transaction instanceof AccountTransaction accountTransaction + && new LedgerDividendTransactionCreator(client).canUpdate(accountTransaction); + } + } diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/ValueEditingSupport.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/ValueEditingSupport.java index 0039807372..48096e3fed 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/ValueEditingSupport.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/ValueEditingSupport.java @@ -64,6 +64,8 @@ public final Object getValue(Object element) throws Exception @Override public void setValue(Object element, Object value) throws Exception { + checkLedgerInlineField(element); + Object subject = adapt(element); Number newValue = stringToLong.convert(String.valueOf(value)); diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/AllTransactionsView.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/AllTransactionsView.java index 90137a6eda..a4e53c13f5 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/AllTransactionsView.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/AllTransactionsView.java @@ -7,6 +7,7 @@ import java.io.Writer; import java.nio.charset.StandardCharsets; import java.text.MessageFormat; +import java.util.ArrayList; import java.util.List; import jakarta.inject.Inject; @@ -25,6 +26,9 @@ import org.eclipse.swt.widgets.FileDialog; import name.abuchen.portfolio.json.JClient; +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.PortfolioTransaction; import name.abuchen.portfolio.model.TransactionPair; import name.abuchen.portfolio.snapshot.filter.PortfolioClientFilter; import name.abuchen.portfolio.ui.Images; @@ -61,7 +65,7 @@ public AllTransactionsView(IPreferenceStore preferenceStore) @Override public void notifyModelUpdated() { - var allTransactions = getClient().getAllTransactions(); + var allTransactions = getTransactionsForView(getClient()); table.setInput(allTransactions); updateTitle(Messages.LabelAllTransactions + " (" //$NON-NLS-1$ @@ -153,10 +157,7 @@ public boolean select(Viewer viewer, Object parentElement, Object element) if (clientFilter == null) return true; TransactionPair tx = (TransactionPair) element; - // check owner and cross owner - return clientFilter.hasElement(tx.getOwner()) || (tx.getTransaction().getCrossEntry() != null - && clientFilter.hasElement(tx.getTransaction().getCrossEntry() // - .getCrossOwner(tx.getTransaction()))); + return matchesClientFilter(clientFilter, tx); } }); @@ -168,6 +169,47 @@ public boolean select(Viewer viewer, Object parentElement, Object element) return table.getControl(); } + static List> getTransactionsForView(Client client) + { + List> transactions = new ArrayList<>(); + + for (var portfolio : client.getPortfolios()) + portfolio.getTransactions().stream().map(t -> new TransactionPair<>(portfolio, t)) + .forEach(transactions::add); + + for (var account : client.getAccounts()) + account.getTransactions().stream() + .filter(t -> t.getType() != AccountTransaction.Type.BUY + && t.getType() != AccountTransaction.Type.SELL) + .map(t -> new TransactionPair<>(account, t)).forEach(transactions::add); + + return transactions; + } + + static boolean matchesClientFilter(PortfolioClientFilter clientFilter, TransactionPair tx) + { + if (isTransfer(tx)) + return clientFilter.hasElement(tx.getOwner()); + + // check owner and cross owner + return clientFilter.hasElement(tx.getOwner()) || (tx.getTransaction().getCrossEntry() != null + && clientFilter.hasElement(tx.getTransaction().getCrossEntry() + .getCrossOwner(tx.getTransaction()))); + } + + private static boolean isTransfer(TransactionPair tx) + { + var transaction = tx.getTransaction(); + + return transaction instanceof AccountTransaction accountTransaction + && (accountTransaction.getType() == AccountTransaction.Type.TRANSFER_IN + || accountTransaction.getType() == AccountTransaction.Type.TRANSFER_OUT) + || transaction instanceof PortfolioTransaction portfolioTransaction + && (portfolioTransaction.getType() == PortfolioTransaction.Type.TRANSFER_IN + || portfolioTransaction + .getType() == PortfolioTransaction.Type.TRANSFER_OUT); + } + @Override protected void addPanePages(List pages) { diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/InvestmentPlanListView.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/InvestmentPlanListView.java index de6084a7c7..5399e0a687 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/InvestmentPlanListView.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/InvestmentPlanListView.java @@ -257,13 +257,14 @@ public Image getImage(Object e) support.addColumn(column); column = new Column(Messages.ColumnLastDate, SWT.None, 80); - column.setLabelProvider(new DateLabelProvider(e -> ((InvestmentPlan) e).getLastDate().orElse(null))); + column.setLabelProvider( + new DateLabelProvider(e -> ((InvestmentPlan) e).getLastDate(getClient()).orElse(null))); ColumnViewerSorter.create(InvestmentPlan.class, "LastDate").attachTo(column); //$NON-NLS-1$ support.addColumn(column); column = new Column(Messages.ColumnNextDate, SWT.None, 80); - column.setLabelProvider( - new DateLabelProvider(e -> ((InvestmentPlan) e).getDateOfNextTransactionToBeGenerated())); + column.setLabelProvider(new DateLabelProvider( + e -> ((InvestmentPlan) e).getDateOfNextTransactionToBeGenerated(getClient()))); ColumnViewerSorter.create(InvestmentPlan.class, "DateOfNextTransactionToBeGenerated").attachTo(column); //$NON-NLS-1$ support.addColumn(column); @@ -377,13 +378,13 @@ public void run() try { CurrencyConverterImpl converter = new CurrencyConverterImpl(factory, getClient().getBaseCurrency()); - List> latest = plan.generateTransactions(converter); + List> latest = plan.generateTransactions(getClient(), converter); if (latest.isEmpty()) { MessageDialog.openInformation(getActiveShell(), Messages.LabelInfo, MessageFormat.format( Messages.InvestmentPlanInfoNoTransactionsGenerated, - Values.Date.format(plan.getDateOfNextTransactionToBeGenerated()))); + Values.Date.format(plan.getDateOfNextTransactionToBeGenerated(getClient())))); } else { diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/TransactionContextMenu.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/TransactionContextMenu.java index 78f6290e55..1d8ef7aa7d 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/TransactionContextMenu.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/TransactionContextMenu.java @@ -2,8 +2,10 @@ import java.util.ArrayList; import java.util.Collection; +import java.util.HashSet; import java.util.Iterator; import java.util.List; +import java.util.Set; import org.eclipse.jface.action.Action; import org.eclipse.jface.action.IMenuManager; @@ -16,10 +18,13 @@ import name.abuchen.portfolio.model.AccountTransaction; import name.abuchen.portfolio.model.AccountTransferEntry; import name.abuchen.portfolio.model.BuySellEntry; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.CrossEntry; import name.abuchen.portfolio.model.Portfolio; import name.abuchen.portfolio.model.PortfolioTransaction; import name.abuchen.portfolio.model.PortfolioTransferEntry; import name.abuchen.portfolio.model.TransactionPair; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerNativeComponentInspectorModel; import name.abuchen.portfolio.ui.Messages; import name.abuchen.portfolio.ui.dialogs.transactions.AccountTransactionDialog; import name.abuchen.portfolio.ui.dialogs.transactions.AccountTransferDialog; @@ -33,6 +38,7 @@ import name.abuchen.portfolio.ui.views.actions.ConvertDeliveryToBuySellAction; import name.abuchen.portfolio.ui.views.actions.ConvertTransferToDepositRemovalAction; import name.abuchen.portfolio.ui.views.actions.CreateRemovalForDividendAction; +import name.abuchen.portfolio.ui.views.actions.LedgerNativeComponentInspectorAction; public class TransactionContextMenu { @@ -70,12 +76,50 @@ public void menuAboutToShow(IMenuManager manager, boolean fullContextMenu, IStru manager.add(new Separator()); - manager.add(new SimpleAction(Messages.MenuTransactionDelete, a -> { - for (Object tx : selection.toArray()) - ((TransactionPair) tx).deleteTransaction(owner.getClient()); + if (!containsLedgerNativeTargetedProjection(selection)) + { + manager.add(new SimpleAction(Messages.MenuTransactionDelete, a -> { + deleteTransactions(owner.getClient(), selection.toArray()); - owner.markDirty(); - })); + owner.markDirty(); + })); + } + } + } + + static void deleteTransactions(Client client, Object[] selectedTransactions) + { + Set deletedLedgerEntryUUIDs = new HashSet<>(); + Set deletedTransactionUUIDs = new HashSet<>(); + + for (Object item : selectedTransactions) + { + TransactionPair pair = (TransactionPair) item; + var transaction = pair.getTransaction(); + var ledgerEntryUUID = pair.getLedgerEntryUUID(); + + if (ledgerEntryUUID.isPresent()) + { + if (!deletedLedgerEntryUUIDs.add(ledgerEntryUUID.get())) + continue; + + pair.deleteTransaction(client); + continue; + } + + if (!deletedTransactionUUIDs.add(transaction.getUUID())) + continue; + + CrossEntry crossEntry = transaction.getCrossEntry(); + if (crossEntry != null) + { + var crossTransaction = crossEntry.getCrossTransaction(transaction); + + if (crossTransaction != null) + deletedTransactionUUIDs.add(crossTransaction.getUUID()); + } + + pair.deleteTransaction(client); } } @@ -87,6 +131,9 @@ public void handleEditKey(KeyEvent e, IStructuredSelection selection) return; TransactionPair tx = (TransactionPair) selection.getFirstElement(); + if (isLedgerNativeTargetedProjection(tx)) + return; + tx.withAccountTransaction().ifPresent(t -> createEditAccountTransactionAction(t).run()); tx.withPortfolioTransaction().ifPresent(t -> createEditPortfolioTransactionAction(t).run()); } @@ -96,6 +143,9 @@ public void handleEditKey(KeyEvent e, IStructuredSelection selection) return; TransactionPair tx = (TransactionPair) selection.getFirstElement(); + if (isLedgerNativeTargetedProjection(tx)) + return; + tx.withAccountTransaction().ifPresent(t -> createCopyAccountTransactionAction(t).run()); tx.withPortfolioTransaction().ifPresent(t -> createCopyPortfolioTransactionAction(t).run()); } @@ -112,6 +162,9 @@ private void fillContextMenuAccountTxList(IMenuManager manager, IStructuredSelec if (accountTxPairs.size() != selection.size()) return; + if (accountTxPairs.stream().anyMatch(TransactionContextMenu::isLedgerNativeTargetedProjection)) + return; + var transfers = accountTxPairs.stream() .filter(p -> p.getTransaction().getType() == AccountTransaction.Type.TRANSFER_IN || p.getTransaction().getType() == AccountTransaction.Type.TRANSFER_OUT) @@ -144,34 +197,47 @@ private void fillContextMenuPortfolioTxList(IMenuManager manager, IStructuredSel if (txCollection.size() != selection.size()) return; - boolean allBuyOrSellType = true; - boolean allDelivery = true; - - for (TransactionPair tx : txCollection) - { - PortfolioTransaction ptx = tx.getTransaction(); - - allBuyOrSellType &= ptx.getType() == PortfolioTransaction.Type.BUY - || ptx.getType() == PortfolioTransaction.Type.SELL; - - allDelivery &= ptx.getType() == PortfolioTransaction.Type.DELIVERY_INBOUND - || ptx.getType() == PortfolioTransaction.Type.DELIVERY_OUTBOUND; - } + if (txCollection.stream().anyMatch(TransactionContextMenu::isLedgerNativeTargetedProjection)) + return; - if (allBuyOrSellType) + if (supportsBuySellToDeliveryAction(txCollection)) { manager.add(new ConvertBuySellToDeliveryAction(owner.getClient(), txCollection)); } - if (allDelivery) + if (supportsDeliveryToBuySellAction(txCollection)) { manager.add(new ConvertDeliveryToBuySellAction(owner.getClient(), txCollection)); } } + static boolean supportsBuySellToDeliveryAction(Collection> txCollection) + { + return !txCollection.isEmpty() && txCollection.stream().noneMatch(TransactionContextMenu::isLedgerNativeTargetedProjection) + && txCollection.stream().allMatch(tx -> { + var type = tx.getTransaction().getType(); + return type == PortfolioTransaction.Type.BUY || type == PortfolioTransaction.Type.SELL; + }); + } + + static boolean supportsDeliveryToBuySellAction(Collection> txCollection) + { + return !txCollection.isEmpty() && txCollection.stream().noneMatch(TransactionContextMenu::isLedgerNativeTargetedProjection) + && txCollection.stream().allMatch(tx -> { + var type = tx.getTransaction().getType(); + return type == PortfolioTransaction.Type.DELIVERY_INBOUND + || type == PortfolioTransaction.Type.DELIVERY_OUTBOUND; + }); + } + private void fillContextMenuAccountTx(IMenuManager manager, boolean fullContextMenu, TransactionPair tx) { + LedgerNativeComponentInspectorAction.create(owner, tx.getTransaction()).ifPresent(manager::add); + + if (isLedgerNativeTargetedProjection(tx)) + return; + Action action = createEditAccountTransactionAction(tx); action.setAccelerator(SWT.MOD1 | 'E'); manager.add(action); @@ -193,6 +259,11 @@ private void fillContextMenuPortfolioTx(IMenuManager manager, boolean fullContex { PortfolioTransaction ptx = tx.getTransaction(); + LedgerNativeComponentInspectorAction.create(owner, tx.getTransaction()).ifPresent(manager::add); + + if (isLedgerNativeTargetedProjection(tx)) + return; + Action editAction = createEditPortfolioTransactionAction(tx); editAction.setAccelerator(SWT.MOD1 | 'E'); manager.add(editAction); @@ -299,4 +370,18 @@ else if (tx.getTransaction().getCrossEntry() instanceof PortfolioTransferEntry e .parameters(tx.getTransaction().getType()); } } + + static boolean containsLedgerNativeTargetedProjection(IStructuredSelection selection) + { + return selection.stream() // + .filter(TransactionPair.class::isInstance) // + .map(TransactionPair.class::cast) // + .anyMatch(TransactionContextMenu::isLedgerNativeTargetedProjection); + } + + static boolean isLedgerNativeTargetedProjection(TransactionPair tx) + { + return LedgerNativeComponentInspectorModel.isLedgerNativeTargetedProjection(tx.getTransaction()); + } + } diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/TransactionsViewer.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/TransactionsViewer.java index 71a0ca7292..593b19e03b 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/TransactionsViewer.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/TransactionsViewer.java @@ -29,11 +29,23 @@ import org.eclipse.swt.widgets.Control; import org.eclipse.swt.widgets.Menu; +import name.abuchen.portfolio.model.Account; import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.BuySellEntry; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.Portfolio; import name.abuchen.portfolio.model.PortfolioTransaction; +import name.abuchen.portfolio.model.PortfolioTransferEntry; import name.abuchen.portfolio.model.Security; import name.abuchen.portfolio.model.Transaction; import name.abuchen.portfolio.model.TransactionPair; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerBuySellTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerDeliveryTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerDividendTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerInlineEditingField; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerInlineEditingPolicy; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferTransactionCreator; import name.abuchen.portfolio.money.Money; import name.abuchen.portfolio.money.Values; import name.abuchen.portfolio.ui.Images; @@ -44,6 +56,7 @@ import name.abuchen.portfolio.ui.util.Colors; import name.abuchen.portfolio.ui.util.ContextMenu; import name.abuchen.portfolio.ui.util.LogoManager; +import name.abuchen.portfolio.ui.util.StringToCurrencyConverter; import name.abuchen.portfolio.ui.util.ValueColorScheme; import name.abuchen.portfolio.ui.util.viewers.Column; import name.abuchen.portfolio.ui.util.viewers.ColumnEditingSupport; @@ -66,6 +79,40 @@ public final class TransactionsViewer implements ModificationListener { + private final class LedgerAwareSharesEditingSupport extends ValueEditingSupport + { + private final StringToCurrencyConverter stringToShares = new StringToCurrencyConverter(Values.Share); + + LedgerAwareSharesEditingSupport() + { + super(Transaction.class, "shares", Values.Share); //$NON-NLS-1$ + } + + @Override + public void setValue(Object element, Object value) throws Exception + { + var pair = (TransactionPair) element; + if (!LedgerInlineEditingPolicy.isEditable(pair, LedgerInlineEditingField.SHARES)) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_UI_017 + .message(Messages.LedgerTransactionsViewerUnsupportedSharesInlineEdit)); + + var transaction = pair.getTransaction(); + var oldValue = Long.valueOf(transaction.getShares()); + var newValue = Long.valueOf(stringToShares.convert(String.valueOf(value)).longValue()); + + if (newValue.equals(oldValue)) + return; + + if (updateLedgerBackedShares(owner.getClient(), pair, newValue.longValue())) + { + notify(element, newValue, oldValue); + return; + } + + super.setValue(element, value); + } + } + private class TransactionLabelProvider extends ColumnLabelProvider { private Function label; @@ -344,12 +391,7 @@ public Color getBackground(Object element) } }); ColumnViewerSorter.create(e -> ((TransactionPair) e).getTransaction().getShares()).attachTo(column); - new ValueEditingSupport(Transaction.class, "shares", Values.Share) //$NON-NLS-1$ - .setCanEditCheck(e -> ((TransactionPair) e).getTransaction() instanceof PortfolioTransaction - || (((TransactionPair) e).getTransaction() instanceof AccountTransaction - && ((AccountTransaction) ((TransactionPair) e) - .getTransaction()) - .getType() == AccountTransaction.Type.DIVIDENDS)) + new LedgerAwareSharesEditingSupport().setCanEditCheck(TransactionsViewer::canEditShares) .addListener(this).attachTo(column); support.addColumn(column); @@ -475,7 +517,7 @@ public String getToolTipText(Object e) })); ColumnViewerSorter.create(e -> exDateProvider.apply(((TransactionPair) e).getTransaction())) .attachTo(column); - new ExDateEditingSupport().addListener(this).attachTo(column); + new ExDateEditingSupport(owner.getClient()).addListener(this).attachTo(column); column.setVisible(false); support.addColumn(column); @@ -483,6 +525,7 @@ public String getToolTipText(Object e) column.setLabelProvider(new TransactionLabelProvider(Transaction::getSource)); ColumnViewerSorter.createIgnoreCase(e -> ((TransactionPair) e).getTransaction().getSource()) .attachTo(column); + new StringEditingSupport(Transaction.class, "source").addListener(this).attachTo(column); //$NON-NLS-1$ support.addColumn(column); } @@ -491,6 +534,87 @@ public ShowHideColumnHelper getColumnSupport() return support; } + static boolean canEditShares(Object element) + { + if (!LedgerInlineEditingPolicy.isEditable(element, LedgerInlineEditingField.SHARES)) + return false; + + Transaction transaction = ((TransactionPair) element).getTransaction(); + + return transaction instanceof PortfolioTransaction + || transaction instanceof AccountTransaction accountTransaction + && accountTransaction.getType() == AccountTransaction.Type.DIVIDENDS; + } + + static boolean updateLedgerBackedShares(Client client, TransactionPair pair, long shares) + { + if (!LedgerInlineEditingPolicy.isEditable(pair, LedgerInlineEditingField.SHARES)) + return false; + + var transaction = pair.getTransaction(); + + if (transaction.getCrossEntry() instanceof BuySellEntry buySellEntry) + { + var creator = new LedgerBuySellTransactionCreator(client); + if (!creator.canUpdate(buySellEntry)) + return false; + + var portfolioTransaction = buySellEntry.getPortfolioTransaction(); + creator.update(buySellEntry, buySellEntry.getPortfolio(), buySellEntry.getAccount(), + portfolioTransaction.getType(), portfolioTransaction.getDateTime(), + portfolioTransaction.getAmount(), portfolioTransaction.getCurrencyCode(), + portfolioTransaction.getSecurity(), shares, portfolioTransaction.getUnits().toList(), + portfolioTransaction.getNote(), portfolioTransaction.getSource()); + return true; + } + + if (transaction.getCrossEntry() instanceof PortfolioTransferEntry transferEntry) + { + var creator = new LedgerPortfolioTransferTransactionCreator(client); + if (!creator.canUpdate(transferEntry)) + return false; + + var sourceTransaction = transferEntry.getSourceTransaction(); + creator.update(transferEntry, transferEntry.getSourcePortfolio(), transferEntry.getTargetPortfolio(), + sourceTransaction.getSecurity(), sourceTransaction.getDateTime(), shares, + sourceTransaction.getAmount(), sourceTransaction.getCurrencyCode(), + sourceTransaction.getNote(), sourceTransaction.getSource()); + return true; + } + + if (transaction instanceof PortfolioTransaction portfolioTransaction) + { + var creator = new LedgerDeliveryTransactionCreator(client); + if (!creator.canUpdate(portfolioTransaction)) + return false; + + creator.update(portfolioTransaction, (Portfolio) pair.getOwner(), + portfolioTransaction.getType(), portfolioTransaction.getDateTime(), + portfolioTransaction.getAmount(), portfolioTransaction.getCurrencyCode(), + portfolioTransaction.getSecurity(), shares, null, null, + portfolioTransaction.getUnits().toList(), portfolioTransaction.getNote(), + portfolioTransaction.getSource()); + return true; + } + + if (transaction instanceof AccountTransaction accountTransaction) + { + var creator = new LedgerDividendTransactionCreator(client); + if (!creator.canUpdate(accountTransaction)) + return false; + + creator.update(accountTransaction, (Account) pair.getOwner(), + accountTransaction.getType(), accountTransaction.getDateTime(), + accountTransaction.getAmount(), accountTransaction.getCurrencyCode(), + accountTransaction.getSecurity(), shares, accountTransaction.getExDate(), null, null, + accountTransaction.getUnits().toList(), accountTransaction.getNote(), + accountTransaction.getSource()); + return true; + } + + return false; + } + private void hookContextMenu(Composite parent) { MenuManager menuMgr = new MenuManager("#PopupMenu"); //$NON-NLS-1$ diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/ConvertBuySellToDeliveryAction.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/ConvertBuySellToDeliveryAction.java index fb82e49005..4fde55d49c 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/ConvertBuySellToDeliveryAction.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/ConvertBuySellToDeliveryAction.java @@ -6,6 +6,7 @@ import org.eclipse.jface.action.Action; import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.LedgerBuySellDeliveryConverter; import name.abuchen.portfolio.model.PortfolioTransaction; import name.abuchen.portfolio.model.PortfolioTransaction.Type; import name.abuchen.portfolio.model.TransactionPair; @@ -53,8 +54,16 @@ else if (allSell) @Override public void run() { + var converter = new LedgerBuySellDeliveryConverter(client); + for (TransactionPair transaction : transactionList) { + if (converter.canConvert(transaction)) + { + converter.convertBuySellToDelivery(transaction); + continue; + } + // delete existing transaction PortfolioTransaction buySellTransaction = transaction.getTransaction(); transaction.getOwner().deleteTransaction(buySellTransaction, client); diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/ConvertDeliveryToBuySellAction.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/ConvertDeliveryToBuySellAction.java index cb0718814e..17f71a17da 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/ConvertDeliveryToBuySellAction.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/ConvertDeliveryToBuySellAction.java @@ -9,6 +9,7 @@ import name.abuchen.portfolio.model.Account; import name.abuchen.portfolio.model.BuySellEntry; import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.LedgerBuySellDeliveryConverter; import name.abuchen.portfolio.model.Portfolio; import name.abuchen.portfolio.model.PortfolioTransaction; import name.abuchen.portfolio.model.PortfolioTransaction.Type; @@ -56,8 +57,16 @@ else if (allOutbound) @Override public void run() { + var converter = new LedgerBuySellDeliveryConverter(client); + for (TransactionPair transaction : transactionList) { + if (converter.canConvertDeliveryToBuySell(transaction)) + { + converter.convertDeliveryToBuySell(transaction); + continue; + } + Portfolio portfolio = (Portfolio) transaction.getOwner(); Account account = portfolio.getReferenceAccount(); PortfolioTransaction deliveryTransaction = transaction.getTransaction(); diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/ConvertPortfolioCompositeTypeAction.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/ConvertPortfolioCompositeTypeAction.java new file mode 100644 index 0000000000..a8fef25e57 --- /dev/null +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/ConvertPortfolioCompositeTypeAction.java @@ -0,0 +1,58 @@ +package name.abuchen.portfolio.ui.views.actions; + +import org.eclipse.jface.action.Action; + +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.LedgerPortfolioCompositeTypeConverter; +import name.abuchen.portfolio.model.PortfolioTransaction; +import name.abuchen.portfolio.model.TransactionPair; +import name.abuchen.portfolio.ui.Messages; + +public class ConvertPortfolioCompositeTypeAction extends Action +{ + private final Client client; + private final TransactionPair transaction; + + public ConvertPortfolioCompositeTypeAction(Client client, TransactionPair transaction) + { + this.client = client; + this.transaction = transaction; + + var type = transaction.getTransaction().getType(); + + if (type != PortfolioTransaction.Type.BUY && type != PortfolioTransaction.Type.SELL + && type != PortfolioTransaction.Type.DELIVERY_INBOUND + && type != PortfolioTransaction.Type.DELIVERY_OUTBOUND) + throw new IllegalArgumentException("unsupported transaction type " + type); //$NON-NLS-1$ + } + + @Override + public void run() + { + var converter = new LedgerPortfolioCompositeTypeConverter(client); + + if (converter.canConvertSafely(transaction)) + { + converter.convert(transaction); + client.markDirty(); + return; + } + + if (converter.isLedgerBacked(transaction)) + throw new UnsupportedOperationException( + Messages.LedgerConvertPortfolioCompositeTypeActionUnsupportedLedgerBackedTransition); + + var type = transaction.getTransaction().getType(); + + if (type == PortfolioTransaction.Type.BUY || type == PortfolioTransaction.Type.SELL) + { + new RevertBuySellAction(client, transaction).run(); + new ConvertBuySellToDeliveryAction(client, transaction).run(); + } + else + { + new RevertDeliveryAction(client, transaction).run(); + new ConvertDeliveryToBuySellAction(client, transaction).run(); + } + } +} diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/ConvertTransferToDepositRemovalAction.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/ConvertTransferToDepositRemovalAction.java index fc1dbdff42..3494128153 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/ConvertTransferToDepositRemovalAction.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/ConvertTransferToDepositRemovalAction.java @@ -1,7 +1,10 @@ package name.abuchen.portfolio.ui.views.actions; +import java.text.MessageFormat; import java.util.Arrays; import java.util.Collection; +import java.util.HashSet; +import java.util.IdentityHashMap; import org.eclipse.jface.action.Action; @@ -9,6 +12,9 @@ import name.abuchen.portfolio.model.AccountTransaction; import name.abuchen.portfolio.model.AccountTransferEntry; import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.LedgerAccountTransferToDepositRemovalConverter; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountTransferTransactionCreator; import name.abuchen.portfolio.ui.Messages; public class ConvertTransferToDepositRemovalAction extends Action @@ -27,10 +33,40 @@ public ConvertTransferToDepositRemovalAction(Client client, Collection()); + + for (AccountTransaction transaction : transactionList) + { + if (!(transaction.getCrossEntry() instanceof AccountTransferEntry entry)) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_UI_018 + .message(MessageFormat.format( + Messages.LedgerConvertTransferToDepositRemovalActionUnsupportedTransferEntry, + transaction))); + + if (ledgerTransferCreator.isLedgerBacked(entry)) + { + if (!ledgerSplitConverter.canSplit(entry)) + throw new UnsupportedOperationException( + LedgerDiagnosticCode.LEDGER_UI_019 + .message(Messages.LedgerConvertTransferToDepositRemovalActionCannotConvertLedgerBackedTransfer)); + + ledgerEntries.add(entry); + } + } + + var convertedLedgerEntries = new HashSet<>(ledgerEntries); + + ledgerEntries.forEach(ledgerSplitConverter::split); + for (AccountTransaction transaction : transactionList) { AccountTransferEntry entry = (AccountTransferEntry) transaction.getCrossEntry(); + if (ledgerTransferCreator.isLedgerBacked(entry) || convertedLedgerEntries.contains(entry)) + continue; + Account accountFrom = entry.getSourceAccount(); Account accountTo = entry.getTargetAccount(); diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/LedgerNativeComponentInspectorAction.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/LedgerNativeComponentInspectorAction.java new file mode 100644 index 0000000000..990529fcb7 --- /dev/null +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/LedgerNativeComponentInspectorAction.java @@ -0,0 +1,39 @@ +package name.abuchen.portfolio.ui.views.actions; + +import java.util.Optional; + +import org.eclipse.jface.action.Action; + +import name.abuchen.portfolio.model.ledger.compatibility.LedgerNativeComponentInspectorModel; +import name.abuchen.portfolio.ui.Messages; +import name.abuchen.portfolio.ui.dialogs.transactions.LedgerNativeComponentInspectorDialog; +import name.abuchen.portfolio.ui.editor.AbstractFinanceView; + +/** + * Opens the read-only Ledger entry inspector for a selected runtime projection. + * The action is only created when the selected object resolves to a ledger-backed entry. + */ +public final class LedgerNativeComponentInspectorAction extends Action +{ + private final AbstractFinanceView owner; + private final LedgerNativeComponentInspectorModel model; + + private LedgerNativeComponentInspectorAction(AbstractFinanceView owner, LedgerNativeComponentInspectorModel model) + { + super(Messages.LedgerNativeComponentInspectorMenu); + this.owner = owner; + this.model = model; + } + + public static Optional create(AbstractFinanceView owner, Object transaction) + { + return LedgerNativeComponentInspectorModel.from(transaction) + .map(model -> new LedgerNativeComponentInspectorAction(owner, model)); + } + + @Override + public void run() + { + new LedgerNativeComponentInspectorDialog(owner.getActiveShell(), model).open(); + } +} diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/RevertBuySellAction.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/RevertBuySellAction.java index 9301462b03..ad566bf77b 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/RevertBuySellAction.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/RevertBuySellAction.java @@ -1,10 +1,12 @@ package name.abuchen.portfolio.ui.views.actions; + import org.eclipse.jface.action.Action; import name.abuchen.portfolio.model.AccountTransaction; import name.abuchen.portfolio.model.BuySellEntry; import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.LedgerBuySellReversalConverter; import name.abuchen.portfolio.model.PortfolioTransaction; import name.abuchen.portfolio.model.PortfolioTransaction.Type; import name.abuchen.portfolio.model.Transaction; @@ -24,6 +26,7 @@ public RevertBuySellAction(Client client, TransactionPair transaction) this.transaction = transaction; Transaction tx = transaction.getTransaction(); + if (tx instanceof PortfolioTransaction) { PortfolioTransaction.Type type = ((PortfolioTransaction) tx).getType(); @@ -48,6 +51,14 @@ else if (tx instanceof AccountTransaction) public void run() { BuySellEntry buysell = (BuySellEntry) transaction.getTransaction().getCrossEntry(); + var converter = new LedgerBuySellReversalConverter(client); + + if (converter.canReverse(buysell)) + { + converter.reverse(buysell); + client.markDirty(); + return; + } PortfolioTransaction tx = buysell.getPortfolioTransaction(); diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/RevertDeliveryAction.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/RevertDeliveryAction.java index 096ce257af..8812674fda 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/RevertDeliveryAction.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/RevertDeliveryAction.java @@ -1,8 +1,10 @@ package name.abuchen.portfolio.ui.views.actions; + import org.eclipse.jface.action.Action; import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.LedgerDeliveryDirectionConverter; import name.abuchen.portfolio.model.PortfolioTransaction; import name.abuchen.portfolio.model.PortfolioTransaction.Type; import name.abuchen.portfolio.model.Transaction.Unit; @@ -33,6 +35,14 @@ public RevertDeliveryAction(Client client, TransactionPair public void run() { PortfolioTransaction tx = transaction.getTransaction(); + var converter = new LedgerDeliveryDirectionConverter(client); + + if (converter.canReverse(transaction)) + { + converter.reverse(transaction); + client.markDirty(); + return; + } // when converting between inbound and outbound deliveries, we keep the // price of the security the same, but add or subtract fees and taxes diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/RevertDepositRemovalAction.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/RevertDepositRemovalAction.java index 8b92758f50..9ad5b0437c 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/RevertDepositRemovalAction.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/RevertDepositRemovalAction.java @@ -1,9 +1,11 @@ package name.abuchen.portfolio.ui.views.actions; + import org.eclipse.jface.action.Action; import name.abuchen.portfolio.model.AccountTransaction; import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.LedgerAccountTypeToggleConverter; import name.abuchen.portfolio.model.TransactionPair; public class RevertDepositRemovalAction extends Action @@ -25,6 +27,14 @@ public RevertDepositRemovalAction(Client client, TransactionPair transaction; + + public RevertFeeTaxAction(Client client, TransactionPair transaction) + { + this.client = client; + this.transaction = transaction; + + AccountTransaction tx = transaction.getTransaction(); + Type type = tx.getType(); + if (type != Type.FEES && type != Type.FEES_REFUND && type != Type.TAXES && type != Type.TAX_REFUND) + throw new IllegalArgumentException("unsupported transaction type " + type + " for transaction " + tx); //$NON-NLS-1$ //$NON-NLS-2$ + } + + @Override + public void run() + { + AccountTransaction tx = transaction.getTransaction(); + var converter = new LedgerAccountTypeToggleConverter(client); + + if (converter.canToggle(transaction)) + { + converter.toggle(transaction); + client.markDirty(); + return; + } + + Type type = tx.getType(); + if (type == Type.FEES) + tx.setType(Type.FEES_REFUND); + else if (type == Type.FEES_REFUND) + tx.setType(Type.FEES); + else if (type == Type.TAXES) + tx.setType(Type.TAX_REFUND); + else if (type == Type.TAX_REFUND) + tx.setType(Type.TAXES); + else + throw new IllegalArgumentException( + "unsupported transaction type " + type + " for transaction " + tx); //$NON-NLS-1$ //$NON-NLS-2$ + + client.markDirty(); + } +} diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/RevertInterestAction.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/RevertInterestAction.java index e3b9273167..a7e2add851 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/RevertInterestAction.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/RevertInterestAction.java @@ -1,10 +1,12 @@ package name.abuchen.portfolio.ui.views.actions; + import org.eclipse.jface.action.Action; import name.abuchen.portfolio.model.AccountTransaction; import name.abuchen.portfolio.model.AccountTransaction.Type; import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.LedgerAccountTypeToggleConverter; import name.abuchen.portfolio.model.TransactionPair; public class RevertInterestAction extends Action @@ -28,6 +30,14 @@ public RevertInterestAction(Client client, TransactionPair t public void run() { AccountTransaction tx = transaction.getTransaction(); + var converter = new LedgerAccountTypeToggleConverter(client); + + if (converter.canToggle(transaction)) + { + converter.toggle(transaction); + client.markDirty(); + return; + } Type type = tx.getType(); if (AccountTransaction.Type.INTEREST.equals(type)) diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/RevertTransferAction.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/RevertTransferAction.java index 155aa575b3..03af44b408 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/RevertTransferAction.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/RevertTransferAction.java @@ -1,5 +1,6 @@ package name.abuchen.portfolio.ui.views.actions; + import org.eclipse.jface.action.Action; import name.abuchen.portfolio.model.Account; @@ -7,6 +8,7 @@ import name.abuchen.portfolio.model.AccountTransferEntry; import name.abuchen.portfolio.model.Client; import name.abuchen.portfolio.model.CrossEntry; +import name.abuchen.portfolio.model.LedgerTransferDirectionConverter; import name.abuchen.portfolio.model.Portfolio; import name.abuchen.portfolio.model.PortfolioTransaction; import name.abuchen.portfolio.model.PortfolioTransferEntry; @@ -49,10 +51,19 @@ public void run() { Transaction tx = transaction.getTransaction(); CrossEntry e = tx.getCrossEntry(); + var converter = new LedgerTransferDirectionConverter(client); + if (e instanceof PortfolioTransferEntry) { PortfolioTransferEntry entry = (PortfolioTransferEntry) transaction.getTransaction().getCrossEntry(); + if (converter.canReverse(entry)) + { + converter.reverse(entry); + client.markDirty(); + return; + } + Portfolio oldSource = entry.getSourcePortfolio(); entry.setSourcePortfolio((Portfolio) entry.getTargetPortfolio()); entry.setTargetPortfolio(oldSource); @@ -67,6 +78,13 @@ else if (e instanceof AccountTransferEntry) { AccountTransferEntry entry = (AccountTransferEntry) transaction.getTransaction().getCrossEntry(); + if (converter.canReverse(entry)) + { + converter.reverse(entry); + client.markDirty(); + return; + } + Account oldSource = entry.getSourceAccount(); entry.setSourceAccount(entry.getTargetAccount()); entry.setTargetAccount(oldSource); diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/panes/AccountTransactionsPane.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/panes/AccountTransactionsPane.java index b1b80293bb..43663c9708 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/panes/AccountTransactionsPane.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/panes/AccountTransactionsPane.java @@ -41,10 +41,15 @@ import name.abuchen.portfolio.model.BuySellEntry; import name.abuchen.portfolio.model.Client; import name.abuchen.portfolio.model.CrossEntry; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; import name.abuchen.portfolio.model.PortfolioTransaction; import name.abuchen.portfolio.model.Transaction; import name.abuchen.portfolio.model.Transaction.Unit; import name.abuchen.portfolio.model.TransactionPair; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerDividendTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerInlineEditingField; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerInlineEditingPolicy; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerNativeComponentInspectorModel; import name.abuchen.portfolio.money.Money; import name.abuchen.portfolio.money.MutableMoney; import name.abuchen.portfolio.money.Quote; @@ -60,6 +65,7 @@ import name.abuchen.portfolio.ui.util.DropDown; import name.abuchen.portfolio.ui.util.LogoManager; import name.abuchen.portfolio.ui.util.SimpleAction; +import name.abuchen.portfolio.ui.util.StringToCurrencyConverter; import name.abuchen.portfolio.ui.util.TableViewerCSVExporter; import name.abuchen.portfolio.ui.util.ValueColorScheme; import name.abuchen.portfolio.ui.util.searchfilter.TransactionFilterDropDown; @@ -75,6 +81,7 @@ import name.abuchen.portfolio.ui.util.viewers.MoneyColorLabelProvider; import name.abuchen.portfolio.ui.util.viewers.SharesLabelProvider; import name.abuchen.portfolio.ui.util.viewers.ShowHideColumnHelper; +import name.abuchen.portfolio.ui.util.viewers.StringEditingSupport; import name.abuchen.portfolio.ui.util.viewers.TransactionOwnerListEditingSupport; import name.abuchen.portfolio.ui.util.viewers.TransactionTypeEditingSupport; import name.abuchen.portfolio.ui.util.viewers.ValueEditingSupport; @@ -82,6 +89,7 @@ import name.abuchen.portfolio.ui.views.AccountListView; import name.abuchen.portfolio.ui.views.actions.ConvertTransferToDepositRemovalAction; import name.abuchen.portfolio.ui.views.actions.CreateRemovalForDividendAction; +import name.abuchen.portfolio.ui.views.actions.LedgerNativeComponentInspectorAction; import name.abuchen.portfolio.ui.views.columns.CalculatedQuoteColumn; import name.abuchen.portfolio.ui.views.columns.IsinColumn; import name.abuchen.portfolio.ui.views.columns.NoteColumn; @@ -90,6 +98,52 @@ public class AccountTransactionsPane implements InformationPanePage, ModificationListener { + private final class LedgerAwareDividendSharesEditingSupport extends ValueEditingSupport + { + private final StringToCurrencyConverter stringToShares = new StringToCurrencyConverter(Values.Share); + + LedgerAwareDividendSharesEditingSupport() + { + super(AccountTransaction.class, "shares", Values.Share); //$NON-NLS-1$ + } + + @Override + public boolean canEdit(Object element) + { + return !isLedgerNativeTargetedProjection((AccountTransaction) element) + && LedgerInlineEditingPolicy.isEditable(element, LedgerInlineEditingField.SHARES) + && ((AccountTransaction) element).getType() == AccountTransaction.Type.DIVIDENDS; + } + + @Override + public void setValue(Object element, Object value) throws Exception + { + if (!LedgerInlineEditingPolicy.isEditable(element, LedgerInlineEditingField.SHARES)) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_UI_020 + .message(Messages.LedgerAccountTransactionsPaneUnsupportedSharesInlineEdit)); + + var transaction = (AccountTransaction) element; + var oldValue = Long.valueOf(transaction.getShares()); + var newValue = Long.valueOf(stringToShares.convert(String.valueOf(value)).longValue()); + + if (newValue.equals(oldValue)) + return; + + var creator = new LedgerDividendTransactionCreator(client); + if (creator.canUpdate(transaction)) + { + creator.update(transaction, account, transaction.getType(), transaction.getDateTime(), + transaction.getAmount(), transaction.getCurrencyCode(), transaction.getSecurity(), + newValue.longValue(), transaction.getExDate(), null, null, + transaction.getUnits().toList(), transaction.getNote(), transaction.getSource()); + notify(element, newValue, oldValue); + return; + } + + super.setValue(element, value); + } + } + @Inject private Client client; @@ -375,15 +429,7 @@ public Color getForeground(Object element) return colorFor((AccountTransaction) element); } }); - new ValueEditingSupport(AccountTransaction.class, "shares", Values.Share) //$NON-NLS-1$ - { - @Override - public boolean canEdit(Object element) - { - AccountTransaction t = (AccountTransaction) element; - return t.getType() == AccountTransaction.Type.DIVIDENDS; - } - }.addListener(this).attachTo(column); + new LedgerAwareDividendSharesEditingSupport().addListener(this).attachTo(column); transactionsColumns.addColumn(column); column = new CalculatedQuoteColumn("6", client, e -> { //$NON-NLS-1$ @@ -455,7 +501,7 @@ public Color getForeground(Object element) } }); ColumnViewerSorter.create(e -> ((AccountTransaction) e).getExDate()).attachTo(column); - new ExDateEditingSupport().addListener(this).attachTo(column); + new ExDateEditingSupport(client).addListener(this).attachTo(column); column.setVisible(false); transactionsColumns.addColumn(column); @@ -475,6 +521,7 @@ public Color getForeground(Object element) } }); ColumnViewerSorter.createIgnoreCase(e -> ((AccountTransaction) e).getSource()).attachTo(column); + new StringEditingSupport(AccountTransaction.class, "source").addListener(this).attachTo(column); //$NON-NLS-1$ transactionsColumns.addColumn(column); transactionsColumns.createColumns(true); @@ -546,7 +593,7 @@ public void keyPressed(KeyEvent e) AccountTransaction transaction = (AccountTransaction) ((IStructuredSelection) transactions .getSelection()).getFirstElement(); - if (account != null && transaction != null) + if (account != null && transaction != null && !isLedgerNativeTargetedProjection(transaction)) createEditAction(account, transaction).run(); } if (e.keyCode == 'd' && e.stateMask == SWT.MOD1) @@ -554,7 +601,7 @@ public void keyPressed(KeyEvent e) AccountTransaction transaction = (AccountTransaction) ((IStructuredSelection) transactions .getSelection()).getFirstElement(); - if (account != null && transaction != null) + if (account != null && transaction != null && !isLedgerNativeTargetedProjection(transaction)) createCopyAction(account, transaction).run(); } } @@ -571,6 +618,11 @@ private void fillTransactionsContextMenu(IMenuManager manager) // NOSONAR if (transaction != null) { + LedgerNativeComponentInspectorAction.create(view, transaction).ifPresent(manager::add); + + if (isLedgerNativeTargetedProjection(transaction)) + return; + Action action = createEditAction(account, transaction); action.setAccelerator(SWT.MOD1 | 'E'); manager.add(action); @@ -590,7 +642,7 @@ private void fillTransactionsContextMenu(IMenuManager manager) // NOSONAR fillCreateRemovalForDividendAction(manager, selection); } - if (transaction != null) + if (transaction != null && !containsLedgerNativeTargetedProjection(selection)) { manager.add(new Separator()); @@ -617,6 +669,9 @@ public void run() private void fillConvertTransferToDepositRemovalAction(IMenuManager manager, IStructuredSelection selection) { + if (containsLedgerNativeTargetedProjection(selection)) + return; + // create collection with all selected transactions Collection accountTxCollection = new ArrayList<>(selection.size()); Iterator it = selection.iterator(); @@ -644,6 +699,9 @@ private void fillConvertTransferToDepositRemovalAction(IMenuManager manager, ISt private void fillCreateRemovalForDividendAction(IMenuManager manager, IStructuredSelection selection) { + if (containsLedgerNativeTargetedProjection(selection)) + return; + // create collection with all selected transactions var dividendTransactionPairs = selection.stream() .filter(t -> ((AccountTransaction) t).getType() == AccountTransaction.Type.DIVIDENDS) @@ -657,6 +715,19 @@ private void fillCreateRemovalForDividendAction(IMenuManager manager, IStructure } } + private static boolean containsLedgerNativeTargetedProjection(IStructuredSelection selection) + { + return selection.stream() // + .filter(AccountTransaction.class::isInstance) // + .map(AccountTransaction.class::cast) // + .anyMatch(AccountTransactionsPane::isLedgerNativeTargetedProjection); + } + + private static boolean isLedgerNativeTargetedProjection(AccountTransaction transaction) + { + return LedgerNativeComponentInspectorModel.isLedgerNativeTargetedProjection(transaction); + } + private Action createEditAction(Account account, AccountTransaction transaction) { // buy / sell diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerInlineEditingField.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerInlineEditingField.java new file mode 100644 index 0000000000..1cdacac472 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerInlineEditingField.java @@ -0,0 +1,15 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +/** + * Identifies transaction-table inline-editing fields controlled by Ledger policy. + */ +public enum LedgerInlineEditingField +{ + DATE, + NOTE, + TYPE, + EX_DATE, + SHARES, + SOURCE, + TRANSACTION_SOURCE +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerInlineEditingPolicy.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerInlineEditingPolicy.java new file mode 100644 index 0000000000..01ff6b4346 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerInlineEditingPolicy.java @@ -0,0 +1,111 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.util.EnumMap; +import java.util.EnumSet; +import java.util.Map; +import java.util.Set; + +import name.abuchen.portfolio.model.Transaction; +import name.abuchen.portfolio.model.TransactionPair; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; + +/** + * Applies the Ledger-V6 transaction-table inline-editing matrix. + * SOURCE and TRANSACTION_SOURCE intentionally map to the same transaction source property. + */ +public final class LedgerInlineEditingPolicy +{ + private static final Map>> MATRIX = matrix(); + + private LedgerInlineEditingPolicy() + { + } + + public static boolean isEditable(Object element, LedgerInlineEditingField field) + { + var transaction = transaction(element); + + if (!(transaction instanceof LedgerBackedTransaction ledgerBackedTransaction)) + return true; + + return isEditable(ledgerBackedTransaction.getLedgerEntry().getType(), + ledgerBackedTransaction.getLedgerProjectionRef().getRole(), field); + } + + public static boolean isEditable(LedgerEntryType type, LedgerProjectionRole role, LedgerInlineEditingField field) + { + if (type == null || role == null || field == null) + return false; + + var byType = MATRIX.get(role); + if (byType == null) + return false; + + return byType.getOrDefault(type, Set.of()).contains(field); + } + + private static Transaction transaction(Object element) + { + if (element instanceof TransactionPair pair) + return pair.getTransaction(); + + if (element instanceof Transaction transaction) + return transaction; + + return null; + } + + private static Map>> matrix() + { + var matrix = new EnumMap>>( + LedgerProjectionRole.class); + + allow(matrix, LedgerProjectionRole.ACCOUNT, LedgerEntryType.DEPOSIT, metadataAndType()); + allow(matrix, LedgerProjectionRole.ACCOUNT, LedgerEntryType.REMOVAL, metadataAndType()); + allow(matrix, LedgerProjectionRole.ACCOUNT, LedgerEntryType.INTEREST, metadataAndType()); + allow(matrix, LedgerProjectionRole.ACCOUNT, LedgerEntryType.INTEREST_CHARGE, metadataAndType()); + allow(matrix, LedgerProjectionRole.ACCOUNT, LedgerEntryType.FEES, metadata()); + allow(matrix, LedgerProjectionRole.ACCOUNT, LedgerEntryType.FEES_REFUND, metadata()); + allow(matrix, LedgerProjectionRole.ACCOUNT, LedgerEntryType.TAXES, metadata()); + allow(matrix, LedgerProjectionRole.ACCOUNT, LedgerEntryType.TAX_REFUND, metadata()); + allow(matrix, LedgerProjectionRole.ACCOUNT, LedgerEntryType.DIVIDENDS, + EnumSet.of(LedgerInlineEditingField.DATE, LedgerInlineEditingField.NOTE, + LedgerInlineEditingField.EX_DATE, LedgerInlineEditingField.SHARES)); + allow(matrix, LedgerProjectionRole.ACCOUNT, LedgerEntryType.BUY, metadataAndType()); + allow(matrix, LedgerProjectionRole.ACCOUNT, LedgerEntryType.SELL, metadataAndType()); + + allow(matrix, LedgerProjectionRole.PORTFOLIO, LedgerEntryType.BUY, metadataAndType()); + allow(matrix, LedgerProjectionRole.PORTFOLIO, LedgerEntryType.SELL, metadataAndType()); + + allow(matrix, LedgerProjectionRole.SOURCE_ACCOUNT, LedgerEntryType.CASH_TRANSFER, metadataAndType()); + allow(matrix, LedgerProjectionRole.TARGET_ACCOUNT, LedgerEntryType.CASH_TRANSFER, metadataAndType()); + + allow(matrix, LedgerProjectionRole.SOURCE_PORTFOLIO, LedgerEntryType.SECURITY_TRANSFER, metadata()); + allow(matrix, LedgerProjectionRole.TARGET_PORTFOLIO, LedgerEntryType.SECURITY_TRANSFER, metadata()); + + allow(matrix, LedgerProjectionRole.DELIVERY_INBOUND, LedgerEntryType.DELIVERY_INBOUND, metadataAndType()); + allow(matrix, LedgerProjectionRole.DELIVERY_OUTBOUND, LedgerEntryType.DELIVERY_OUTBOUND, metadataAndType()); + + return matrix; + } + + private static EnumSet metadata() + { + return EnumSet.of(LedgerInlineEditingField.DATE, LedgerInlineEditingField.NOTE); + } + + private static EnumSet metadataAndType() + { + var fields = metadata(); + fields.add(LedgerInlineEditingField.TYPE); + return fields; + } + + private static void allow(Map>> matrix, + LedgerProjectionRole role, LedgerEntryType type, Set fields) + { + matrix.computeIfAbsent(role, ignored -> new EnumMap<>(LedgerEntryType.class)).put(type, Set.copyOf(fields)); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerNativeComponentInspectorModel.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerNativeComponentInspectorModel.java new file mode 100644 index 0000000000..e03d5d3644 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerNativeComponentInspectorModel.java @@ -0,0 +1,311 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Function; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerParameter; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinition; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinitionRegistry; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerLegDefinition; +import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.Values; + +/** + * Collects read-only display rows for inspecting a Ledger entry. + * This class is part of the compatibility layer used by UI code that must show + * ledger-backed projections without opening internal Ledger model packages. + * It reads Ledger facts and optional Java-only native leg configuration metadata, but it never mutates them. + */ +public final class LedgerNativeComponentInspectorModel +{ + public enum HeaderField + { + ENTRY_TYPE, + ENTRY_UUID, + DATE_TIME, + NOTE, + SOURCE, + SHAPE, + NATIVE_TARGETED, + SELECTED_PROJECTION_ROLE, + SELECTED_PROJECTION_UUID, + SELECTED_PRIMARY_POSTING_UUID, + SELECTED_POSTING_GROUP_UUID + } + + public record HeaderRow(HeaderField field, String value) + { + } + + public record ParameterRow(String parameter, String code, String valueKind, String value, String domain) + { + } + + public record LegRow(String legRole, String postingType, String cardinality, String projectionRole, + String primaryExpected, String groupExpected) + { + } + + public record PostingRow(String postingUUID, String postingType, String amount, String currency, String security, + String shares, String account, String portfolio) + { + } + + public record PostingParameterRow(String postingUUID, String postingType, String parameter, String code, + String valueKind, String value, String domain) + { + } + + public record ProjectionRefRow(String projectionRole, String owner, String projectionUUID, + String primaryPostingUUID, String postingGroupUUID) + { + } + + private final List headerRows; + private final List entryParameters; + private final List legs; + private final List postings; + private final List postingParameters; + private final List projectionRefs; + private final boolean nativeEntryDefinitionAvailable; + + private LedgerNativeComponentInspectorModel(List headerRows, List entryParameters, + List legs, List postings, List postingParameters, + List projectionRefs, boolean nativeEntryDefinitionAvailable) + { + this.headerRows = List.copyOf(headerRows); + this.entryParameters = List.copyOf(entryParameters); + this.legs = List.copyOf(legs); + this.postings = List.copyOf(postings); + this.postingParameters = List.copyOf(postingParameters); + this.projectionRefs = List.copyOf(projectionRefs); + this.nativeEntryDefinitionAvailable = nativeEntryDefinitionAvailable; + } + + public static Optional from(Object transaction) + { + if (transaction instanceof LedgerBackedTransaction ledgerBackedTransaction) + return from(ledgerBackedTransaction); + + return Optional.empty(); + } + + public static boolean isLedgerBackedProjection(Object transaction) + { + return transaction instanceof LedgerBackedTransaction; + } + + public static boolean isLedgerNativeTargetedProjection(Object transaction) + { + return transaction instanceof LedgerBackedTransaction ledgerBackedTransaction + && ledgerBackedTransaction.getLedgerEntry().getType() != null + && ledgerBackedTransaction.getLedgerEntry().getType().isLedgerNativeTargeted(); + } + + static Optional from(LedgerBackedTransaction transaction) + { + Objects.requireNonNull(transaction); + return from(transaction.getLedgerEntry(), transaction.getLedgerProjectionRef(), LedgerEntryDefinitionRegistry::lookup); + } + + static Optional from(LedgerEntry entry, LedgerProjectionRef selectedProjectionRef, + Function> definitionLookup) + { + Objects.requireNonNull(entry); + Objects.requireNonNull(definitionLookup); + + if (entry.getType() == null) + return Optional.empty(); + + var definition = entry.getType().isLedgerNativeTargeted() ? definitionLookup.apply(entry.getType()) + : Optional.empty(); + var nativeEntryDefinitionAvailable = definition.isPresent(); + + return Optional.of(new LedgerNativeComponentInspectorModel(headerRows(entry, selectedProjectionRef), + parameterRows(entry.getParameters()), + definition.map(d -> legRows(d.getLegDefinitions())).orElseGet(List::of), + postingRows(entry.getPostings()), postingParameterRows(entry.getPostings()), + projectionRefRows(entry.getProjectionRefs()), nativeEntryDefinitionAvailable)); + } + + public List getHeaderRows() + { + return headerRows; + } + + public List getEntryParameters() + { + return entryParameters; + } + + public List getLegs() + { + return legs; + } + + public List getPostings() + { + return postings; + } + + public List getPostingParameters() + { + return postingParameters; + } + + public List getProjectionRefs() + { + return projectionRefs; + } + + public boolean isNativeEntryDefinitionAvailable() + { + return nativeEntryDefinitionAvailable; + } + + private static List headerRows(LedgerEntry entry, LedgerProjectionRef selectedProjectionRef) + { + return List.of(new HeaderRow(HeaderField.ENTRY_TYPE, format(entry.getType())), + new HeaderRow(HeaderField.ENTRY_UUID, format(entry.getUUID())), + new HeaderRow(HeaderField.DATE_TIME, format(entry.getDateTime())), + new HeaderRow(HeaderField.NOTE, format(entry.getNote())), + new HeaderRow(HeaderField.SOURCE, format(entry.getSource())), + new HeaderRow(HeaderField.SHAPE, + entry.getType().isLedgerNativeTargeted() ? "Ledger-native targeted" //$NON-NLS-1$ + : "Legacy fixed-shape"), //$NON-NLS-1$ + new HeaderRow(HeaderField.NATIVE_TARGETED, + Boolean.toString(entry.getType().isLedgerNativeTargeted())), + new HeaderRow(HeaderField.SELECTED_PROJECTION_ROLE, + selectedProjectionRef != null ? format(selectedProjectionRef.getRole()) : ""), //$NON-NLS-1$ + new HeaderRow(HeaderField.SELECTED_PROJECTION_UUID, + selectedProjectionRef != null ? format(selectedProjectionRef.getUUID()) : ""), //$NON-NLS-1$ + new HeaderRow(HeaderField.SELECTED_PRIMARY_POSTING_UUID, + selectedProjectionRef != null + ? format(selectedProjectionRef.getPrimaryPostingUUID()) + : ""), //$NON-NLS-1$ + new HeaderRow(HeaderField.SELECTED_POSTING_GROUP_UUID, + selectedProjectionRef != null + ? format(selectedProjectionRef.getPostingGroupUUID()) + : "")); //$NON-NLS-1$ + } + + private static List parameterRows(List> parameters) + { + return parameters.stream().map(parameter -> new ParameterRow(format(parameter.getType()), + code(parameter.getType()), format(parameter.getValueKind()), format(parameter.getValue()), + domain(parameter.getType()))).toList(); + } + + private static List legRows(Collection definitions) + { + return definitions.stream() + .map(definition -> new LegRow(format(definition.getRole()), format(definition.getPostingType()), + format(definition.getCardinality()), + definition.getProjectionRole() + .map(LedgerNativeComponentInspectorModel::format) + .orElse(""), //$NON-NLS-1$ + Boolean.toString(definition.isPrimaryPostingExpected()), + Boolean.toString(definition.isPostingGroupExpected()))) + .toList(); + } + + private static List postingRows(List postings) + { + return postings.stream() + .map(posting -> new PostingRow(format(posting.getUUID()), format(posting.getType()), + formatAmount(posting), format(posting.getCurrency()), + format(posting.getSecurity()), formatShares(posting.getShares()), + format(posting.getAccount()), format(posting.getPortfolio()))) + .toList(); + } + + private static List postingParameterRows(List postings) + { + return postings.stream() + .flatMap(posting -> posting.getParameters().stream() + .map(parameter -> new PostingParameterRow(format(posting.getUUID()), + format(posting.getType()), format(parameter.getType()), + code(parameter.getType()), format(parameter.getValueKind()), + format(parameter.getValue()), domain(parameter.getType())))) + .toList(); + } + + private static List projectionRefRows(List projectionRefs) + { + return projectionRefs.stream() + .map(projectionRef -> new ProjectionRefRow(format(projectionRef.getRole()), + owner(projectionRef), format(projectionRef.getUUID()), + format(projectionRef.getPrimaryPostingUUID()), + format(projectionRef.getPostingGroupUUID()))) + .toList(); + } + + private static String owner(LedgerProjectionRef projectionRef) + { + if (projectionRef.getAccount() != null) + return format(projectionRef.getAccount()); + + return format(projectionRef.getPortfolio()); + } + + private static String code(LedgerParameterType type) + { + return type != null ? type.getCode() : ""; //$NON-NLS-1$ + } + + private static String domain(LedgerParameterType type) + { + return type != null && type.hasCodeDomain() ? format(type.getCodeDomain()) : ""; //$NON-NLS-1$ + } + + private static String formatAmount(LedgerPosting posting) + { + if (posting.getCurrency() == null || posting.getCurrency().isBlank()) + return posting.getAmount() != 0 ? Long.toString(posting.getAmount()) : ""; //$NON-NLS-1$ + + return Values.Money.format(Money.of(posting.getCurrency(), posting.getAmount())); + } + + private static String formatShares(long shares) + { + return shares != 0 ? Values.Share.format(shares) : ""; //$NON-NLS-1$ + } + + private static String format(Object value) + { + if (value == null) + return ""; //$NON-NLS-1$ + + if (value instanceof Money money) + return Values.Money.format(money); + else if (value instanceof LocalDate date) + return Values.Date.format(date); + else if (value instanceof LocalDateTime dateTime) + return Values.DateTime.format(dateTime); + else if (value instanceof BigDecimal decimal) + return decimal.toPlainString(); + else if (value instanceof Security security) + return security.getName(); + else if (value instanceof Account account) + return account.getName(); + else if (value instanceof Portfolio portfolio) + return portfolio.getName(); + + return String.valueOf(value); + } +} From 8eab2b275769f28dac748c04ea9d7119b91ec0bd Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Fri, 26 Jun 2026 14:30:40 +0200 Subject: [PATCH 09/68] Add Ledger importer investment-plan and stock-split guardrails Add Ledger guardrails for importer insertion, investment plans, checks, and stock split paths with tests. This isolates write-path protections outside the main transaction viewers. Reporting bridge, diagnostic NLS, and documentation are intentionally left unchanged in this commit. --- ...onsWithSecurityCanHaveExDateCheckTest.java | 194 +++++ .../actions/DetectDuplicatesActionTest.java | 34 + .../actions/InsertActionTest.java | 820 +++++++++++++++++- .../LedgerImportWriteGuardrailTest.java | 423 +++++++++ .../portfolio/model/InvestmentPlanTest.java | 490 +++++++++++ .../LedgerInvestmentPlanRefSupportTest.java | 223 +++++ .../transactions/InvestmentPlanModelTest.java | 31 + .../splits/LedgerStockSplitWritePathTest.java | 232 +++++ .../wizards/splits/StockSplitModelTest.java | 64 ++ .../ui/jobs/CreateInvestmentPlanTxJob.java | 2 +- .../ui/wizards/splits/StockSplitModel.java | 16 +- .../name.abuchen.portfolio.checks.Check | 6 +- .../impl/BuySellMissingSecurityIssue.java | 5 +- .../checks/impl/CrossEntryCheck.java | 365 ++------ .../checks/impl/DeleteTransactionFix.java | 8 + .../impl/DividendsAndInterestCheck.java | 35 +- .../impl/FixDepositsWithSecurityCheck.java | 37 - .../checks/impl/FixTaxRefundsCheck.java | 48 - .../checks/impl/FixWrongTransfersCheck.java | 53 -- .../checks/impl/LedgerCheckSupport.java | 39 + .../impl/MissingAccountTransferIssue.java | 63 +- .../impl/MissingBuySellAccountIssue.java | 104 +-- .../impl/MissingBuySellPortfolioIssue.java | 47 +- .../impl/MissingPortfolioTransferIssue.java | 67 +- .../impl/MissingTransactionDateCheck.java | 123 --- .../impl/NegativeExchangeRateCheck.java | 48 + ...actionsWithSecurityCanHaveExDateCheck.java | 57 +- ...tfolioTransactionWithoutSecurityCheck.java | 43 +- .../checks/impl/TransactionCurrencyCheck.java | 82 +- .../actions/DetectDuplicatesAction.java | 36 +- .../datatransfer/actions/InsertAction.java | 223 +++-- .../portfolio/model/InvestmentPlan.java | 330 +++++++ .../model/LedgerPlanReferenceSupport.java | 89 ++ 33 files changed, 3330 insertions(+), 1107 deletions(-) create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/checks/impl/OnlyAccountTransactionsWithSecurityCanHaveExDateCheckTest.java create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/datatransfer/actions/LedgerImportWriteGuardrailTest.java create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerInvestmentPlanRefSupportTest.java create mode 100644 name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/wizards/splits/LedgerStockSplitWritePathTest.java delete mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/FixDepositsWithSecurityCheck.java delete mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/FixTaxRefundsCheck.java delete mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/FixWrongTransfersCheck.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/LedgerCheckSupport.java delete mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/MissingTransactionDateCheck.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerPlanReferenceSupport.java diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/checks/impl/OnlyAccountTransactionsWithSecurityCanHaveExDateCheckTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/checks/impl/OnlyAccountTransactionsWithSecurityCanHaveExDateCheckTest.java new file mode 100644 index 0000000000..d0c3302988 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/checks/impl/OnlyAccountTransactionsWithSecurityCanHaveExDateCheckTest.java @@ -0,0 +1,194 @@ +package name.abuchen.portfolio.checks.impl; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.LocalDateTime; + +import org.junit.Test; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.ClientFactory; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerParameter; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; +import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator.IssueCode; +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.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionService; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Values; + +/** + * Tests repair behavior for account transactions that carry an ex-date without a security. + * These tests make sure legacy rows clear the field and ledger-backed rows remove only the ex-date fact. + */ +@SuppressWarnings("nls") +public class OnlyAccountTransactionsWithSecurityCanHaveExDateCheckTest +{ + private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 1, 2, 0, 0); + private static final LocalDateTime EX_DATE = LocalDateTime.of(2025, 12, 31, 0, 0); + + /** + * Verifies that the legacy ex-date repair keeps its existing behavior. + * An account transaction without security must simply lose the invalid ex-date. + */ + @Test + public void testLegacyAccountTransactionExDateWithoutSecurityIsCleared() + { + var client = new Client(); + var account = new Account(); + var transaction = new AccountTransaction(DATE_TIME, CurrencyUnit.EUR, Values.Amount.factorize(10), null, + AccountTransaction.Type.DIVIDENDS); + + client.addAccount(account); + transaction.setExDate(EX_DATE); + account.addTransaction(transaction); + + new OnlyAccountTransactionsWithSecurityCanHaveExDateCheck().execute(client); + + assertNull(transaction.getExDate()); + } + + /** + * Verifies that an invalid ex-date on a ledger-backed booking is removed as a single fact. + * The booking itself stays alive; no security or dividend structure is inferred. + */ + @Test + public void testLedgerBackedExDateWithoutSecurityClearsOnlyExDateFact() + { + var client = new Client(); + var account = new Account(); + var entry = new LedgerEntry("entry"); + var posting = new LedgerPosting("posting"); + var projection = new LedgerProjectionRef("projection"); + + client.addAccount(account); + + entry.setType(LedgerEntryType.DIVIDENDS); + entry.setDateTime(DATE_TIME); + + posting.setType(LedgerPostingType.CASH); + posting.setAccount(account); + posting.setAmount(Values.Amount.factorize(10)); + posting.setCurrency(CurrencyUnit.EUR); + posting.addParameter(LedgerParameter.ofLocalDateTime(LedgerParameterType.EX_DATE, EX_DATE)); + entry.addPosting(posting); + + projection.setRole(LedgerProjectionRole.ACCOUNT); + projection.setAccount(account); + entry.addProjectionRef(projection); + + client.getLedger().addEntry(entry); + LedgerProjectionService.materialize(client); + + var transaction = account.getTransactions().get(0); + + assertTrue(transaction instanceof LedgerBackedTransaction); + assertThat(transaction.getExDate(), is(EX_DATE)); + + var entryUUID = entry.getUUID(); + var projectionUUID = projection.getUUID(); + var projectionRole = projection.getRole(); + + new OnlyAccountTransactionsWithSecurityCanHaveExDateCheck().execute(client); + + assertThat(client.getLedger().getEntries().size(), is(1)); + assertThat(client.getLedger().getEntries().get(0).getUUID(), is(entryUUID)); + assertThat(entry.getProjectionRefs().get(0).getUUID(), is(projectionUUID)); + assertThat(entry.getProjectionRefs().get(0).getRole(), is(projectionRole)); + assertFalse(posting.getParameters().stream() + .anyMatch(parameter -> parameter.getType() == LedgerParameterType.EX_DATE)); + assertThat(account.getTransactions().size(), is(1)); + + var refreshed = account.getTransactions().get(0); + + assertTrue(refreshed instanceof LedgerBackedTransaction); + assertThat(((LedgerBackedTransaction) refreshed).getLedgerProjectionRef().getUUID(), is(projectionUUID)); + assertNull(refreshed.getExDate()); + + var validation = LedgerStructuralValidator.validate(client.getLedger()); + + assertFalse(validation.hasIssue(IssueCode.EX_DATE_SECURITY_REQUIRED)); + assertTrue(validation.hasIssue(IssueCode.DIVIDEND_SECURITY_REQUIRED)); + } + + /** + * Verifies that clearing an invalid ledger-backed ex-date is stable after XML save/load. + * The ledger entry and projection remain present, but the ex-date fact does not come back. + */ + @Test + public void testLedgerBackedExDateClearSurvivesXmlReload() throws Exception + { + var client = new Client(); + var account = new Account(); + var entry = new LedgerEntry("entry"); + var posting = new LedgerPosting("posting"); + var projection = new LedgerProjectionRef("projection"); + + client.addAccount(account); + + entry.setType(LedgerEntryType.FEES); + entry.setDateTime(DATE_TIME); + + posting.setType(LedgerPostingType.CASH); + posting.setAccount(account); + posting.setAmount(Values.Amount.factorize(10)); + posting.setCurrency(CurrencyUnit.EUR); + posting.addParameter(LedgerParameter.ofLocalDateTime(LedgerParameterType.EX_DATE, EX_DATE)); + entry.addPosting(posting); + + projection.setRole(LedgerProjectionRole.ACCOUNT); + projection.setAccount(account); + entry.addProjectionRef(projection); + + client.getLedger().addEntry(entry); + LedgerProjectionService.materialize(client); + + new OnlyAccountTransactionsWithSecurityCanHaveExDateCheck().execute(client); + + assertFalse(LedgerStructuralValidator.validate(client.getLedger()).hasIssue( + IssueCode.EX_DATE_SECURITY_REQUIRED)); + + var loaded = reloadXml(client); + + assertThat(loaded.getLedger().getEntries().size(), is(1)); + assertThat(loaded.getLedger().getEntries().get(0).getUUID(), is(entry.getUUID())); + assertThat(loaded.getAccounts().get(0).getTransactions().size(), is(1)); + assertNull(loaded.getAccounts().get(0).getTransactions().get(0).getExDate()); + } + + private Client reloadXml(Client client) throws Exception + { + return ClientFactory.load(new ByteArrayInputStream(saveXml(client).getBytes(StandardCharsets.UTF_8))); + } + + private String saveXml(Client client) throws Exception + { + var file = File.createTempFile("ledger-ex-date-repair-policy", ".xml"); //$NON-NLS-1$ //$NON-NLS-2$ + + try + { + ClientFactory.save(client, file); + return Files.readString(file.toPath(), StandardCharsets.UTF_8); + } + finally + { + Files.deleteIfExists(file.toPath()); + } + } +} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/datatransfer/actions/DetectDuplicatesActionTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/datatransfer/actions/DetectDuplicatesActionTest.java index b960b11322..2bea6e15a0 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/datatransfer/actions/DetectDuplicatesActionTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/datatransfer/actions/DetectDuplicatesActionTest.java @@ -24,6 +24,7 @@ import name.abuchen.portfolio.model.Client; import name.abuchen.portfolio.model.Portfolio; import name.abuchen.portfolio.model.PortfolioTransaction; +import name.abuchen.portfolio.model.PortfolioTransferEntry; import name.abuchen.portfolio.model.Security; import name.abuchen.portfolio.model.Transaction; @@ -129,6 +130,28 @@ public void testDuplicateDetectionWithTransferOutAndWithdrawal() assertThat(status.getCode(), is(Code.WARNING)); } + @Test + public void testPortfolioTransferDuplicateDetectionChecksSourceAndTargetPortfolios() + { + var action = new DetectDuplicatesAction(new Client()); + + var existingSource = getPortfolioTransferEntry(); + var sourceCandidate = getPortfolioTransferEntry(); + + var sourceStatus = action.process(sourceCandidate, portfolio(existingSource.getSourceTransaction()), + new Portfolio()); + + assertThat(sourceStatus.getCode(), is(Code.WARNING)); + + var existingTarget = getPortfolioTransferEntry(); + var targetCandidate = getPortfolioTransferEntry(); + + var targetStatus = action.process(targetCandidate, new Portfolio(), + portfolio(existingTarget.getTargetTransaction())); + + assertThat(targetStatus.getCode(), is(Code.WARNING)); + } + private AccountTransaction getTestEntry(AccountTransaction.Type type) { var transaction = new AccountTransaction(); @@ -140,6 +163,17 @@ private AccountTransaction getTestEntry(AccountTransaction.Type type) return transaction; } + private PortfolioTransferEntry getPortfolioTransferEntry() + { + var entry = new PortfolioTransferEntry(); + entry.setDate(LocalDateTime.of(2025, 12, 15, 0, 0)); + entry.setAmount(1000); + entry.setCurrencyCode("EUR"); //$NON-NLS-1$ + entry.setShares(100); + + return entry; + } + private Account account(AccountTransaction t) { Account a = new Account(); diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/datatransfer/actions/InsertActionTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/datatransfer/actions/InsertActionTest.java index cdcf9aa729..b68ce9b89b 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/datatransfer/actions/InsertActionTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/datatransfer/actions/InsertActionTest.java @@ -1,31 +1,65 @@ package name.abuchen.portfolio.datatransfer.actions; +import static org.hamcrest.CoreMatchers.instanceOf; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.core.IsIterableContaining.hasItem; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; import java.beans.BeanInfo; import java.beans.IntrospectionException; import java.beans.Introspector; import java.beans.PropertyDescriptor; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.lang.reflect.InvocationTargetException; +import java.math.BigDecimal; +import java.nio.file.Files; +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.Set; import java.util.stream.Collectors; +import org.eclipse.core.runtime.NullProgressMonitor; import org.junit.Before; import org.junit.Test; +import name.abuchen.portfolio.Messages; +import name.abuchen.portfolio.datatransfer.ImportAction.Status; +import name.abuchen.portfolio.junit.TestCurrencyConverter; import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.AccountTransferEntry; import name.abuchen.portfolio.model.BuySellEntry; import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.ClientFactory; +import name.abuchen.portfolio.model.InvestmentPlan; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; import name.abuchen.portfolio.model.Portfolio; import name.abuchen.portfolio.model.PortfolioTransaction; import name.abuchen.portfolio.model.PortfolioTransaction.Type; +import name.abuchen.portfolio.model.PortfolioTransferEntry; +import name.abuchen.portfolio.model.SaveFlag; import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.SecurityPrice; +import name.abuchen.portfolio.model.Transaction; import name.abuchen.portfolio.model.Transaction.Unit; +import name.abuchen.portfolio.model.ledger.LedgerParameter; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; +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.model.ledger.projection.LedgerBackedTransaction; import name.abuchen.portfolio.money.CurrencyUnit; import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.Values; @SuppressWarnings("nls") public class InsertActionTest @@ -33,32 +67,28 @@ public class InsertActionTest private Client client; private BuySellEntry entry; - private LocalDateTime transactionDate = LocalDateTime.now().withSecond(0).withNano(0); + private final LocalDateTime transactionDate = LocalDateTime.now().withSecond(0).withNano(0); + private final LocalDateTime exDate = transactionDate.minusDays(7); @Before public void prepare() { client = new Client(); - Security security = new Security(); + Security security = new Security("Security", CurrencyUnit.EUR); + security.setUpdatedAt(Instant.now()); client.addSecurity(security); - Portfolio portfolio = new Portfolio(); + Portfolio portfolio = new Portfolio("Portfolio"); + portfolio.setUpdatedAt(Instant.now()); client.addPortfolio(portfolio); - Account account = new Account(); + Account account = new Account("Account"); + account.setUpdatedAt(Instant.now()); client.addAccount(account); - entry = new BuySellEntry(); - entry.setType(Type.BUY); - entry.setMonetaryAmount(Money.of(CurrencyUnit.EUR, 9_99)); - entry.setShares(99); - entry.setDate(transactionDate); - entry.setSecurity(security); - entry.setNote("note"); - entry.setSource("source"); - entry.getPortfolioTransaction().addUnit(new Unit(Unit.Type.TAX, Money.of(CurrencyUnit.EUR, 1_99))); + entry = buySell(Type.BUY); } @Test - public void testInsertOfBuySellEntry() + public void testInsertOfBuySellEntryCreatesLedgerTruth() { Account account = client.getAccounts().get(0); Portfolio portfolio = client.getPortfolios().get(0); @@ -68,14 +98,20 @@ public void testInsertOfBuySellEntry() assertThat(account.getTransactions().size(), is(1)); assertThat(portfolio.getTransactions().size(), is(1)); + assertThat(client.getLedger().getEntries().size(), is(1)); + AccountTransaction accountTransaction = account.getTransactions().get(0); PortfolioTransaction t = portfolio.getTransactions().get(0); + assertThat(accountTransaction, instanceOf(LedgerBackedTransaction.class)); + assertThat(t, instanceOf(LedgerBackedTransaction.class)); assertThat(t.getType(), is(Type.BUY)); assertTransaction(t); + assertThat(client.getAllTransactions().size(), is(1)); + assertValid(client); } @Test - public void testConversionOfBuySellEntry() + public void testConversionOfBuySellEntryCreatesLedgerDeliveryTruth() { Account account = client.getAccounts().get(0); Portfolio portfolio = client.getPortfolios().get(0); @@ -86,10 +122,567 @@ public void testConversionOfBuySellEntry() assertThat(account.getTransactions().isEmpty(), is(true)); assertThat(portfolio.getTransactions().size(), is(1)); + assertThat(client.getLedger().getEntries().size(), is(1)); PortfolioTransaction delivery = portfolio.getTransactions().get(0); + assertThat(delivery, instanceOf(LedgerBackedTransaction.class)); assertThat(delivery.getType(), is(Type.DELIVERY_INBOUND)); assertTransaction(delivery); + assertValid(client); + } + + @Test + public void testAccountOnlyImportsCreateLedgerTruthForSupportedFamilies() + { + Account account = client.getAccounts().get(0); + Security security = client.getSecurities().get(0); + var types = List.of(AccountTransaction.Type.DEPOSIT, AccountTransaction.Type.REMOVAL, + AccountTransaction.Type.INTEREST, AccountTransaction.Type.INTEREST_CHARGE, + AccountTransaction.Type.FEES, AccountTransaction.Type.FEES_REFUND, + AccountTransaction.Type.TAXES, AccountTransaction.Type.TAX_REFUND); + + InsertAction action = new InsertAction(client); + for (AccountTransaction.Type type : types) + { + AccountTransaction transaction = new AccountTransaction(type); + transaction.setDateTime(transactionDate); + transaction.setAmount(Values.Amount.factorize(10)); + transaction.setCurrencyCode(CurrencyUnit.EUR); + transaction.setSecurity(security); + transaction.setNote("note " + type.name()); + transaction.setSource("source " + type.name()); + transaction.addUnit(new Unit(Unit.Type.FEE, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(1)), + Money.of(CurrencyUnit.USD, Values.Amount.factorize(2)), BigDecimal.valueOf(0.5))); + + action.process(transaction, account); + } + + assertThat(client.getLedger().getEntries().size(), is(types.size())); + assertThat(account.getTransactions().size(), is(types.size())); + assertThat(client.getAllTransactions().size(), is(types.size())); + assertTrue(account.getTransactions().stream().allMatch(LedgerBackedTransaction.class::isInstance)); + assertTrue(client.getLedger().getEntries().stream().anyMatch(entry -> entry.getPostings().stream() + .anyMatch(posting -> posting.getType() == LedgerPostingType.FEE + && posting.getForexAmount() == Values.Amount.factorize(2) + && CurrencyUnit.USD.equals(posting.getForexCurrency())))); + assertValid(client); + } + + @Test + public void testDividendImportCreatesLedgerTruthAndPreservesFacts() + { + Account account = client.getAccounts().get(0); + Security security = client.getSecurities().get(0); + AccountTransaction dividend = dividend(); + + new InsertAction(client).process(dividend, account); + + AccountTransaction projection = account.getTransactions().get(0); + LedgerPosting cashPosting = client.getLedger().getEntries().get(0).getPostings().get(0); + + assertThat(projection, instanceOf(LedgerBackedTransaction.class)); + assertThat(client.getLedger().getEntries().get(0).getType(), is(LedgerEntryType.DIVIDENDS)); + assertThat(projection.getExDate(), is(exDate)); + assertThat(projection.getShares(), is(Values.Share.factorize(3))); + assertSame(security, projection.getSecurity()); + assertThat(projection.getUnits().count(), is(2L)); + assertThat(exDate(cashPosting), is(exDate)); + assertValid(client); + } + + @Test + public void testDeliveryImportsCreateLedgerTruth() + { + Portfolio portfolio = client.getPortfolios().get(0); + + new InsertAction(client).process(delivery(Type.DELIVERY_INBOUND), portfolio); + new InsertAction(client).process(delivery(Type.DELIVERY_OUTBOUND), portfolio); + + assertThat(client.getLedger().getEntries().size(), is(2)); + assertThat(client.getLedger().getEntries().get(0).getType(), is(LedgerEntryType.DELIVERY_INBOUND)); + assertThat(client.getLedger().getEntries().get(1).getType(), is(LedgerEntryType.DELIVERY_OUTBOUND)); + assertThat(portfolio.getTransactions().size(), is(2)); + assertTrue(portfolio.getTransactions().stream().allMatch(LedgerBackedTransaction.class::isInstance)); + assertThat(client.getAllTransactions().size(), is(2)); + assertValid(client); + } + + @Test + public void testAccountTransferImportCreatesOneLedgerEntryWithTwoProjections() + { + Account source = client.getAccounts().get(0); + Account target = new Account("USD Account"); + target.setCurrencyCode(CurrencyUnit.USD); + client.addAccount(target); + AccountTransferEntry transfer = new AccountTransferEntry(); + transfer.setDate(transactionDate); + transfer.getSourceTransaction().setMonetaryAmount(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(100))); + transfer.getTargetTransaction().setMonetaryAmount(Money.of(CurrencyUnit.USD, Values.Amount.factorize(200))); + transfer.getSourceTransaction().addUnit(new Unit(Unit.Type.GROSS_VALUE, + Money.of(CurrencyUnit.EUR, Values.Amount.factorize(100)), + Money.of(CurrencyUnit.USD, Values.Amount.factorize(200)), BigDecimal.valueOf(0.5))); + transfer.setNote("transfer note"); + transfer.setSource("transfer source"); + + new InsertAction(client).process(transfer, source, target); + + AccountTransaction sourceProjection = source.getTransactions().get(0); + AccountTransaction targetProjection = target.getTransactions().get(0); + + assertThat(client.getLedger().getEntries().size(), is(1)); + assertThat(client.getLedger().getEntries().get(0).getType(), is(LedgerEntryType.CASH_TRANSFER)); + assertThat(sourceProjection, instanceOf(LedgerBackedTransaction.class)); + assertThat(targetProjection, instanceOf(LedgerBackedTransaction.class)); + assertThat(sourceProjection.getType(), is(AccountTransaction.Type.TRANSFER_OUT)); + assertThat(targetProjection.getType(), is(AccountTransaction.Type.TRANSFER_IN)); + assertThat(sourceProjection.getCrossEntry().getCrossTransaction(sourceProjection), is(targetProjection)); + assertThat(client.getAllTransactions().size(), is(1)); + assertValid(client); + } + + @Test + public void testPortfolioTransferImportCreatesOneLedgerEntryWithTwoProjections() + { + Portfolio source = client.getPortfolios().get(0); + Portfolio target = new Portfolio("Target Portfolio"); + client.addPortfolio(target); + PortfolioTransferEntry transfer = portfolioTransfer(); + + new InsertAction(client).process(transfer, source, target); + + PortfolioTransaction sourceProjection = source.getTransactions().get(0); + PortfolioTransaction targetProjection = target.getTransactions().get(0); + + assertThat(client.getLedger().getEntries().size(), is(1)); + assertThat(client.getLedger().getEntries().get(0).getType(), is(LedgerEntryType.SECURITY_TRANSFER)); + assertThat(sourceProjection, instanceOf(LedgerBackedTransaction.class)); + assertThat(targetProjection, instanceOf(LedgerBackedTransaction.class)); + assertThat(sourceProjection.getType(), is(Type.TRANSFER_OUT)); + assertThat(targetProjection.getType(), is(Type.TRANSFER_IN)); + assertThat(sourceProjection.getCrossEntry().getCrossTransaction(sourceProjection), is(targetProjection)); + assertThat(client.getAllTransactions().size(), is(1)); + assertValid(client); + } + + @Test + public void testBuyAndSellImportsCreateLedgerTruth() + { + Account account = client.getAccounts().get(0); + Portfolio portfolio = client.getPortfolios().get(0); + BuySellEntry buy = buySell(Type.BUY); + BuySellEntry sell = buySell(Type.SELL); + + InsertAction action = new InsertAction(client); + action.process(buy, account, portfolio); + action.process(sell, account, portfolio); + + assertThat(client.getLedger().getEntries().size(), is(2)); + assertThat(client.getLedger().getEntries().get(0).getType(), is(LedgerEntryType.BUY)); + assertThat(client.getLedger().getEntries().get(1).getType(), is(LedgerEntryType.SELL)); + assertThat(account.getTransactions().size(), is(2)); + assertThat(portfolio.getTransactions().size(), is(2)); + assertTrue(account.getTransactions().stream().allMatch(LedgerBackedTransaction.class::isInstance)); + assertTrue(portfolio.getTransactions().stream().allMatch(LedgerBackedTransaction.class::isInstance)); + assertThat(client.getAllTransactions().size(), is(2)); + assertValid(client); + } + + @Test + public void testDuplicateDetectionStillFindsImportedLedgerBackedProjection() + { + Account account = client.getAccounts().get(0); + Portfolio portfolio = client.getPortfolios().get(0); + InsertAction insert = new InsertAction(client); + DetectDuplicatesAction duplicates = new DetectDuplicatesAction(client); + + insert.process(entry, account, portfolio); + + Status status = duplicates.process(buySell(Type.BUY), account, portfolio); + + assertThat(status.getCode(), is(Status.Code.WARNING)); + assertThat(status.getMessage(), is(Messages.LabelPotentialDuplicate)); + assertThat(client.getLedger().getEntries().size(), is(1)); + assertThat(client.getAllTransactions().size(), is(1)); + } + + @Test + public void testInvestmentPlanGeneratedLedgerBuyIsUpdatedByImportedBuyInsteadOfDuplicated() throws Exception + { + Account account = client.getAccounts().get(0); + Portfolio portfolio = client.getPortfolios().get(0); + Security security = client.getSecurities().get(0); + LocalDate planStart = transactionDate.toLocalDate().minusMonths(1); + security.addPrice(new SecurityPrice(planStart, Values.Quote.factorize(10))); + + InvestmentPlan plan = new InvestmentPlan("Buy Plan"); + plan.setType(InvestmentPlan.Type.PURCHASE_OR_DELIVERY); + plan.setAccount(account); + plan.setPortfolio(portfolio); + plan.setSecurity(security); + plan.setAmount(Values.Amount.factorize(100)); + plan.setFees(Values.Amount.factorize(1)); + plan.setTaxes(Values.Amount.factorize(2)); + plan.setStart(planStart); + plan.setInterval(12); + client.addPlan(plan); + + PortfolioTransaction generated = (PortfolioTransaction) plan + .generateTransactions(client, new TestCurrencyConverter()).get(0).getTransaction(); + var ledgerBacked = (LedgerBackedTransaction) generated; + String ledgerEntryUUID = ledgerBacked.getLedgerEntry().getUUID(); + String projectionUUID = ledgerBacked.getLedgerProjectionRef().getUUID(); + long importedShares = Math.round(generated.getShares() * 1.05d); + + BuySellEntry imported = buySell(Type.BUY); + imported.setDate(generated.getDateTime()); + imported.setMonetaryAmount(generated.getMonetaryAmount()); + imported.setShares(importedShares); + imported.setSecurity(security); + imported.setNote("imported note"); + imported.setSource("imported source"); + imported.getPortfolioTransaction().clearUnits(); + imported.getPortfolioTransaction() + .addUnit(new Unit(Unit.Type.FEE, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(3)))); + imported.getPortfolioTransaction() + .addUnit(new Unit(Unit.Type.TAX, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(4)))); + + Status duplicateStatus = new DetectDuplicatesAction(client).process(imported, account, portfolio); + assertThat(duplicateStatus.getCode(), is(Status.Code.WARNING)); + assertThat(duplicateStatus.getMessage(), is(Messages.InvestmentPlanItemImportToolTip)); + + InsertAction insert = new InsertAction(client); + insert.setInvestmentPlanItem(true); + insert.process(imported, account, portfolio); + + assertThat(client.getLedger().getEntries().size(), is(1)); + assertThat(account.getTransactions().size(), is(1)); + assertThat(portfolio.getTransactions().size(), is(1)); + assertThat(client.getAllTransactions().size(), is(1)); + + PortfolioTransaction updated = portfolio.getTransactions().get(0); + AccountTransaction updatedAccountSide = (AccountTransaction) updated.getCrossEntry() + .getCrossTransaction(updated); + var updatedLedgerBacked = (LedgerBackedTransaction) updated; + + assertThat(updatedLedgerBacked.getLedgerEntry().getUUID(), is(ledgerEntryUUID)); + assertThat(updated.getUUID(), is(projectionUUID)); + assertThat(updated.getShares(), is(importedShares)); + assertThat(updated.getNote(), is("imported note")); + assertThat(updated.getSource(), is("imported source")); + assertThat(updatedAccountSide.getNote(), is("imported note")); + assertThat(updatedAccountSide.getSource(), is("imported source")); + assertThat(updated.getUnitSum(Unit.Type.FEE), is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(3)))); + assertThat(updated.getUnitSum(Unit.Type.TAX), is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(4)))); + + assertThat(plan.getLedgerExecutionRefs().size(), is(1)); + assertThat(plan.getLedgerExecutionRefs().get(0).getLedgerEntryUUID(), is(ledgerEntryUUID)); + assertThat(plan.getLedgerExecutionRefs().get(0).getProjectionUUID(), is(projectionUUID)); + assertThat(plan.getTransactions(client).get(0).getTransaction(), is(updated)); + + Client xml = loadXml(saveXml(client)); + Client protobuf = loadProtobuf(saveProtobuf(client)); + assertUpdatedPlanImportRoundtrip(xml, importedShares); + assertUpdatedPlanImportRoundtrip(protobuf, importedShares); + assertValid(client); + } + + @Test + public void testInvestmentPlanGeneratedLedgerBuyRejectsImportedSellWithDiagnostic() throws Exception + { + Account account = client.getAccounts().get(0); + Portfolio portfolio = client.getPortfolios().get(0); + Security security = client.getSecurities().get(0); + LocalDate planStart = transactionDate.toLocalDate().minusMonths(1); + security.addPrice(new SecurityPrice(planStart, Values.Quote.factorize(10))); + + InvestmentPlan plan = new InvestmentPlan("Buy Plan"); + plan.setType(InvestmentPlan.Type.PURCHASE_OR_DELIVERY); + plan.setAccount(account); + plan.setPortfolio(portfolio); + plan.setSecurity(security); + plan.setAmount(Values.Amount.factorize(100)); + plan.setStart(planStart); + plan.setInterval(12); + client.addPlan(plan); + + PortfolioTransaction generated = (PortfolioTransaction) plan + .generateTransactions(client, new TestCurrencyConverter()).get(0).getTransaction(); + BuySellEntry imported = buySell(Type.SELL); + imported.setDate(generated.getDateTime()); + imported.setSecurity(security); + + InsertAction insert = new InsertAction(client); + var update = InsertAction.class.getDeclaredMethod("updateLedgerBackedInvestmentPlanTransaction", + Transaction.class, BuySellEntry.class); + update.setAccessible(true); + + var exception = assertThrows(InvocationTargetException.class, () -> update.invoke(insert, generated, imported)); + + assertThat(exception.getCause().getMessage(), is(LedgerDiagnosticCode.LEDGER_UI_006.message( + Messages.LedgerInsertActionGeneratedBuyTypeMismatch))); + } + + @Test + public void testNonMatchingInvestmentPlanImportStillCreatesNewLedgerBuy() throws Exception + { + Account account = client.getAccounts().get(0); + Portfolio portfolio = client.getPortfolios().get(0); + Security security = client.getSecurities().get(0); + LocalDate planStart = transactionDate.toLocalDate().minusMonths(1); + security.addPrice(new SecurityPrice(planStart, Values.Quote.factorize(10))); + + InvestmentPlan plan = new InvestmentPlan("Buy Plan"); + plan.setType(InvestmentPlan.Type.PURCHASE_OR_DELIVERY); + plan.setAccount(account); + plan.setPortfolio(portfolio); + plan.setSecurity(security); + plan.setAmount(Values.Amount.factorize(100)); + plan.setStart(planStart); + plan.setInterval(12); + client.addPlan(plan); + plan.generateTransactions(client, new TestCurrencyConverter()); + + Security otherSecurity = new Security("Other Security", CurrencyUnit.EUR); + client.addSecurity(otherSecurity); + BuySellEntry imported = buySell(Type.BUY); + imported.setDate(transactionDate); + imported.setSecurity(otherSecurity); + + Status duplicateStatus = new DetectDuplicatesAction(client).process(imported, account, portfolio); + assertThat(duplicateStatus.getCode(), is(Status.Code.OK)); + + new InsertAction(client).process(imported, account, portfolio); + + assertThat(client.getLedger().getEntries().size(), is(2)); + assertThat(account.getTransactions().size(), is(2)); + assertThat(portfolio.getTransactions().size(), is(2)); + assertThat(client.getAllTransactions().size(), is(2)); + assertThat(plan.getLedgerExecutionRefs().size(), is(1)); + assertValid(client); + } + + @Test + public void testAmbiguousInvestmentPlanGeneratedLedgerBuyIsNotUpdatedByImportedBuy() throws Exception + { + Account account = client.getAccounts().get(0); + Portfolio portfolio = client.getPortfolios().get(0); + Security security = client.getSecurities().get(0); + LocalDate planStart = transactionDate.toLocalDate().minusMonths(1); + security.addPrice(new SecurityPrice(planStart, Values.Quote.factorize(10))); + + InvestmentPlan firstPlan = buyPlan("First Buy Plan", account, portfolio, security, planStart); + InvestmentPlan secondPlan = buyPlan("Second Buy Plan", account, portfolio, security, planStart); + client.addPlan(firstPlan); + client.addPlan(secondPlan); + + PortfolioTransaction firstGenerated = (PortfolioTransaction) firstPlan + .generateTransactions(client, new TestCurrencyConverter()).get(0).getTransaction(); + PortfolioTransaction secondGenerated = (PortfolioTransaction) secondPlan + .generateTransactions(client, new TestCurrencyConverter()).get(0).getTransaction(); + long firstShares = firstGenerated.getShares(); + long secondShares = secondGenerated.getShares(); + String firstNote = firstGenerated.getNote(); + String secondNote = secondGenerated.getNote(); + + BuySellEntry imported = buySell(Type.BUY); + imported.setDate(firstGenerated.getDateTime()); + imported.setMonetaryAmount(firstGenerated.getMonetaryAmount()); + imported.setShares(Math.round(firstGenerated.getShares() * 1.05d)); + imported.setSecurity(security); + imported.setNote("ambiguous imported note"); + imported.setSource("ambiguous imported source"); + + Status duplicateStatus = new DetectDuplicatesAction(client).process(imported, account, portfolio); + assertThat(duplicateStatus.getCode(), is(Status.Code.WARNING)); + assertThat(duplicateStatus.getMessage(), is(Messages.LabelPotentialDuplicate)); + + InsertAction insert = new InsertAction(client); + insert.setInvestmentPlanItem(true); + Status insertStatus = insert.process(imported, account, portfolio); + + assertThat(insertStatus.getCode(), is(Status.Code.WARNING)); + assertThat(insertStatus.getMessage(), is(Messages.LabelPotentialDuplicate)); + assertThat(client.getLedger().getEntries().size(), is(2)); + assertThat(account.getTransactions().size(), is(2)); + assertThat(portfolio.getTransactions().size(), is(2)); + assertThat(client.getAllTransactions().size(), is(2)); + assertThat(firstGenerated.getShares(), is(firstShares)); + assertThat(secondGenerated.getShares(), is(secondShares)); + assertThat(firstGenerated.getNote(), is(firstNote)); + assertThat(secondGenerated.getNote(), is(secondNote)); + assertThat(firstPlan.getLedgerExecutionRefs().size(), is(1)); + assertThat(secondPlan.getLedgerExecutionRefs().size(), is(1)); + assertValid(client); + } + + @Test + public void testPortfolioTransferDuplicatePipelineSkipsSecondLedgerInsertion() + { + Portfolio source = client.getPortfolios().get(0); + Portfolio target = new Portfolio("Target Portfolio"); + client.addPortfolio(target); + + PortfolioTransferEntry first = portfolioTransfer(); + Status firstStatus = importIfNotDuplicate(first, source, target); + + PortfolioTransaction sourceProjection = source.getTransactions().get(0); + PortfolioTransaction targetProjection = target.getTransactions().get(0); + + assertThat(firstStatus.getCode(), is(Status.Code.OK)); + assertThat(client.getLedger().getEntries().size(), is(1)); + assertThat(source.getTransactions().size(), is(1)); + assertThat(target.getTransactions().size(), is(1)); + assertThat(sourceProjection.getType(), is(Type.TRANSFER_OUT)); + assertThat(targetProjection.getType(), is(Type.TRANSFER_IN)); + + PortfolioTransferEntry duplicate = portfolioTransfer(); + Status duplicateStatus = importIfNotDuplicate(duplicate, source, target); + + assertThat(duplicateStatus.getCode(), is(Status.Code.WARNING)); + assertThat(duplicateStatus.getMessage(), is(Messages.LabelPotentialDuplicate)); + assertThat(client.getLedger().getEntries().size(), is(1)); + assertThat(source.getTransactions().size(), is(1)); + assertThat(target.getTransactions().size(), is(1)); + assertSame(sourceProjection, source.getTransactions().get(0)); + assertSame(targetProjection, target.getTransactions().get(0)); + assertThat(source.getTransactions().get(0).getType(), is(Type.TRANSFER_OUT)); + assertThat(target.getTransactions().get(0).getType(), is(Type.TRANSFER_IN)); + assertThat(client.getAllTransactions().size(), is(1)); + assertValid(client); + } + + @Test + public void testAccountOnlyDuplicatePipelineSkipsSecondLedgerInsertion() + { + Account account = client.getAccounts().get(0); + + Status firstStatus = importIfNotDuplicate(accountOnly(AccountTransaction.Type.DEPOSIT), account); + Status duplicateStatus = importIfNotDuplicate(accountOnly(AccountTransaction.Type.DEPOSIT), account); + + assertThat(firstStatus.getCode(), is(Status.Code.OK)); + assertThat(duplicateStatus.getCode(), is(Status.Code.WARNING)); + assertThat(client.getLedger().getEntries().size(), is(1)); + assertThat(account.getTransactions().size(), is(1)); + assertThat(client.getAllTransactions().size(), is(1)); + assertThat(account.getTransactions().get(0), instanceOf(LedgerBackedTransaction.class)); + assertValid(client); + } + + @Test + public void testDividendDuplicatePipelineSkipsSecondLedgerInsertion() + { + Account account = client.getAccounts().get(0); + + Status firstStatus = importIfNotDuplicate(dividend(), account); + Status duplicateStatus = importIfNotDuplicate(dividend(), account); + + assertThat(firstStatus.getCode(), is(Status.Code.OK)); + assertThat(duplicateStatus.getCode(), is(Status.Code.WARNING)); + assertThat(client.getLedger().getEntries().size(), is(1)); + assertThat(account.getTransactions().size(), is(1)); + assertThat(client.getAllTransactions().size(), is(1)); + assertThat(account.getTransactions().get(0), instanceOf(LedgerBackedTransaction.class)); + assertThat(account.getTransactions().get(0).getExDate(), is(exDate)); + assertValid(client); + } + + @Test + public void testDeliveryDuplicatePipelineSkipsSecondLedgerInsertion() + { + Portfolio portfolio = client.getPortfolios().get(0); + + Status firstStatus = importIfNotDuplicate(delivery(Type.DELIVERY_INBOUND), portfolio); + Status duplicateStatus = importIfNotDuplicate(delivery(Type.DELIVERY_INBOUND), portfolio); + + assertThat(firstStatus.getCode(), is(Status.Code.OK)); + assertThat(duplicateStatus.getCode(), is(Status.Code.WARNING)); + assertThat(client.getLedger().getEntries().size(), is(1)); + assertThat(portfolio.getTransactions().size(), is(1)); + assertThat(client.getAllTransactions().size(), is(1)); + assertThat(portfolio.getTransactions().get(0), instanceOf(LedgerBackedTransaction.class)); + assertThat(portfolio.getTransactions().get(0).getType(), is(Type.DELIVERY_INBOUND)); + assertValid(client); + } + + @Test + public void testAccountTransferDuplicatePipelineSkipsSecondLedgerInsertion() + { + Account source = client.getAccounts().get(0); + Account target = new Account("USD Account"); + target.setCurrencyCode(CurrencyUnit.USD); + client.addAccount(target); + + Status firstStatus = importIfNotDuplicate(accountTransfer(), source, target); + Status duplicateStatus = importIfNotDuplicate(accountTransfer(), source, target); + + assertThat(firstStatus.getCode(), is(Status.Code.OK)); + assertThat(duplicateStatus.getCode(), is(Status.Code.WARNING)); + assertThat(client.getLedger().getEntries().size(), is(1)); + assertThat(source.getTransactions().size(), is(1)); + assertThat(target.getTransactions().size(), is(1)); + assertThat(client.getAllTransactions().size(), is(1)); + assertThat(source.getTransactions().get(0).getType(), is(AccountTransaction.Type.TRANSFER_OUT)); + assertThat(target.getTransactions().get(0).getType(), is(AccountTransaction.Type.TRANSFER_IN)); + assertValid(client); + } + + @Test + public void testBuySellDuplicatePipelineSkipsSecondLedgerInsertion() + { + Account account = client.getAccounts().get(0); + Portfolio portfolio = client.getPortfolios().get(0); + + Status firstStatus = importIfNotDuplicate(buySell(Type.BUY), account, portfolio); + Status duplicateStatus = importIfNotDuplicate(buySell(Type.BUY), account, portfolio); + + assertThat(firstStatus.getCode(), is(Status.Code.OK)); + assertThat(duplicateStatus.getCode(), is(Status.Code.WARNING)); + assertThat(client.getLedger().getEntries().size(), is(1)); + assertThat(account.getTransactions().size(), is(1)); + assertThat(portfolio.getTransactions().size(), is(1)); + assertThat(client.getAllTransactions().size(), is(1)); + assertThat(account.getTransactions().get(0), instanceOf(LedgerBackedTransaction.class)); + assertThat(portfolio.getTransactions().get(0), instanceOf(LedgerBackedTransaction.class)); + assertValid(client); + } + + @Test + public void testXmlAndProtobufRoundtripAfterImportedLedgerEntries() throws Exception + { + Account account = client.getAccounts().get(0); + Portfolio portfolio = client.getPortfolios().get(0); + InsertAction action = new InsertAction(client); + action.process(accountOnly(AccountTransaction.Type.DEPOSIT), account); + action.process(dividend(), account); + action.process(delivery(Type.DELIVERY_INBOUND), portfolio); + action.process(buySell(Type.BUY), account, portfolio); + + Client xml = loadXml(saveXml(client)); + Client protobuf = loadProtobuf(saveProtobuf(client)); + + assertThat(xml.getLedger().getEntries().size(), is(4)); + assertThat(protobuf.getLedger().getEntries().size(), is(4)); + assertThat(xml.getAllTransactions().size(), is(4)); + assertThat(protobuf.getAllTransactions().size(), is(4)); + assertTrue(xml.getAllTransactions().stream() + .allMatch(holder -> holder.getTransaction() instanceof LedgerBackedTransaction)); + assertTrue(protobuf.getAllTransactions().stream() + .allMatch(holder -> holder.getTransaction() instanceof LedgerBackedTransaction)); + assertValid(xml); + assertValid(protobuf); + } + + private InvestmentPlan buyPlan(String name, Account account, Portfolio portfolio, Security security, LocalDate start) + { + InvestmentPlan plan = new InvestmentPlan(name); + plan.setType(InvestmentPlan.Type.PURCHASE_OR_DELIVERY); + plan.setAccount(account); + plan.setPortfolio(portfolio); + plan.setSecurity(security); + plan.setAmount(Values.Amount.factorize(100)); + plan.setStart(start); + plan.setInterval(12); + return plan; } private void assertTransaction(PortfolioTransaction t) @@ -105,6 +698,203 @@ private void assertTransaction(PortfolioTransaction t) assertThat(t.getUnits().count(), is(1L)); } + private void assertUpdatedPlanImportRoundtrip(Client client, long importedShares) + { + assertThat(client.getLedger().getEntries().size(), is(1)); + assertThat(client.getAccounts().get(0).getTransactions().size(), is(1)); + assertThat(client.getPortfolios().get(0).getTransactions().size(), is(1)); + assertThat(client.getAllTransactions().size(), is(1)); + + PortfolioTransaction updated = client.getPortfolios().get(0).getTransactions().get(0); + assertThat(updated, instanceOf(LedgerBackedTransaction.class)); + assertThat(updated.getShares(), is(importedShares)); + assertThat(updated.getNote(), is("imported note")); + assertThat(updated.getSource(), is("imported source")); + assertThat(updated.getUnitSum(Unit.Type.FEE), is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(3)))); + assertThat(updated.getUnitSum(Unit.Type.TAX), is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(4)))); + assertThat(client.getPlans().get(0).getTransactions(client).get(0).getTransaction(), is(updated)); + assertValid(client); + } + + private AccountTransaction accountOnly(AccountTransaction.Type type) + { + AccountTransaction transaction = new AccountTransaction(type); + transaction.setDateTime(transactionDate); + transaction.setAmount(Values.Amount.factorize(11)); + transaction.setCurrencyCode(CurrencyUnit.EUR); + transaction.setNote("account-only note"); + transaction.setSource("account-only source"); + return transaction; + } + + private AccountTransaction dividend() + { + AccountTransaction dividend = accountOnly(AccountTransaction.Type.DIVIDENDS); + dividend.setSecurity(client.getSecurities().get(0)); + dividend.setShares(Values.Share.factorize(3)); + dividend.setExDate(exDate); + dividend.addUnit(new Unit(Unit.Type.GROSS_VALUE, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(20)), + Money.of(CurrencyUnit.USD, Values.Amount.factorize(40)), BigDecimal.valueOf(0.5))); + dividend.addUnit(new Unit(Unit.Type.TAX, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(5)))); + return dividend; + } + + private AccountTransferEntry accountTransfer() + { + AccountTransferEntry transfer = new AccountTransferEntry(); + transfer.setDate(transactionDate); + transfer.getSourceTransaction().setMonetaryAmount(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(100))); + transfer.getTargetTransaction().setMonetaryAmount(Money.of(CurrencyUnit.USD, Values.Amount.factorize(200))); + transfer.setNote("transfer note"); + transfer.setSource("transfer source"); + return transfer; + } + + private PortfolioTransaction delivery(Type type) + { + PortfolioTransaction transaction = new PortfolioTransaction(type); + transaction.setDateTime(transactionDate); + transaction.setSecurity(client.getSecurities().get(0)); + transaction.setAmount(Values.Amount.factorize(30)); + transaction.setCurrencyCode(CurrencyUnit.EUR); + transaction.setShares(Values.Share.factorize(3)); + transaction.setNote("delivery note"); + transaction.setSource("delivery source"); + transaction.addUnit(new Unit(Unit.Type.FEE, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(2)), + Money.of(CurrencyUnit.USD, Values.Amount.factorize(4)), BigDecimal.valueOf(0.5))); + return transaction; + } + + private PortfolioTransferEntry portfolioTransfer() + { + PortfolioTransferEntry transfer = new PortfolioTransferEntry(); + transfer.setDate(transactionDate); + transfer.setSecurity(client.getSecurities().get(0)); + transfer.setShares(Values.Share.factorize(7)); + transfer.setAmount(Values.Amount.factorize(70)); + transfer.setCurrencyCode(CurrencyUnit.EUR); + transfer.setNote("portfolio transfer note"); + transfer.setSource("portfolio transfer source"); + return transfer; + } + + private BuySellEntry buySell(Type type) + { + BuySellEntry buySell = new BuySellEntry(); + buySell.setType(type); + buySell.setMonetaryAmount(Money.of(CurrencyUnit.EUR, 9_99)); + buySell.setShares(99); + buySell.setDate(transactionDate); + buySell.setSecurity(client.getSecurities().get(0)); + buySell.setNote("note"); + buySell.setSource("source"); + buySell.getPortfolioTransaction().addUnit(new Unit(Unit.Type.TAX, Money.of(CurrencyUnit.EUR, 1_99))); + return buySell; + } + + private Status importIfNotDuplicate(AccountTransaction transaction, Account account) + { + Status status = new DetectDuplicatesAction(client).process(transaction, account); + if (status.getCode() == Status.Code.OK) + new InsertAction(client).process(transaction, account); + return status; + } + + private Status importIfNotDuplicate(PortfolioTransaction transaction, Portfolio portfolio) + { + Status status = new DetectDuplicatesAction(client).process(transaction, portfolio); + if (status.getCode() == Status.Code.OK) + new InsertAction(client).process(transaction, portfolio); + return status; + } + + private Status importIfNotDuplicate(BuySellEntry buySell, Account account, Portfolio portfolio) + { + Status status = new DetectDuplicatesAction(client).process(buySell, account, portfolio); + if (status.getCode() == Status.Code.OK) + new InsertAction(client).process(buySell, account, portfolio); + return status; + } + + private Status importIfNotDuplicate(AccountTransferEntry transfer, Account source, Account target) + { + Status status = new DetectDuplicatesAction(client).process(transfer, source, target); + if (status.getCode() == Status.Code.OK) + new InsertAction(client).process(transfer, source, target); + return status; + } + + private Status importIfNotDuplicate(PortfolioTransferEntry transfer, Portfolio source, Portfolio target) + { + Status status = new DetectDuplicatesAction(client).process(transfer, source, target); + if (status.getCode() == Status.Code.OK) + new InsertAction(client).process(transfer, source, target); + return status; + } + + private LocalDateTime exDate(LedgerPosting posting) + { + return posting.getParameters().stream() + .filter(parameter -> parameter.getType() == LedgerParameterType.EX_DATE) + .findFirst() + .map(LedgerParameter::getValue) + .map(LocalDateTime.class::cast) + .orElse(null); + } + + private void assertValid(Client client) + { + if (!LedgerStructuralValidator.validate(client.getLedger()).isOK()) + throw new AssertionError(LedgerStructuralValidator.validate(client.getLedger()).getIssues().toString()); + } + + private byte[] saveXml(Client client) throws Exception + { + File file = Files.createTempFile("ledger-import-insert-action", ".xml").toFile(); + try + { + ClientFactory.save(client, file); + return Files.readAllBytes(file.toPath()); + } + finally + { + Files.deleteIfExists(file.toPath()); + } + } + + private Client loadXml(byte[] bytes) throws Exception + { + return ClientFactory.load(new ByteArrayInputStream(bytes)); + } + + private byte[] saveProtobuf(Client client) throws Exception + { + File file = Files.createTempFile("ledger-import-insert-action", ".portfolio").toFile(); + try + { + ClientFactory.saveAs(client, file, null, EnumSet.of(SaveFlag.BINARY, SaveFlag.COMPRESSED)); + return Files.readAllBytes(file.toPath()); + } + finally + { + Files.deleteIfExists(file.toPath()); + } + } + + private Client loadProtobuf(byte[] bytes) throws Exception + { + File file = Files.createTempFile("ledger-import-insert-action", ".portfolio").toFile(); + try + { + Files.write(file.toPath(), bytes); + return ClientFactory.load(file, null, new NullProgressMonitor()); + } + finally + { + Files.deleteIfExists(file.toPath()); + } + } + @Test public void testPortfolioTransactionAttributes() throws IntrospectionException { diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/datatransfer/actions/LedgerImportWriteGuardrailTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/datatransfer/actions/LedgerImportWriteGuardrailTest.java new file mode 100644 index 0000000000..79c20adbd0 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/datatransfer/actions/LedgerImportWriteGuardrailTest.java @@ -0,0 +1,423 @@ +package name.abuchen.portfolio.datatransfer.actions; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.math.BigDecimal; +import java.nio.file.Files; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.EnumSet; +import java.util.List; + +import org.eclipse.core.runtime.NullProgressMonitor; +import org.junit.Test; + +import name.abuchen.portfolio.junit.TestCurrencyConverter; +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.BuySellEntry; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.ClientFactory; +import name.abuchen.portfolio.model.InvestmentPlan; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.PortfolioTransaction; +import name.abuchen.portfolio.model.PortfolioTransaction.Type; +import name.abuchen.portfolio.model.SaveFlag; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.SecurityPrice; +import name.abuchen.portfolio.model.Transaction.Unit; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerParameter; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerDeliveryTransactionCreator; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.Values; + +/** + * Tests import updates for generated ledger-backed transactions. + * These tests make sure matching imports update existing ledger truth and rejected imports leave the booking unchanged. + */ +@SuppressWarnings("nls") +public class LedgerImportWriteGuardrailTest +{ + /** + * Verifies that an imported buy updates a plan-generated ledger-backed buy. + * The existing ledger entry and plan reference must survive while shares, note, source, fees, and taxes change. + */ + @Test + public void testInvestmentPlanImportUpdatesLedgerBackedGeneratedBuyThroughLedgerTruth() throws Exception + { + var fixture = buyPlanFixture(true); + var generatedProjection = fixture.generatedProjection(); + LedgerEntry generatedEntry = ((LedgerBackedTransaction) generatedProjection).getLedgerEntry(); + var entryUUID = generatedEntry.getUUID(); + var executionRefs = List.copyOf(fixture.plan().getLedgerExecutionRefs()); + var accountProjection = generatedProjection.getCrossEntry().getCrossTransaction(generatedProjection); + var accountProjectionUUID = accountProjection.getUUID(); + var portfolioProjectionUUID = generatedProjection.getUUID(); + var imported = importedBuy(fixture.security(), generatedProjection.getDateTime().plusDays(2), + generatedProjection.getAmount() + Values.Amount.factorize(1), + generatedProjection.getShares() + Values.Share.factorize(0.5)); + + InsertAction action = new InsertAction(fixture.client()); + action.setInvestmentPlanItem(true); + + action.process(imported, fixture.otherAccount(), fixture.otherPortfolio()); + + assertThat(fixture.client().getLedger().getEntries().size(), is(1)); + assertThat(generatedEntry.getUUID(), is(entryUUID)); + assertThat(fixture.account().getTransactions().size(), is(1)); + assertThat(fixture.portfolio().getTransactions().size(), is(1)); + assertThat(fixture.otherAccount().getTransactions().isEmpty(), is(true)); + assertThat(fixture.otherPortfolio().getTransactions().isEmpty(), is(true)); + assertThat(accountProjection.getUUID(), is(accountProjectionUUID)); + assertThat(generatedProjection.getUUID(), is(portfolioProjectionUUID)); + assertThat(fixture.plan().getLedgerExecutionRefs(), is(executionRefs)); + assertThat(generatedProjection.getDateTime(), is(imported.getPortfolioTransaction().getDateTime())); + assertThat(generatedProjection.getAmount(), is(imported.getPortfolioTransaction().getAmount())); + assertThat(generatedProjection.getCurrencyCode(), is(imported.getPortfolioTransaction().getCurrencyCode())); + assertThat(generatedProjection.getSecurity(), is(fixture.security())); + assertThat(generatedProjection.getShares(), is(imported.getPortfolioTransaction().getShares())); + assertThat(generatedProjection.getNote(), is("imported note")); + assertThat(generatedProjection.getSource(), is("imported source")); + assertThat(generatedProjection.getUnitSum(Unit.Type.FEE), is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(2)))); + assertThat(generatedProjection.getUnitSum(Unit.Type.TAX), is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(1)))); + assertRoundtrip(fixture.client(), entryUUID, portfolioProjectionUUID, executionRefs); + assertValid(fixture.client()); + } + + /** + * Verifies that an imported buy can update a plan-generated inbound delivery. + * The delivery remains the single ledger truth and no new portfolio booking is inserted. + */ + @Test + public void testInvestmentPlanImportUpdatesLedgerBackedGeneratedDeliveryThroughLedgerTruth() throws Exception + { + var fixture = buyPlanFixture(false); + var generatedProjection = fixture.generatedProjection(); + LedgerEntry generatedEntry = ((LedgerBackedTransaction) generatedProjection).getLedgerEntry(); + var entryUUID = generatedEntry.getUUID(); + var executionRefs = List.copyOf(fixture.plan().getLedgerExecutionRefs()); + var portfolioProjectionUUID = generatedProjection.getUUID(); + var imported = importedBuy(fixture.security(), generatedProjection.getDateTime().plusDays(1), + generatedProjection.getAmount() + Values.Amount.factorize(1), + generatedProjection.getShares() + Values.Share.factorize(0.5)); + + InsertAction action = new InsertAction(fixture.client()); + action.setInvestmentPlanItem(true); + + action.process(imported, fixture.account(), fixture.otherPortfolio()); + + assertThat(fixture.client().getLedger().getEntries().size(), is(1)); + assertThat(generatedEntry.getUUID(), is(entryUUID)); + assertThat(fixture.account().getTransactions().isEmpty(), is(true)); + assertThat(fixture.portfolio().getTransactions().size(), is(1)); + assertThat(fixture.otherPortfolio().getTransactions().isEmpty(), is(true)); + assertThat(generatedProjection.getUUID(), is(portfolioProjectionUUID)); + assertThat(generatedProjection.getType(), is(Type.DELIVERY_INBOUND)); + assertThat(fixture.plan().getLedgerExecutionRefs(), is(executionRefs)); + assertThat(generatedProjection.getDateTime(), is(imported.getPortfolioTransaction().getDateTime())); + assertThat(generatedProjection.getAmount(), is(imported.getPortfolioTransaction().getAmount())); + assertThat(generatedProjection.getCurrencyCode(), is(imported.getPortfolioTransaction().getCurrencyCode())); + assertThat(generatedProjection.getSecurity(), is(fixture.security())); + assertThat(generatedProjection.getShares(), is(imported.getPortfolioTransaction().getShares())); + assertThat(generatedProjection.getNote(), is("imported note")); + assertThat(generatedProjection.getSource(), is("imported source")); + assertThat(generatedProjection.getUnitSum(Unit.Type.FEE), is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(2)))); + assertThat(generatedProjection.getUnitSum(Unit.Type.TAX), is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(1)))); + assertRoundtrip(fixture.client(), entryUUID, portfolioProjectionUUID, executionRefs); + assertValid(fixture.client()); + } + + /** + * Verifies that an imported sell is not applied to a generated buy match. + * The import path must reject before mutation when the generated booking has the wrong direction. + */ + @Test + public void testInvestmentPlanImportRejectsSellMatchAgainstGeneratedBuyBeforeMutation() throws Exception + { + var fixture = buyPlanFixture(true); + var generatedProjection = fixture.generatedProjection(); + LedgerEntry generatedEntry = ((LedgerBackedTransaction) generatedProjection).getLedgerEntry(); + var snapshot = LedgerEntrySnapshot.of(generatedEntry); + var executionRefs = List.copyOf(fixture.plan().getLedgerExecutionRefs()); + var imported = importedBuy(fixture.security(), generatedProjection.getDateTime(), generatedProjection.getAmount(), + generatedProjection.getShares()); + imported.setType(Type.SELL); + + InsertAction action = new InsertAction(fixture.client()); + action.setInvestmentPlanItem(true); + + assertThrows(UnsupportedOperationException.class, + () -> action.process(imported, fixture.account(), fixture.portfolio())); + + assertThat(fixture.plan().getLedgerExecutionRefs(), is(executionRefs)); + snapshot.assertUnchanged(generatedEntry); + assertValid(fixture.client()); + } + + /** + * Verifies that unsupported generated security transaction types are rejected before import update. + * The plan reference, ledger entry, and owner lists must remain unchanged. + */ + @Test + public void testInvestmentPlanImportRejectsUnsupportedLedgerBackedGeneratedSecurityTypeBeforeMutation() + { + var fixture = unsupportedOutboundDeliveryFixture(); + var generatedProjection = fixture.generatedProjection(); + LedgerEntry generatedEntry = ((LedgerBackedTransaction) generatedProjection).getLedgerEntry(); + var snapshot = LedgerEntrySnapshot.of(generatedEntry); + var executionRefs = List.copyOf(fixture.plan().getLedgerExecutionRefs()); + var imported = importedBuy(fixture.security(), generatedProjection.getDateTime(), generatedProjection.getAmount(), + generatedProjection.getShares()); + + InsertAction action = new InsertAction(fixture.client()); + action.setInvestmentPlanItem(true); + + assertThrows(UnsupportedOperationException.class, + () -> action.process(imported, fixture.account(), fixture.portfolio())); + + assertThat(fixture.client().getLedger().getEntries().size(), is(1)); + assertThat(fixture.plan().getLedgerExecutionRefs(), is(executionRefs)); + assertThat(fixture.portfolio().getTransactions(), is(List.of(generatedProjection))); + assertThat(fixture.account().getTransactions().isEmpty(), is(true)); + snapshot.assertUnchanged(generatedEntry); + assertValid(fixture.client()); + } + + private InvestmentPlanFixture buyPlanFixture(boolean withAccount) throws Exception + { + var client = new Client(); + var account = new Account("Account"); + var otherAccount = new Account("Other Account"); + var portfolio = new Portfolio("Portfolio"); + var otherPortfolio = new Portfolio("Other Portfolio"); + var security = new Security("Security", CurrencyUnit.EUR); + var updatedAt = Instant.now(); + + account.setUpdatedAt(updatedAt); + otherAccount.setUpdatedAt(updatedAt); + portfolio.setUpdatedAt(updatedAt); + otherPortfolio.setUpdatedAt(updatedAt); + security.setUpdatedAt(updatedAt); + client.addAccount(account); + client.addAccount(otherAccount); + client.addPortfolio(portfolio); + client.addPortfolio(otherPortfolio); + client.addSecurity(security); + portfolio.setReferenceAccount(account); + otherPortfolio.setReferenceAccount(otherAccount); + + security.addPrice(new SecurityPrice(LocalDate.now(), Values.Quote.factorize(10))); + InvestmentPlan plan = new InvestmentPlan(); + plan.setName("ledger plan"); + plan.setType(InvestmentPlan.Type.PURCHASE_OR_DELIVERY); + if (withAccount) + plan.setAccount(account); + plan.setPortfolio(portfolio); + plan.setSecurity(security); + plan.setStart(LocalDate.now().minusMonths(1)); + plan.setInterval(12); + plan.setAmount(Values.Amount.factorize(100)); + client.addPlan(plan); + + var generated = plan.generateTransactions(client, new TestCurrencyConverter()); + return new InvestmentPlanFixture(client, account, otherAccount, portfolio, otherPortfolio, security, plan, + (PortfolioTransaction) generated.get(0).getTransaction()); + } + + private InvestmentPlanFixture unsupportedOutboundDeliveryFixture() + { + var client = new Client(); + var account = new Account("Account"); + var otherAccount = new Account("Other Account"); + var portfolio = new Portfolio("Portfolio"); + var otherPortfolio = new Portfolio("Other Portfolio"); + var security = new Security("Security", CurrencyUnit.EUR); + var updatedAt = Instant.now(); + + account.setUpdatedAt(updatedAt); + otherAccount.setUpdatedAt(updatedAt); + portfolio.setUpdatedAt(updatedAt); + otherPortfolio.setUpdatedAt(updatedAt); + security.setUpdatedAt(updatedAt); + portfolio.setReferenceAccount(account); + otherPortfolio.setReferenceAccount(otherAccount); + client.addAccount(account); + client.addAccount(otherAccount); + client.addPortfolio(portfolio); + client.addPortfolio(otherPortfolio); + client.addSecurity(security); + + InvestmentPlan plan = new InvestmentPlan(); + plan.setName("unsupported ledger plan"); + plan.setType(InvestmentPlan.Type.PURCHASE_OR_DELIVERY); + plan.setPortfolio(portfolio); + plan.setSecurity(security); + plan.setStart(LocalDate.now().minusMonths(1)); + plan.setInterval(12); + client.addPlan(plan); + + var generatedProjection = new LedgerDeliveryTransactionCreator(client).create(portfolio, + Type.DELIVERY_OUTBOUND, LocalDateTime.now().minusMonths(1), Values.Amount.factorize(100), + CurrencyUnit.EUR, security, Values.Share.factorize(10), null, null, List.of(), null, null); + plan.addLedgerExecutionRef(InvestmentPlan.LedgerExecutionRef.of((LedgerBackedTransaction) generatedProjection)); + + return new InvestmentPlanFixture(client, account, otherAccount, portfolio, otherPortfolio, security, plan, + generatedProjection); + } + + private BuySellEntry importedBuy(Security security, LocalDateTime dateTime, long amount, long shares) + { + BuySellEntry imported = new BuySellEntry(); + imported.setType(Type.BUY); + imported.setDate(dateTime); + imported.setSecurity(security); + imported.setShares(shares); + imported.setCurrencyCode(CurrencyUnit.EUR); + imported.setAmount(amount); + imported.setNote("imported note"); + imported.setSource("imported source"); + imported.getPortfolioTransaction().addUnit(new Unit(Unit.Type.FEE, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(2)))); + imported.getPortfolioTransaction().addUnit(new Unit(Unit.Type.TAX, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(1)))); + return imported; + } + + private void assertRoundtrip(Client client, String entryUUID, String projectionUUID, + List executionRefs) throws Exception + { + assertLoadedRoundtrip(loadXml(saveXml(client)), entryUUID, projectionUUID, executionRefs); + assertLoadedRoundtrip(loadProtobuf(saveProtobuf(client)), entryUUID, projectionUUID, executionRefs); + } + + private void assertLoadedRoundtrip(Client client, String entryUUID, String projectionUUID, + List executionRefs) + { + assertThat(client.getLedger().getEntries().stream().filter(entry -> entryUUID.equals(entry.getUUID())).count(), + is(1L)); + assertTrue(client.getAllTransactions().stream() + .anyMatch(pair -> projectionUUID.equals(pair.getTransaction().getUUID()))); + assertThat(client.getPlans().get(0).getLedgerExecutionRefs().size(), is(executionRefs.size())); + assertThat(client.getPlans().get(0).getLedgerExecutionRefs().get(0).getLedgerEntryUUID(), + is(executionRefs.get(0).getLedgerEntryUUID())); + assertThat(client.getPlans().get(0).getLedgerExecutionRefs().get(0).getProjectionUUID(), + is(executionRefs.get(0).getProjectionUUID())); + assertThat(client.getPlans().get(0).getLedgerExecutionRefs().get(0).getProjectionRole(), + is(executionRefs.get(0).getProjectionRole())); + assertValid(client); + } + + private byte[] saveXml(Client client) throws Exception + { + File file = Files.createTempFile("ledger-investment-plan-import-update", ".xml").toFile(); + try + { + ClientFactory.save(client, file); + return Files.readAllBytes(file.toPath()); + } + finally + { + Files.deleteIfExists(file.toPath()); + } + } + + private Client loadXml(byte[] bytes) throws Exception + { + return ClientFactory.load(new ByteArrayInputStream(bytes)); + } + + private byte[] saveProtobuf(Client client) throws Exception + { + File file = Files.createTempFile("ledger-investment-plan-import-update", ".portfolio").toFile(); + try + { + ClientFactory.saveAs(client, file, null, EnumSet.of(SaveFlag.BINARY, SaveFlag.COMPRESSED)); + return Files.readAllBytes(file.toPath()); + } + finally + { + Files.deleteIfExists(file.toPath()); + } + } + + private Client loadProtobuf(byte[] bytes) throws Exception + { + File file = Files.createTempFile("ledger-investment-plan-import-update", ".portfolio").toFile(); + try + { + Files.write(file.toPath(), bytes); + return ClientFactory.load(file, null, new NullProgressMonitor()); + } + finally + { + Files.deleteIfExists(file.toPath()); + } + } + + private void assertValid(Client client) + { + if (!LedgerStructuralValidator.validate(client.getLedger()).isOK()) + throw new AssertionError(LedgerStructuralValidator.validate(client.getLedger()).getIssues().toString()); + } + + private record LedgerEntrySnapshot(LocalDateTime dateTime, String note, String source, + List postings, List projections) + { + static LedgerEntrySnapshot of(LedgerEntry entry) + { + return new LedgerEntrySnapshot(entry.getDateTime(), entry.getNote(), entry.getSource(), + entry.getPostings().stream().map(PostingSnapshot::of).toList(), + entry.getProjectionRefs().stream().map(ProjectionSnapshot::of).toList()); + } + + void assertUnchanged(LedgerEntry entry) + { + assertThat(entry.getDateTime(), is(dateTime)); + assertThat(entry.getNote(), is(note)); + assertThat(entry.getSource(), is(source)); + assertThat(entry.getPostings().stream().map(PostingSnapshot::of).toList(), is(postings)); + assertThat(entry.getProjectionRefs().stream().map(ProjectionSnapshot::of).toList(), is(projections)); + } + } + + private record PostingSnapshot(String uuid, LedgerPostingType type, long amount, String currency, Long forexAmount, + String forexCurrency, BigDecimal exchangeRate, Security security, long shares, Account account, + Portfolio portfolio, List> parameters) + { + static PostingSnapshot of(LedgerPosting posting) + { + return new PostingSnapshot(posting.getUUID(), posting.getType(), posting.getAmount(), posting.getCurrency(), + posting.getForexAmount(), posting.getForexCurrency(), posting.getExchangeRate(), + posting.getSecurity(), posting.getShares(), posting.getAccount(), posting.getPortfolio(), + List.copyOf(posting.getParameters())); + } + } + + private record ProjectionSnapshot(String uuid, LedgerProjectionRole role, Account account, Portfolio portfolio, + String primaryPostingUUID, String postingGroupUUID) + { + static ProjectionSnapshot of(LedgerProjectionRef projection) + { + return new ProjectionSnapshot(projection.getUUID(), projection.getRole(), projection.getAccount(), + projection.getPortfolio(), projection.getPrimaryPostingUUID(), + projection.getPostingGroupUUID()); + } + } + + private record InvestmentPlanFixture(Client client, Account account, Account otherAccount, Portfolio portfolio, + Portfolio otherPortfolio, Security security, InvestmentPlan plan, + PortfolioTransaction generatedProjection) + { + } +} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/InvestmentPlanTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/InvestmentPlanTest.java index cc2546171e..b05db17b10 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/InvestmentPlanTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/InvestmentPlanTest.java @@ -1,11 +1,18 @@ package name.abuchen.portfolio.model; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.instanceOf; import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.Month; @@ -20,12 +27,19 @@ import name.abuchen.portfolio.junit.SecurityBuilder; import name.abuchen.portfolio.junit.TestCurrencyConverter; import name.abuchen.portfolio.model.Transaction.Unit; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; import name.abuchen.portfolio.money.CurrencyUnit; import name.abuchen.portfolio.money.Money; import name.abuchen.portfolio.money.Values; import name.abuchen.portfolio.util.TradeCalendar; import name.abuchen.portfolio.util.TradeCalendarManager; +/** + * Tests investment plan behavior, including generated ledger-backed bookings. + * These tests make sure plans can still resolve generated transactions after ledger-aware updates and conversions. + */ @SuppressWarnings("nls") public class InvestmentPlanTest { @@ -47,6 +61,11 @@ public void setup() this.investmentPlan.setInterval(1); } + /** + * Checks the generated booking scenario: generation of buy transaction. + * The plan reference must resolve to the intended transaction after the operation. + * This prevents stale or ambiguous generated booking references. + */ @Test public void testGenerationOfBuyTransaction() throws IOException { @@ -100,6 +119,11 @@ public void testGenerationOfBuyTransaction() throws IOException assertThat(newlyGenerated.get(0).getTransaction().getDateTime(), is(LocalDateTime.parse("2016-05-31T00:00"))); } + /** + * Checks the generated booking scenario: generation of delivery transaction. + * The plan reference must resolve to the intended transaction after the operation. + * This prevents stale or ambiguous generated booking references. + */ @Test public void testGenerationOfDeliveryTransaction() throws IOException { @@ -138,6 +162,11 @@ public void testGenerationOfDeliveryTransaction() throws IOException assertThat(((PortfolioTransaction) tx.get(1)).getType(), is(PortfolioTransaction.Type.DELIVERY_INBOUND)); } + /** + * Checks the generated booking scenario: generation of deposit transaction. + * The plan reference must resolve to the intended transaction after the operation. + * This prevents stale or ambiguous generated booking references. + */ @Test public void testGenerationOfDepositTransaction() throws IOException { @@ -176,6 +205,11 @@ public void testGenerationOfDepositTransaction() throws IOException assertThat(((AccountTransaction) tx.get(1)).getType(), is(AccountTransaction.Type.DEPOSIT)); } + /** + * Checks the generated booking scenario: generation of removal transaction. + * The plan reference must resolve to the intended transaction after the operation. + * This prevents stale or ambiguous generated booking references. + */ @Test public void testGenerationOfRemovalTransaction() throws IOException { @@ -204,6 +238,11 @@ public void testGenerationOfRemovalTransaction() throws IOException is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(100)))); } + /** + * Checks the generated booking scenario: generation of interest transaction. + * The plan reference must resolve to the intended transaction after the operation. + * This prevents stale or ambiguous generated booking references. + */ @Test public void testGenerationOfInterestTransaction() throws IOException { @@ -235,6 +274,11 @@ public void testGenerationOfInterestTransaction() throws IOException is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(10)))); } + /** + * Checks the generated booking scenario: no generation with start in future. + * The plan reference must resolve to the intended transaction after the operation. + * This prevents stale or ambiguous generated booking references. + */ @Test public void testNoGenerationWithStartInFuture() throws IOException { @@ -290,6 +334,11 @@ public void testErrorMessageWhenNoQuotesExist() throws IOException investmentPlan.generateTransactions(new TestCurrencyConverter()); } + /** + * Checks the generated booking scenario: generation of weekly buy transaction. + * The plan reference must resolve to the intended transaction after the operation. + * This prevents stale or ambiguous generated booking references. + */ @Test public void testGenerationOfWeeklyBuyTransaction() throws IOException { @@ -359,6 +408,11 @@ public void testGenerationOfWeeklyBuyTransaction() throws IOException assertThat(newlyGenerated.get(2).getTransaction().getDateTime(), is(LocalDateTime.parse("2016-04-08T00:00"))); } + /** + * Checks the generated booking scenario: weekly from18 march2024. + * The plan reference must resolve to the intended transaction after the operation. + * This prevents stale or ambiguous generated booking references. + */ @Test public void testWeeklyFrom18March2024() throws IOException { @@ -380,4 +434,440 @@ public void testWeeklyFrom18March2024() throws IOException assertThat(tx.get(3).getDateTime(), is(LocalDateTime.parse("2024-04-08T00:00"))); } + /** + * Checks the generated booking scenario: ledger generation of buy stores portfolio execution ref. + * The plan reference must resolve to the intended transaction after the operation. + * This prevents stale or ambiguous generated booking references. + */ + @Test + public void testLedgerGenerationOfBuyStoresPortfolioExecutionRef() throws IOException + { + investmentPlan.setName("Buy Plan"); + investmentPlan.setType(InvestmentPlan.Type.PURCHASE_OR_DELIVERY); + investmentPlan.setAccount(account); + investmentPlan.setPortfolio(portfolio); + investmentPlan.setSecurity(security); + investmentPlan.setFees(Values.Amount.factorize(1)); + investmentPlan.setTaxes(Values.Amount.factorize(2)); + investmentPlan.setStart(LocalDate.now().minusMonths(1)); + investmentPlan.setInterval(12); + + List> generated = investmentPlan.generateTransactions(client, new TestCurrencyConverter()); + + assertThat(generated, hasSize(1)); + assertThat(investmentPlan.getTransactions(), hasSize(0)); + assertThat(investmentPlan.getLedgerExecutionRefs(), hasSize(1)); + assertThat(client.getLedger().getEntries(), hasSize(1)); + assertThat(account.getTransactions(), hasSize(1)); + assertThat(portfolio.getTransactions(), hasSize(1)); + assertThat(client.getAllTransactions(), hasSize(1)); + + var transaction = generated.get(0).getTransaction(); + assertThat(transaction, instanceOf(LedgerBackedTransaction.class)); + assertThat(transaction, instanceOf(PortfolioTransaction.class)); + assertThat(((PortfolioTransaction) transaction).getType(), is(PortfolioTransaction.Type.BUY)); + assertThat(transaction.getUnitSum(Unit.Type.FEE), is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(1)))); + assertThat(transaction.getUnitSum(Unit.Type.TAX), is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(2)))); + + var ledgerBacked = (LedgerBackedTransaction) transaction; + var ref = investmentPlan.getLedgerExecutionRefs().get(0); + assertThat(ref.getLedgerEntryUUID(), is(ledgerBacked.getLedgerEntry().getUUID())); + assertThat(ref.getProjectionUUID(), is(ledgerBacked.getLedgerProjectionRef().getUUID())); + assertThat(ref.getProjectionRole(), is(LedgerProjectionRole.PORTFOLIO)); + assertThat(investmentPlan.getTransactions(client).get(0).getTransaction(), is(transaction)); + assertValidLedger(); + } + + /** + * Checks the generated booking scenario: ledger generation of delivery stores portfolio execution ref. + * The plan reference must resolve to the intended transaction after the operation. + * This prevents stale or ambiguous generated booking references. + */ + @Test + public void testLedgerGenerationOfDeliveryStoresPortfolioExecutionRef() throws IOException + { + investmentPlan.setName("Delivery Plan"); + investmentPlan.setType(InvestmentPlan.Type.PURCHASE_OR_DELIVERY); + investmentPlan.setPortfolio(portfolio); + investmentPlan.setSecurity(security); + investmentPlan.setStart(LocalDate.now().minusMonths(1)); + investmentPlan.setInterval(12); + + List> generated = investmentPlan.generateTransactions(client, new TestCurrencyConverter()); + + assertThat(generated, hasSize(1)); + assertThat(investmentPlan.getTransactions(), hasSize(0)); + assertThat(investmentPlan.getLedgerExecutionRefs(), hasSize(1)); + assertThat(client.getLedger().getEntries(), hasSize(1)); + assertThat(portfolio.getTransactions(), hasSize(1)); + assertThat(client.getAllTransactions(), hasSize(1)); + + var transaction = generated.get(0).getTransaction(); + assertThat(transaction, instanceOf(LedgerBackedTransaction.class)); + assertThat(transaction, instanceOf(PortfolioTransaction.class)); + assertThat(((PortfolioTransaction) transaction).getType(), is(PortfolioTransaction.Type.DELIVERY_INBOUND)); + + var ledgerBacked = (LedgerBackedTransaction) transaction; + var ref = investmentPlan.getLedgerExecutionRefs().get(0); + assertThat(ref.getLedgerEntryUUID(), is(ledgerBacked.getLedgerEntry().getUUID())); + assertThat(ref.getProjectionUUID(), is(ledgerBacked.getLedgerProjectionRef().getUUID())); + assertThat(ref.getProjectionRole(), is(LedgerProjectionRole.DELIVERY_INBOUND)); + assertThat(investmentPlan.getTransactions(client).get(0).getTransaction(), is(transaction)); + assertValidLedger(); + } + + /** + * Checks the generated booking scenario: ledger generation of account only stores account execution ref. + * The plan reference must resolve to the intended transaction after the operation. + * This prevents stale or ambiguous generated booking references. + */ + @Test + public void testLedgerGenerationOfAccountOnlyStoresAccountExecutionRef() throws IOException + { + investmentPlan.setName("Interest Plan"); + investmentPlan.setType(InvestmentPlan.Type.INTEREST); + investmentPlan.setAccount(account); + investmentPlan.setAmount(Values.Amount.factorize(200)); + investmentPlan.setTaxes(Values.Amount.factorize(10)); + investmentPlan.setStart(LocalDate.now().minusMonths(1)); + investmentPlan.setInterval(12); + + List> generated = investmentPlan.generateTransactions(client, new TestCurrencyConverter()); + + assertThat(generated, hasSize(1)); + assertThat(investmentPlan.getTransactions(), hasSize(0)); + assertThat(investmentPlan.getLedgerExecutionRefs(), hasSize(1)); + assertThat(client.getLedger().getEntries(), hasSize(1)); + assertThat(account.getTransactions(), hasSize(1)); + assertThat(client.getAllTransactions(), hasSize(1)); + + var transaction = generated.get(0).getTransaction(); + assertThat(transaction, instanceOf(LedgerBackedTransaction.class)); + assertThat(transaction, instanceOf(AccountTransaction.class)); + assertThat(((AccountTransaction) transaction).getType(), is(AccountTransaction.Type.INTEREST)); + assertThat(transaction.getMonetaryAmount(), is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(200)))); + assertThat(transaction.getUnitSum(Unit.Type.TAX), is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(10)))); + + var ledgerBacked = (LedgerBackedTransaction) transaction; + var ref = investmentPlan.getLedgerExecutionRefs().get(0); + assertThat(ref.getLedgerEntryUUID(), is(ledgerBacked.getLedgerEntry().getUUID())); + assertThat(ref.getProjectionUUID(), is(ledgerBacked.getLedgerProjectionRef().getUUID())); + assertThat(ref.getProjectionRole(), is(LedgerProjectionRole.ACCOUNT)); + assertThat(investmentPlan.getTransactions(client).get(0).getTransaction(), is(transaction)); + assertValidLedger(); + } + + /** + * Checks the generated booking scenario: ledger generation of deposit without units stores account execution ref. + * The plan reference must resolve to the intended transaction after the operation. + * This prevents stale or ambiguous generated booking references. + */ + @Test + public void testLedgerGenerationOfDepositWithoutUnitsStoresAccountExecutionRef() throws IOException + { + investmentPlan.setName("Deposit Plan"); + investmentPlan.setType(InvestmentPlan.Type.DEPOSIT); + investmentPlan.setAccount(account); + investmentPlan.setAmount(Values.Amount.factorize(200)); + investmentPlan.setStart(LocalDate.now().minusMonths(1)); + investmentPlan.setInterval(12); + + List> generated = investmentPlan.generateTransactions(client, new TestCurrencyConverter()); + + assertThat(generated, hasSize(1)); + assertThat(investmentPlan.getTransactions(), hasSize(0)); + assertThat(investmentPlan.getLedgerExecutionRefs(), hasSize(1)); + assertThat(client.getLedger().getEntries(), hasSize(1)); + assertThat(account.getTransactions(), hasSize(1)); + assertThat(client.getAllTransactions(), hasSize(1)); + + var transaction = generated.get(0).getTransaction(); + assertThat(transaction, instanceOf(LedgerBackedTransaction.class)); + assertThat(transaction, instanceOf(AccountTransaction.class)); + assertThat(((AccountTransaction) transaction).getType(), is(AccountTransaction.Type.DEPOSIT)); + assertThat(transaction.getMonetaryAmount(), is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(200)))); + + var ledgerBacked = (LedgerBackedTransaction) transaction; + var ref = investmentPlan.getLedgerExecutionRefs().get(0); + assertThat(ref.getLedgerEntryUUID(), is(ledgerBacked.getLedgerEntry().getUUID())); + assertThat(ref.getProjectionUUID(), is(ledgerBacked.getLedgerProjectionRef().getUUID())); + assertThat(ref.getProjectionRole(), is(LedgerProjectionRole.ACCOUNT)); + assertValidLedger(); + } + + /** + * Checks the generated booking scenario: ledger generation rejects unsupported deposit units before mutation. + * The plan reference must resolve to the intended transaction after the operation. + * This prevents stale or ambiguous generated booking references. + */ + @Test + public void testLedgerGenerationRejectsUnsupportedDepositUnitsBeforeMutation() + { + assertUnsupportedAccountOnlyUnitsRejectedBeforeMutation(InvestmentPlan.Type.DEPOSIT); + } + + /** + * Checks the generated booking scenario: ledger generation rejects unsupported removal units before mutation. + * The plan reference must resolve to the intended transaction after the operation. + * This prevents stale or ambiguous generated booking references. + */ + @Test + public void testLedgerGenerationRejectsUnsupportedRemovalUnitsBeforeMutation() + { + assertUnsupportedAccountOnlyUnitsRejectedBeforeMutation(InvestmentPlan.Type.REMOVAL); + } + + /** + * Checks the generated booking scenario: ledger generation is idempotent and removal clears execution ref. + * The plan reference must resolve to the intended transaction after the operation. + * This prevents stale or ambiguous generated booking references. + */ + @Test + public void testLedgerGenerationIsIdempotentAndRemovalClearsExecutionRef() throws IOException + { + investmentPlan.setName("Idempotent Plan"); + investmentPlan.setType(InvestmentPlan.Type.PURCHASE_OR_DELIVERY); + investmentPlan.setAccount(account); + investmentPlan.setPortfolio(portfolio); + investmentPlan.setSecurity(security); + investmentPlan.setStart(LocalDate.now().minusMonths(1)); + investmentPlan.setInterval(12); + + List> generated = investmentPlan.generateTransactions(client, new TestCurrencyConverter()); + investmentPlan.generateTransactions(client, new TestCurrencyConverter()); + var generatedDate = generated.get(0).getTransaction().getDateTime().toLocalDate(); + + assertThat(client.getLedger().getEntries(), hasSize(1)); + assertThat(account.getTransactions(), hasSize(1)); + assertThat(portfolio.getTransactions(), hasSize(1)); + assertThat(investmentPlan.getLedgerExecutionRefs(), hasSize(1)); + assertTrue(investmentPlan.getLastDate().isEmpty()); + assertThat(investmentPlan.getLastDate(client).orElseThrow(), is(generatedDate)); + assertTrue(investmentPlan.getDateOfNextTransactionToBeGenerated(client).isAfter(generatedDate)); + + investmentPlan.removeTransaction((PortfolioTransaction) generated.get(0).getTransaction()); + + assertThat(investmentPlan.getLedgerExecutionRefs(), hasSize(0)); + assertThat(client.getLedger().getEntries(), hasSize(1)); + assertThat(portfolio.getTransactions(), hasSize(1)); + } + + /** + * Checks the generated booking scenario: ledger deletion clears execution refs before protobuf roundtrip. + * The plan reference must resolve to the intended transaction after the operation. + * This prevents stale or ambiguous generated booking references. + */ + @Test + public void testLedgerDeletionClearsExecutionRefsBeforeProtobufRoundtrip() throws IOException + { + investmentPlan.setName("Deleted Ledger Plan"); + investmentPlan.setType(InvestmentPlan.Type.PURCHASE_OR_DELIVERY); + investmentPlan.setAccount(account); + investmentPlan.setPortfolio(portfolio); + investmentPlan.setSecurity(security); + investmentPlan.setStart(LocalDate.now().minusMonths(1)); + investmentPlan.setInterval(12); + client.addPlan(investmentPlan); + + InvestmentPlan unrelatedPlan = new InvestmentPlan(); + unrelatedPlan.setName("Unrelated Ledger Plan"); + unrelatedPlan.setType(InvestmentPlan.Type.PURCHASE_OR_DELIVERY); + unrelatedPlan.setAccount(account); + unrelatedPlan.setPortfolio(portfolio); + unrelatedPlan.setSecurity(security); + unrelatedPlan.setStart(LocalDate.now().minusMonths(1)); + unrelatedPlan.setInterval(12); + unrelatedPlan.setAmount(Values.Amount.factorize(100)); + client.addPlan(unrelatedPlan); + + var generated = investmentPlan.generateTransactions(client, new TestCurrencyConverter()); + var unrelatedGenerated = unrelatedPlan.generateTransactions(client, new TestCurrencyConverter()); + var deletedPortfolioProjection = (PortfolioTransaction) generated.get(0).getTransaction(); + var deletedLedgerProjection = (LedgerBackedTransaction) deletedPortfolioProjection; + var deletedEntryUUID = deletedLedgerProjection.getLedgerEntry().getUUID(); + var accountProjection = (AccountTransaction) deletedPortfolioProjection.getCrossEntry() + .getCrossTransaction(deletedPortfolioProjection); + var unrelatedEntryUUID = ((LedgerBackedTransaction) unrelatedGenerated.get(0).getTransaction()).getLedgerEntry() + .getUUID(); + + account.deleteTransaction(accountProjection, client); + + assertFalse(client.getLedger().getEntries().stream().anyMatch(entry -> entry.getUUID().equals(deletedEntryUUID))); + assertThat(client.getLedger().getEntries().stream().filter(entry -> entry.getUUID().equals(unrelatedEntryUUID)) + .count(), is(1L)); + assertThat(account.getTransactions(), hasSize(1)); + assertThat(portfolio.getTransactions(), hasSize(1)); + assertFalse(account.getTransactions().stream() + .anyMatch(transaction -> transaction.getUUID().equals(accountProjection.getUUID()))); + assertFalse(portfolio.getTransactions().stream() + .anyMatch(transaction -> transaction.getUUID().equals(deletedPortfolioProjection.getUUID()))); + assertThat(investmentPlan.getLedgerExecutionRefs(), hasSize(0)); + assertThat(investmentPlan.getTransactions(client), hasSize(0)); + assertThat(unrelatedPlan.getLedgerExecutionRefs(), hasSize(1)); + assertThat(unrelatedPlan.getTransactions(client), hasSize(1)); + + String xml = ClientTestUtilities.toString(client); + assertFalse(xml.contains(deletedEntryUUID)); + Client xmlLoaded = ClientFactory.load(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); + assertThat(xmlLoaded.getPlans().get(0).getLedgerExecutionRefs(), hasSize(0)); + assertThat(xmlLoaded.getPlans().get(0).getTransactions(xmlLoaded), hasSize(0)); + assertThat(xmlLoaded.getPlans().get(1).getLedgerExecutionRefs(), hasSize(1)); + assertFalse(xmlLoaded.getAccounts().get(0).getTransactions().stream() + .anyMatch(transaction -> transaction.getUUID().equals(accountProjection.getUUID()))); + assertFalse(xmlLoaded.getPortfolios().get(0).getTransactions().stream() + .anyMatch(transaction -> transaction.getUUID().equals(deletedPortfolioProjection.getUUID()))); + assertFalse(xmlLoaded.getLedger().getEntries().stream().anyMatch(entry -> entry.getUUID().equals(deletedEntryUUID))); + + Client loaded = loadProtobuf(client); + assertThat(loaded.getPlans().get(0).getLedgerExecutionRefs(), hasSize(0)); + assertThat(loaded.getPlans().get(0).getTransactions(loaded), hasSize(0)); + assertThat(loaded.getPlans().get(1).getLedgerExecutionRefs(), hasSize(1)); + assertFalse(loaded.getLedger().getEntries().stream().anyMatch(entry -> entry.getUUID().equals(deletedEntryUUID))); + } + + /** + * Checks the generated booking scenario: owner delete ledger backed account only transaction removes ledger truth before xml roundtrip. + * The plan reference must resolve to the intended transaction after the operation. + * This prevents stale or ambiguous generated booking references. + */ + @Test + public void testOwnerDeleteLedgerBackedAccountOnlyTransactionRemovesLedgerTruthBeforeXmlRoundtrip() throws IOException + { + investmentPlan.setName("Deleted Account Plan"); + investmentPlan.setType(InvestmentPlan.Type.INTEREST); + investmentPlan.setAccount(account); + investmentPlan.setAmount(Values.Amount.factorize(200)); + investmentPlan.setTaxes(Values.Amount.factorize(10)); + investmentPlan.setStart(LocalDate.now().minusMonths(1)); + investmentPlan.setInterval(12); + client.addPlan(investmentPlan); + + var generated = investmentPlan.generateTransactions(client, new TestCurrencyConverter()); + var deletedProjection = (AccountTransaction) generated.get(0).getTransaction(); + var deletedEntryUUID = ((LedgerBackedTransaction) deletedProjection).getLedgerEntry().getUUID(); + + account.deleteTransaction(deletedProjection, client); + + assertThat(account.getTransactions(), hasSize(0)); + assertThat(client.getLedger().getEntries(), hasSize(0)); + assertThat(investmentPlan.getLedgerExecutionRefs(), hasSize(0)); + assertThat(investmentPlan.getTransactions(client), hasSize(0)); + + String xml = ClientTestUtilities.toString(client); + assertFalse(xml.contains(deletedEntryUUID)); + + Client loaded = ClientFactory.load(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); + + assertThat(loaded.getLedger().getEntries(), hasSize(0)); + assertThat(loaded.getAccounts().get(0).getTransactions(), hasSize(0)); + assertThat(loaded.getPlans().get(0).getLedgerExecutionRefs(), hasSize(0)); + assertThat(loaded.getPlans().get(0).getTransactions(loaded), hasSize(0)); + } + + /** + * Checks the generated booking scenario: generated ledger execution refs survive xml and protobuf roundtrip. + * The plan reference must resolve to the intended transaction after the operation. + * This prevents stale or ambiguous generated booking references. + */ + @Test + public void testGeneratedLedgerExecutionRefsSurviveXmlAndProtobufRoundtrip() throws IOException + { + investmentPlan.setName("Roundtrip Plan"); + investmentPlan.setType(InvestmentPlan.Type.PURCHASE_OR_DELIVERY); + investmentPlan.setAccount(account); + investmentPlan.setPortfolio(portfolio); + investmentPlan.setSecurity(security); + investmentPlan.setStart(LocalDate.now().minusMonths(1)); + investmentPlan.setInterval(12); + client.addPlan(investmentPlan); + + var generated = investmentPlan.generateTransactions(client, new TestCurrencyConverter()); + var expected = (LedgerBackedTransaction) generated.get(0).getTransaction(); + + String xml = ClientTestUtilities.toString(client); + assertThat(xml, containsString("")); + assertFalse(xml.contains(" investmentPlan.getTransactions(client)); + } + + private void assertGeneratedPlanRefRoundtrip(Client loaded, LedgerBackedTransaction expected) + { + var loadedPlan = loaded.getPlans().get(0); + assertThat(loadedPlan.getTransactions(), hasSize(0)); + assertThat(loadedPlan.getLedgerExecutionRefs(), hasSize(1)); + + var ref = loadedPlan.getLedgerExecutionRefs().get(0); + assertThat(ref.getLedgerEntryUUID(), is(expected.getLedgerEntry().getUUID())); + assertThat(ref.getProjectionUUID(), is(expected.getLedgerProjectionRef().getUUID())); + assertThat(ref.getProjectionRole(), is(LedgerProjectionRole.PORTFOLIO)); + + var resolved = loadedPlan.getTransactions(loaded).get(0).getTransaction(); + assertThat(resolved, instanceOf(LedgerBackedTransaction.class)); + assertThat(resolved.getUUID(), is(expected.getLedgerProjectionRef().getUUID())); + assertThat(((PortfolioTransaction) resolved).getType(), is(PortfolioTransaction.Type.BUY)); + assertThat(loaded.getLedger().getEntries(), hasSize(1)); + assertThat(loaded.getAllTransactions(), hasSize(1)); + assertTrue(LedgerStructuralValidator.validate(loaded.getLedger()).isOK()); + } + + private void assertUnsupportedAccountOnlyUnitsRejectedBeforeMutation(InvestmentPlan.Type type) + { + investmentPlan.setName("Unsupported Account Plan"); + investmentPlan.setType(type); + investmentPlan.setAccount(account); + investmentPlan.setTaxes(Values.Amount.factorize(10)); + investmentPlan.setStart(LocalDate.now().minusMonths(1)); + investmentPlan.setInterval(12); + + assertThrows(InvestmentPlan.UnsupportedLedgerGenerationException.class, + () -> investmentPlan.generateTransactions(client, new TestCurrencyConverter())); + assertThat(client.getLedger().getEntries(), hasSize(0)); + assertThat(account.getTransactions(), hasSize(0)); + assertThat(investmentPlan.getLedgerExecutionRefs(), hasSize(0)); + } + + private Client loadProtobuf(Client source) throws IOException + { + var stream = new ByteArrayOutputStream(); + new ProtobufWriter().save(source, stream); + return new ProtobufWriter().load(new ByteArrayInputStream(stream.toByteArray())); + } + + private void assertValidLedger() + { + var result = LedgerStructuralValidator.validate(client.getLedger()); + assertTrue(result.getIssues().toString(), result.isOK()); + } + } diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerInvestmentPlanRefSupportTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerInvestmentPlanRefSupportTest.java new file mode 100644 index 0000000000..df50ed2ef2 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerInvestmentPlanRefSupportTest.java @@ -0,0 +1,223 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertThrows; + +import org.junit.Test; + +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.InvestmentPlan; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; + +/** + * Tests helper rules used when generated transfer bookings are split. + * These tests make sure plan references are moved only when the source or target side is unambiguous. + */ +@SuppressWarnings("nls") +public class LedgerInvestmentPlanRefSupportTest +{ + /** + * Verifies that a plan reference known as the transfer source follows the new removal booking. + * The planned update must point to the removal side before the converter applies the split. + */ + @Test + public void testSourceRoleOnlyExecutionRefMapsToRemovalProjectionUpdate() + { + var fixture = fixture(); + var plan = planWithRef(fixture.client(), + new InvestmentPlan.LedgerExecutionRef(fixture.transferEntry().getUUID(), null, + LedgerProjectionRole.SOURCE_ACCOUNT)); + + var updates = prepareSplitUpdates(fixture); + + assertExecutionRef(plan, fixture.transferEntry().getUUID(), null, LedgerProjectionRole.SOURCE_ACCOUNT); + + updates.apply(); + + assertExecutionRef(plan, fixture.removalEntry().getUUID(), fixture.removalProjection().getUUID(), + LedgerProjectionRole.ACCOUNT); + } + + /** + * Verifies that a plan reference known as the transfer target follows the new deposit booking. + * The planned update must point to the deposit side before the converter applies the split. + */ + @Test + public void testTargetRoleOnlyExecutionRefMapsToDepositProjectionUpdate() + { + var fixture = fixture(); + var plan = planWithRef(fixture.client(), + new InvestmentPlan.LedgerExecutionRef(fixture.transferEntry().getUUID(), null, + LedgerProjectionRole.TARGET_ACCOUNT)); + + var updates = prepareSplitUpdates(fixture); + + assertExecutionRef(plan, fixture.transferEntry().getUUID(), null, LedgerProjectionRole.TARGET_ACCOUNT); + + updates.apply(); + + assertExecutionRef(plan, fixture.depositEntry().getUUID(), fixture.depositProjection().getUUID(), + LedgerProjectionRole.ACCOUNT); + } + + /** + * Verifies that a plan reference to the old source-side booking continues as a removal. + * The prepared update must replace the old transfer projection with the new removal projection. + */ + @Test + public void testSourceProjectionExecutionRefMapsToRemovalProjectionUpdate() + { + var fixture = fixture(); + var plan = planWithRef(fixture.client(), + new InvestmentPlan.LedgerExecutionRef(fixture.transferEntry().getUUID(), + fixture.sourceProjection().getUUID(), LedgerProjectionRole.SOURCE_ACCOUNT)); + + prepareSplitUpdates(fixture).apply(); + + assertExecutionRef(plan, fixture.removalEntry().getUUID(), fixture.removalProjection().getUUID(), + LedgerProjectionRole.ACCOUNT); + } + + /** + * Verifies that an unspecific plan reference blocks a transfer split. + * Without a source or target side, the generated booking cannot be continued as either removal or deposit. + */ + @Test + public void testEntryOnlyExecutionRefRejectsSplitMapping() + { + var fixture = fixture(); + var plan = planWithRef(fixture.client(), + new InvestmentPlan.LedgerExecutionRef(fixture.transferEntry().getUUID(), null, null)); + + var exception = assertThrows(UnsupportedOperationException.class, () -> prepareSplitUpdates(fixture)); + + assertThat(exception.getMessage(), is(LedgerDiagnosticCode.LEDGER_CONVERT_045 + .message("Ledger plan reference cannot be mapped to a split transfer side"))); + assertExecutionRef(plan, fixture.transferEntry().getUUID(), null, null); + } + + /** + * Verifies that a contradictory plan reference is rejected before the split can proceed. + * A reference may not point to the source-side booking and be treated as the target side. + */ + @Test + public void testConflictingProjectionRoleRejectsSplitMapping() + { + var fixture = fixture(); + var plan = planWithRef(fixture.client(), + new InvestmentPlan.LedgerExecutionRef(fixture.transferEntry().getUUID(), + fixture.sourceProjection().getUUID(), LedgerProjectionRole.TARGET_ACCOUNT)); + + var exception = assertThrows(UnsupportedOperationException.class, () -> prepareSplitUpdates(fixture)); + + assertThat(exception.getMessage(), is(LedgerDiagnosticCode.LEDGER_CONVERT_045 + .message("Ledger plan reference cannot be mapped to a split transfer side"))); + assertExecutionRef(plan, fixture.transferEntry().getUUID(), fixture.sourceProjection().getUUID(), + LedgerProjectionRole.TARGET_ACCOUNT); + } + + /** + * Verifies that an ambiguous split target is rejected before any plan reference is changed. + * The code must not choose between multiple possible removal bookings. + */ + @Test + public void testAmbiguousSplitTargetProjectionRejectsBeforeExecutionRefUpdate() + { + var fixture = fixture(); + var plan = planWithRef(fixture.client(), + new InvestmentPlan.LedgerExecutionRef(fixture.transferEntry().getUUID(), + fixture.sourceProjection().getUUID(), LedgerProjectionRole.SOURCE_ACCOUNT)); + + fixture.removalEntry().addProjectionRef(accountProjection("second-removal-projection")); + + var exception = assertThrows(UnsupportedOperationException.class, () -> prepareSplitUpdates(fixture)); + + assertThat(exception.getMessage(), is(LedgerDiagnosticCode.LEDGER_PROJ_041 + .message("Ledger plan reference cannot be mapped to a split transfer side"))); + assertExecutionRef(plan, fixture.transferEntry().getUUID(), fixture.sourceProjection().getUUID(), + LedgerProjectionRole.SOURCE_ACCOUNT); + } + + private LedgerInvestmentPlanRefSupport.SplitExecutionRefUpdates prepareSplitUpdates(Fixture fixture) + { + return LedgerInvestmentPlanRefSupport.prepareAccountTransferSplitExecutionRefUpdates(fixture.client(), + fixture.transferEntry(), fixture.sourceProjection(), fixture.targetProjection(), + fixture.removalEntry(), fixture.depositEntry()); + } + + private InvestmentPlan planWithRef(Client client, InvestmentPlan.LedgerExecutionRef ref) + { + var plan = new InvestmentPlan("Plan"); + + plan.addLedgerExecutionRef(ref); + client.addPlan(plan); + + return plan; + } + + private void assertExecutionRef(InvestmentPlan plan, String entryUUID, String projectionUUID, + LedgerProjectionRole role) + { + var ref = plan.getLedgerExecutionRefs().get(0); + + assertThat(ref.getLedgerEntryUUID(), is(entryUUID)); + assertThat(ref.getProjectionUUID(), projectionUUID == null ? nullValue() : is(projectionUUID)); + assertThat(ref.getProjectionRole(), role == null ? nullValue() : is(role)); + } + + private Fixture fixture() + { + var client = new Client(); + var transferEntry = entry("transfer-entry", LedgerEntryType.CASH_TRANSFER); + var removalEntry = entry("removal-entry", LedgerEntryType.REMOVAL); + var depositEntry = entry("deposit-entry", LedgerEntryType.DEPOSIT); + var sourceProjection = projection("source-projection", LedgerProjectionRole.SOURCE_ACCOUNT); + var targetProjection = projection("target-projection", LedgerProjectionRole.TARGET_ACCOUNT); + var removalProjection = accountProjection("removal-projection"); + var depositProjection = accountProjection("deposit-projection"); + + transferEntry.addProjectionRef(sourceProjection); + transferEntry.addProjectionRef(targetProjection); + removalEntry.addProjectionRef(removalProjection); + depositEntry.addProjectionRef(depositProjection); + + return new Fixture(client, transferEntry, sourceProjection, targetProjection, removalEntry, removalProjection, + depositEntry, depositProjection); + } + + private LedgerEntry entry(String uuid, LedgerEntryType type) + { + var entry = new LedgerEntry(uuid); + + entry.setType(type); + + return entry; + } + + private LedgerProjectionRef accountProjection(String uuid) + { + return projection(uuid, LedgerProjectionRole.ACCOUNT); + } + + private LedgerProjectionRef projection(String uuid, LedgerProjectionRole role) + { + var projection = new LedgerProjectionRef(uuid); + + projection.setRole(role); + + return projection; + } + + private record Fixture(Client client, LedgerEntry transferEntry, LedgerProjectionRef sourceProjection, + LedgerProjectionRef targetProjection, LedgerEntry removalEntry, + LedgerProjectionRef removalProjection, LedgerEntry depositEntry, + LedgerProjectionRef depositProjection) + { + } +} diff --git a/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/InvestmentPlanModelTest.java b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/InvestmentPlanModelTest.java index 29b088bf5f..48336ba838 100644 --- a/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/InvestmentPlanModelTest.java +++ b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/InvestmentPlanModelTest.java @@ -12,6 +12,10 @@ import name.abuchen.portfolio.model.Account; import name.abuchen.portfolio.model.Client; import name.abuchen.portfolio.model.InvestmentPlan; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.ExchangeRateProviderFactory; +import name.abuchen.portfolio.money.Values; +import name.abuchen.portfolio.ui.jobs.CreateInvestmentPlanTxJob; @SuppressWarnings("nls") public class InvestmentPlanModelTest @@ -51,4 +55,31 @@ public void testEditRemovalPlan() assertThat(model.getAmount(), is(100L)); assertThat(model.getCalculationStatus(), is(Status.OK_STATUS)); } + + @Test + public void testAutoGenerationJobHandlesUnsupportedLedgerPlanWithoutMutation() throws InterruptedException + { + Client client = new Client(); + Account account = new Account("Account"); + account.setCurrencyCode(CurrencyUnit.EUR); + client.addAccount(account); + + InvestmentPlan investmentPlan = new InvestmentPlan("Unsupported Plan"); + investmentPlan.setType(InvestmentPlan.Type.REMOVAL); + investmentPlan.setAccount(account); + investmentPlan.setAmount(Values.Amount.factorize(100)); + investmentPlan.setTaxes(Values.Amount.factorize(10)); + investmentPlan.setStart(LocalDateTime.now().minusMonths(1)); + investmentPlan.setInterval(12); + investmentPlan.setAutoGenerate(true); + client.addPlan(investmentPlan); + + var job = new CreateInvestmentPlanTxJob(client, new ExchangeRateProviderFactory(client)); + job.schedule(); + job.join(); + + assertThat(job.getResult().isOK(), is(true)); + assertThat(account.getTransactions().size(), is(0)); + assertThat(investmentPlan.getLedgerExecutionRefs().size(), is(0)); + } } diff --git a/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/wizards/splits/LedgerStockSplitWritePathTest.java b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/wizards/splits/LedgerStockSplitWritePathTest.java new file mode 100644 index 0000000000..c258944263 --- /dev/null +++ b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/wizards/splits/LedgerStockSplitWritePathTest.java @@ -0,0 +1,232 @@ +package name.abuchen.portfolio.ui.wizards.splits; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertThrows; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.Test; + +import name.abuchen.portfolio.model.Account; +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.SecurityEvent; +import name.abuchen.portfolio.model.SecurityPrice; +import name.abuchen.portfolio.model.Transaction; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerBuySellTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerDeliveryTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerDividendTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferTransactionCreator; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Values; + +/** + * Tests stock split write behavior for ledger-backed share transactions. + * These tests make sure split adjustments update ledger facts without changing unrelated transactions. + */ +public class LedgerStockSplitWritePathTest +{ + private static final LocalDate EX_DATE = LocalDate.of(2020, 1, 10); + private static final LocalDateTime TRANSACTION_DATE = LocalDateTime.of(2020, 1, 2, 0, 0); + + /** + * Verifies that a stock split adjusts both legacy and ledger-backed transactions for the selected security. + * Unrelated securities and non-share facts must stay unchanged. + */ + @Test + public void testApplyChangesAdjustsMixedLegacyAndLedgerBackedTransactions() + { + var client = new Client(); + var security = security(); + var otherSecurity = security(); + var account = account(); + var portfolio = portfolio(); + var targetPortfolio = portfolio(); + + client.addSecurity(security); + client.addSecurity(otherSecurity); + client.addAccount(account); + client.addPortfolio(portfolio); + client.addPortfolio(targetPortfolio); + + var legacyBuy = legacyBuy(security); + portfolio.addTransaction(legacyBuy); + var unaffectedLegacyBuy = legacyBuy(otherSecurity); + portfolio.addTransaction(unaffectedLegacyBuy); + + new LedgerBuySellTransactionCreator(client).create(portfolio, account, PortfolioTransaction.Type.BUY, + TRANSACTION_DATE, Values.Amount.factorize(100), CurrencyUnit.EUR, security, + Values.Share.factorize(10), List.of(), "note", "source"); + new LedgerDividendTransactionCreator(client).create(account, TRANSACTION_DATE, Values.Amount.factorize(5), + CurrencyUnit.EUR, security, Values.Share.factorize(10), null, null, null, List.of(), "note", + "source"); + new LedgerDeliveryTransactionCreator(client).create(portfolio, PortfolioTransaction.Type.DELIVERY_INBOUND, + TRANSACTION_DATE, Values.Amount.factorize(100), CurrencyUnit.EUR, security, + Values.Share.factorize(10), null, null, List.of(), "note", "source"); + new LedgerPortfolioTransferTransactionCreator(client).create(portfolio, targetPortfolio, security, + TRANSACTION_DATE, Values.Share.factorize(10), Values.Amount.factorize(100), CurrencyUnit.EUR, + "note", "source"); + security.addPrice(new SecurityPrice(LocalDate.of(2020, 1, 1), 100L)); + + var before = StateBefore.of(client, security); + + splitModel(client, security).applyChanges(); + + before.assertOnlySelectedSharesChanged(client, security, EX_DATE, Values.Share.factorize(20)); + + assertThat(security.getEvents().size(), is(1)); + assertThat(security.getEvents().get(0).getDate(), is(EX_DATE)); + assertThat(security.getEvents().get(0).getType(), is(SecurityEvent.Type.STOCK_SPLIT)); + assertThat(security.getPrices().get(0).getValue(), is(50L)); + assertThat(legacyBuy.getShares(), is(Values.Share.factorize(20))); + assertThat(account.getTransactions().get(1).getShares(), is(Values.Share.factorize(20))); + assertThat(portfolio.getTransactions().get(2).getShares(), is(Values.Share.factorize(20))); + assertThat(portfolio.getTransactions().get(3).getShares(), is(Values.Share.factorize(20))); + assertThat(targetPortfolio.getTransactions().get(0).getShares(), is(Values.Share.factorize(20))); + assertThat(unaffectedLegacyBuy.getShares(), is(Values.Share.factorize(10))); + } + + /** + * Verifies that an invalid ledger share adjustment is rejected before live mutation. + * The split must not add a security event or partially change legacy or ledger-backed shares. + */ + @Test + public void testApplyChangesRejectsInvalidLedgerAdjustmentBeforeLiveMutation() + { + var client = new Client(); + var security = security(); + var account = account(); + var portfolio = portfolio(); + + client.addSecurity(security); + client.addAccount(account); + client.addPortfolio(portfolio); + + var legacyBuy = legacyBuy(security); + portfolio.addTransaction(legacyBuy); + + new LedgerBuySellTransactionCreator(client).create(portfolio, account, PortfolioTransaction.Type.BUY, + TRANSACTION_DATE, Values.Amount.factorize(100), CurrencyUnit.EUR, security, + Values.Share.factorize(10), List.of(), "note", "source"); + + var before = StateBefore.of(client, security); + + var model = splitModel(client, security); + model.setNewShares(BigDecimal.valueOf(-1)); + + assertThrows(IllegalArgumentException.class, model::applyChanges); + + before.assertOnlySelectedSharesChanged(client, security, EX_DATE, Values.Share.factorize(10)); + assertThat(security.getEvents().size(), is(0)); + assertThat(legacyBuy.getShares(), is(Values.Share.factorize(10))); + } + + private StockSplitModel splitModel(Client client, Security security) + { + var model = new StockSplitModel(client, security); + model.setExDate(EX_DATE); + model.setNewShares(BigDecimal.valueOf(2)); + model.setOldShares(BigDecimal.ONE); + return model; + } + + private PortfolioTransaction legacyBuy(Security security) + { + var transaction = new PortfolioTransaction(); + transaction.setType(PortfolioTransaction.Type.BUY); + transaction.setDateTime(TRANSACTION_DATE); + transaction.setSecurity(security); + transaction.setShares(Values.Share.factorize(10)); + transaction.setAmount(Values.Amount.factorize(100)); + transaction.setCurrencyCode(security.getCurrencyCode()); + return transaction; + } + + private Account account() + { + return new Account(); + } + + private Portfolio portfolio() + { + return new Portfolio(); + } + + private Security security() + { + return new Security("Security", CurrencyUnit.EUR); + } + + private record StateBefore(List allTransactions, List accounts, + List portfolios) + { + static StateBefore of(Client client, Security security) + { + return new StateBefore(client.getAllTransactions().stream() + .map(pair -> TransactionState.of(pair.getTransaction())) + .toList(), + client.getAccounts().stream().map(OwnerState::of).toList(), + client.getPortfolios().stream().map(OwnerState::of).toList()); + } + + void assertOnlySelectedSharesChanged(Client client, Security security, LocalDate exDate, long adjustedShares) + { + assertThat(client.getAccounts().stream().map(OwnerState::of).toList(), is(accounts)); + assertThat(client.getPortfolios().stream().map(OwnerState::of).toList(), is(portfolios)); + + var current = client.getAllTransactions().stream().map(pair -> TransactionState.of(pair.getTransaction())) + .toList(); + assertThat(current.size(), is(allTransactions.size())); + + for (var before : allTransactions) + { + var after = current.stream().filter(snapshot -> snapshot.uuid().equals(before.uuid())).findFirst() + .orElseThrow(); + + assertThat(after.dateTime(), is(before.dateTime())); + assertThat(after.security(), is(before.security())); + assertThat(after.amount(), is(before.amount())); + assertThat(after.currencyCode(), is(before.currencyCode())); + assertThat(after.note(), is(before.note())); + assertThat(after.source(), is(before.source())); + + if (before.security() == security && before.dateTime().toLocalDate().isBefore(exDate)) + assertThat(after.shares(), is(adjustedShares)); + else + assertThat(after.shares(), is(before.shares())); + } + } + } + + private record OwnerState(String uuid, List transactionUUIDs) + { + static OwnerState of(Account account) + { + return new OwnerState(account.getUUID(), + account.getTransactions().stream().map(Transaction::getUUID).toList()); + } + + static OwnerState of(Portfolio portfolio) + { + return new OwnerState(portfolio.getUUID(), + portfolio.getTransactions().stream().map(Transaction::getUUID).toList()); + } + } + + private record TransactionState(String uuid, LocalDateTime dateTime, Security security, long shares, long amount, + String currencyCode, String note, String source) + { + static TransactionState of(Transaction transaction) + { + return new TransactionState(transaction.getUUID(), transaction.getDateTime(), transaction.getSecurity(), + transaction.getShares(), transaction.getAmount(), transaction.getCurrencyCode(), + transaction.getNote(), transaction.getSource()); + } + } +} diff --git a/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/wizards/splits/StockSplitModelTest.java b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/wizards/splits/StockSplitModelTest.java index f2986988dc..c80de37483 100644 --- a/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/wizards/splits/StockSplitModelTest.java +++ b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/wizards/splits/StockSplitModelTest.java @@ -4,13 +4,23 @@ import static org.hamcrest.Matchers.is; import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; import org.junit.Test; +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.SecurityEvent; +import name.abuchen.portfolio.model.SecurityPrice; import name.abuchen.portfolio.money.Values; public class StockSplitModelTest { + private static final LocalDate EX_DATE = LocalDate.of(2020, 1, 10); + private static final LocalDateTime TRANSACTION_DATE = LocalDateTime.of(2020, 1, 2, 0, 0); @Test public void testCalculateNewQuoteAndStock() @@ -89,4 +99,58 @@ public void testNonTrivialRatios() assertThat(model.calculateNewQuote(70L), is(30L)); } + @Test + public void testApplyChangesStillUpdatesLegacyTransactionsAndPrices() + { + var client = new Client(); + var security = security(); + var portfolio = portfolio(); + + client.addSecurity(security); + client.addPortfolio(portfolio); + + var legacyBuy = legacyBuy(security); + portfolio.addTransaction(legacyBuy); + security.addPrice(new SecurityPrice(LocalDate.of(2020, 1, 1), 100L)); + + splitModel(client, security).applyChanges(); + + assertThat(security.getEvents().size(), is(1)); + assertThat(security.getEvents().get(0).getDate(), is(EX_DATE)); + assertThat(security.getEvents().get(0).getType(), is(SecurityEvent.Type.STOCK_SPLIT)); + assertThat(legacyBuy.getShares(), is(Values.Share.factorize(20))); + assertThat(security.getPrices().get(0).getValue(), is(50L)); + } + + private StockSplitModel splitModel(Client client, Security security) + { + var model = new StockSplitModel(client, security); + model.setExDate(EX_DATE); + model.setNewShares(BigDecimal.valueOf(2)); + model.setOldShares(BigDecimal.ONE); + return model; + } + + private PortfolioTransaction legacyBuy(Security security) + { + var transaction = new PortfolioTransaction(); + transaction.setType(PortfolioTransaction.Type.BUY); + transaction.setDateTime(TRANSACTION_DATE); + transaction.setSecurity(security); + transaction.setShares(Values.Share.factorize(10)); + transaction.setAmount(Values.Amount.factorize(100)); + transaction.setCurrencyCode(security.getCurrencyCode()); + return transaction; + } + + private Portfolio portfolio() + { + return new Portfolio(); + } + + private Security security() + { + return new Security("Security", name.abuchen.portfolio.money.CurrencyUnit.EUR); + } + } diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/jobs/CreateInvestmentPlanTxJob.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/jobs/CreateInvestmentPlanTxJob.java index 32cee7d8c4..b1026c269d 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/jobs/CreateInvestmentPlanTxJob.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/jobs/CreateInvestmentPlanTxJob.java @@ -53,7 +53,7 @@ protected IStatus run(IProgressMonitor monitor) getClient().getPlans().stream().filter(InvestmentPlan::isAutoGenerate).forEach(plan -> { try { - List> transactions = plan.generateTransactions(converter); + List> transactions = plan.generateTransactions(getClient(), converter); if (!transactions.isEmpty()) tx.put(plan, transactions); } diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/wizards/splits/StockSplitModel.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/wizards/splits/StockSplitModel.java index 799a2a7431..69147c5ab3 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/wizards/splits/StockSplitModel.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/wizards/splits/StockSplitModel.java @@ -11,6 +11,7 @@ import name.abuchen.portfolio.model.SecurityPrice; import name.abuchen.portfolio.model.Transaction; import name.abuchen.portfolio.model.TransactionPair; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerShareAdjustmentHelper; import name.abuchen.portfolio.money.Values; import name.abuchen.portfolio.ui.util.BindingHelper; @@ -108,6 +109,15 @@ public long calculateNewQuote(long oldQuote) @Override public void applyChanges() { + List> transactions = isChangeTransactions() ? security.getTransactions(getClient()) // + : List.of(); + List affectedTransactions = transactions.stream().map(pair -> (Transaction) pair.getTransaction()) + .filter(t -> t.getDateTime().toLocalDate().isBefore(exDate)).toList(); + var ledgerShareAdjustmentPlan = isChangeTransactions() ? LedgerShareAdjustmentHelper.plan(getClient(), security, + affectedTransactions, this::calculateNewStock) : LedgerShareAdjustmentHelper.emptyPlan(); + + ledgerShareAdjustmentPlan.apply(); + // save stock split ratio as technical values (and hence do not format // in the local of user) in order to restore/retrieve ratio later SecurityEvent event = new SecurityEvent(exDate, SecurityEvent.Type.STOCK_SPLIT, newShares + ":" + oldShares); //$NON-NLS-1$ @@ -115,11 +125,9 @@ public void applyChanges() if (isChangeTransactions()) { - List> transactions = security.getTransactions(getClient()); - for (TransactionPair pair : transactions) + for (Transaction t : affectedTransactions) { - Transaction t = pair.getTransaction(); - if (t.getDateTime().toLocalDate().isBefore(exDate)) + if (!ledgerShareAdjustmentPlan.isLedgerBacked(t)) t.setShares(calculateNewStock(t.getShares())); } } diff --git a/name.abuchen.portfolio/META-INF/services/name.abuchen.portfolio.checks.Check b/name.abuchen.portfolio/META-INF/services/name.abuchen.portfolio.checks.Check index 7e90e146ff..cd4c6e966c 100644 --- a/name.abuchen.portfolio/META-INF/services/name.abuchen.portfolio.checks.Check +++ b/name.abuchen.portfolio/META-INF/services/name.abuchen.portfolio.checks.Check @@ -7,11 +7,7 @@ name.abuchen.portfolio.checks.impl.NullSecurityPricesCheck name.abuchen.portfolio.checks.impl.DanglingAccountsCheck name.abuchen.portfolio.checks.impl.MissingCurrencyCheck name.abuchen.portfolio.checks.impl.TransactionCurrencyCheck -name.abuchen.portfolio.checks.impl.FixTaxRefundsCheck name.abuchen.portfolio.checks.impl.MissingUUIDCheck name.abuchen.portfolio.checks.impl.FixOrphanedSecurtiesCheck -name.abuchen.portfolio.checks.impl.FixWrongTransfersCheck -name.abuchen.portfolio.checks.impl.FixDepositsWithSecurityCheck name.abuchen.portfolio.checks.impl.NegativeExchangeRateCheck -name.abuchen.portfolio.checks.impl.MissingTransactionDateCheck -name.abuchen.portfolio.checks.impl.OnlyAccountTransactionsWithSecurityCanHaveExDateCheck \ No newline at end of file +name.abuchen.portfolio.checks.impl.OnlyAccountTransactionsWithSecurityCanHaveExDateCheck diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/BuySellMissingSecurityIssue.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/BuySellMissingSecurityIssue.java index 16088f8b84..31529cc85f 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/BuySellMissingSecurityIssue.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/BuySellMissingSecurityIssue.java @@ -1,7 +1,6 @@ package name.abuchen.portfolio.checks.impl; import java.text.MessageFormat; -import java.util.ArrayList; import java.util.List; import name.abuchen.portfolio.Messages; @@ -26,8 +25,6 @@ public String getLabel() @Override public List getAvailableFixes() { - List answer = new ArrayList(); - answer.add(new DeleteTransactionFix(client, account, transaction)); - return answer; + return List.of(new DeleteTransactionFix(client, account, transaction)); } } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/CrossEntryCheck.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/CrossEntryCheck.java index 15be2f194f..27de1e0ef0 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/CrossEntryCheck.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/CrossEntryCheck.java @@ -1,21 +1,15 @@ package name.abuchen.portfolio.checks.impl; import java.util.ArrayList; -import java.util.HashSet; -import java.util.Iterator; import java.util.List; -import java.util.Set; import name.abuchen.portfolio.checks.Check; import name.abuchen.portfolio.checks.Issue; import name.abuchen.portfolio.model.Account; import name.abuchen.portfolio.model.AccountTransaction; -import name.abuchen.portfolio.model.AccountTransferEntry; -import name.abuchen.portfolio.model.BuySellEntry; import name.abuchen.portfolio.model.Client; import name.abuchen.portfolio.model.Portfolio; import name.abuchen.portfolio.model.PortfolioTransaction; -import name.abuchen.portfolio.model.PortfolioTransferEntry; public class CrossEntryCheck implements Check { @@ -23,333 +17,78 @@ public class CrossEntryCheck implements Check @Override public List execute(Client client) { - return new CheckImpl(client).execute(); - } - - private static class AccountEntry - { - Account account; - AccountTransaction transaction; - - private AccountEntry(Account owner, AccountTransaction transaction) - { - this.account = owner; - this.transaction = transaction; - } - } - - private static class PortfolioEntry - { - Portfolio portfolio; - PortfolioTransaction transaction; - - private PortfolioEntry(Portfolio owner, PortfolioTransaction transaction) - { - this.portfolio = owner; - this.transaction = transaction; - } - } - - private static class CheckImpl - { - private Client client; - - private List accountTransactions = new ArrayList(); - private List portfolioTransactions = new ArrayList(); - - private List issues = new ArrayList(); - - public CheckImpl(Client client) - { - this.client = client; - } - - public List execute() - { - collectAccountTransactions(); - collectPortfolioTransactions(); - - matchBuySell(); - matchAccountTransfers(); - matchPortfolioTransfers(); - - return issues; - } - - private void collectAccountTransactions() - { - for (Account account : client.getAccounts()) - { - for (AccountTransaction t : account.getTransactions()) - { - if (t.getCrossEntry() != null) - continue; - - switch (t.getType()) - { - case BUY: - case SELL: - case TRANSFER_IN: - case TRANSFER_OUT: - accountTransactions.add(new AccountEntry(account, t)); - break; - default: - break; - } - } - } - } + List issues = new ArrayList(); - private void collectPortfolioTransactions() + for (Account account : client.getAccounts()) { - for (Portfolio portfolio : client.getPortfolios()) + for (AccountTransaction transaction : account.getTransactions()) { - for (PortfolioTransaction t : portfolio.getTransactions()) - { - if (t.getCrossEntry() != null) - continue; + if (transaction.getCrossEntry() != null) + continue; - switch (t.getType()) - { - case BUY: - case SELL: - case TRANSFER_IN: - case TRANSFER_OUT: - portfolioTransactions.add(new PortfolioEntry(portfolio, t)); - break; - default: - break; - } - } + reportAccountIssue(client, account, transaction, issues); } } - private void matchBuySell() + for (Portfolio portfolio : client.getPortfolios()) { - Iterator iterAccount = accountTransactions.iterator(); - while (iterAccount.hasNext()) - { - AccountEntry suspect = iterAccount.next(); - - if (suspect.transaction.getType() != AccountTransaction.Type.BUY - && suspect.transaction.getType() != AccountTransaction.Type.SELL) - continue; - - if (suspect.transaction.getSecurity() == null) - { - issues.add(new BuySellMissingSecurityIssue(client, suspect.account, suspect.transaction)); - iterAccount.remove(); - continue; - } - - PortfolioTransaction.Type neededType = PortfolioTransaction.Type.valueOf(suspect.transaction.getType() - .name()); - - PortfolioEntry match = null; - for (PortfolioEntry candidate : portfolioTransactions) - { - if (candidate.transaction.getType() != neededType) - continue; - - if (!candidate.transaction.getDateTime().equals(suspect.transaction.getDateTime())) - continue; - - if (candidate.transaction.getSecurity() != suspect.transaction.getSecurity()) - continue; - - if (candidate.transaction.getAmount() != suspect.transaction.getAmount()) - continue; - - match = candidate; - break; - } - - if (match == null) - { - issues.add(new MissingBuySellPortfolioIssue(client, suspect.account, suspect.transaction)); - iterAccount.remove(); - } - else - { - BuySellEntry entry = new BuySellEntry(match.portfolio, suspect.account); - entry.setCurrencyCode(match.transaction.getCurrencyCode()); - entry.setType(match.transaction.getType()); - entry.setDate(match.transaction.getDateTime()); - entry.setSecurity(match.transaction.getSecurity()); - entry.setShares(match.transaction.getShares()); - entry.setAmount(match.transaction.getAmount()); - entry.getPortfolioTransaction().addUnits(match.transaction.getUnits()); - entry.insert(); - - match.portfolio.getTransactions().remove(match.transaction); - suspect.account.getTransactions().remove(suspect.transaction); - - portfolioTransactions.remove(match); - iterAccount.remove(); - } - } - - // create issues for any unmatched portfolio transaction - Iterator iterPorfolio = portfolioTransactions.iterator(); - while (iterPorfolio.hasNext()) + for (PortfolioTransaction transaction : portfolio.getTransactions()) { - PortfolioEntry t = iterPorfolio.next(); - - if (t.transaction.getType() != PortfolioTransaction.Type.BUY - && t.transaction.getType() != PortfolioTransaction.Type.SELL) + if (transaction.getCrossEntry() != null) continue; - issues.add(new MissingBuySellAccountIssue(client, t.portfolio, t.transaction)); - iterPorfolio.remove(); + reportPortfolioIssue(client, portfolio, transaction, issues); } } - private void matchAccountTransfers() - { - Set matched = new HashSet(); - - for (AccountEntry suspect : accountTransactions) - { - if (matched.contains(suspect)) - continue; - - AccountTransaction.Type neededType = null; - if (suspect.transaction.getType() == AccountTransaction.Type.TRANSFER_IN) - neededType = AccountTransaction.Type.TRANSFER_OUT; - else if (suspect.transaction.getType() == AccountTransaction.Type.TRANSFER_OUT) - neededType = AccountTransaction.Type.TRANSFER_IN; - - if (neededType == null) - continue; - - AccountEntry match = null; - for (AccountEntry candidate : accountTransactions) - { - if (matched.contains(candidate)) - continue; - - if (candidate.account.equals(suspect.account)) - continue; - - if (candidate.transaction.getType() != neededType) - continue; - - if (!candidate.transaction.getDateTime().equals(suspect.transaction.getDateTime())) - continue; - - if (candidate.transaction.getAmount() != suspect.transaction.getAmount()) - continue; - - match = candidate; - break; - } + return issues; + } - if (match == null) - { - matched.add(suspect); - issues.add(new MissingAccountTransferIssue(client, suspect.account, suspect.transaction)); - } + private void reportAccountIssue(Client client, Account account, AccountTransaction transaction, List issues) + { + switch (transaction.getType()) + { + case BUY: + case SELL: + if (LedgerCheckSupport.isLedgerBacked(transaction)) + issues.add(new MissingBuySellPortfolioIssue(client, account, transaction)); + else if (transaction.getSecurity() == null) + issues.add(new BuySellMissingSecurityIssue(client, account, transaction)); else - { - AccountTransferEntry crossentry = null; - - if (suspect.transaction.getType() == AccountTransaction.Type.TRANSFER_IN) - crossentry = new AccountTransferEntry(match.account, suspect.account); - else - crossentry = new AccountTransferEntry(suspect.account, match.account); - - crossentry.setDate(match.transaction.getDateTime()); - crossentry.setAmount(match.transaction.getAmount()); - crossentry.setCurrencyCode(match.transaction.getCurrencyCode()); - crossentry.insert(); - - suspect.account.getTransactions().remove(suspect.transaction); - match.account.getTransactions().remove(match.transaction); - - matched.add(suspect); - matched.add(match); - } - } - - accountTransactions.removeAll(matched); + issues.add(new MissingBuySellPortfolioIssue(client, account, transaction)); + break; + case TRANSFER_IN: + case TRANSFER_OUT: + issues.add(new MissingAccountTransferIssue(client, account, transaction)); + break; + default: + break; } + } - private void matchPortfolioTransfers() + private void reportPortfolioIssue(Client client, Portfolio portfolio, PortfolioTransaction transaction, + List issues) + { + switch (transaction.getType()) { - Set matched = new HashSet(); - - for (PortfolioEntry suspect : portfolioTransactions) - { - if (matched.contains(suspect)) - continue; - - PortfolioTransaction.Type neededType = null; - if (suspect.transaction.getType() == PortfolioTransaction.Type.TRANSFER_IN) - neededType = PortfolioTransaction.Type.TRANSFER_OUT; - else if (suspect.transaction.getType() == PortfolioTransaction.Type.TRANSFER_OUT) - neededType = PortfolioTransaction.Type.TRANSFER_IN; - - if (neededType == null) - continue; - - PortfolioEntry match = null; - for (PortfolioEntry possibleMatch : portfolioTransactions) - { - if (matched.contains(possibleMatch)) - continue; - - if (possibleMatch.portfolio.equals(suspect.portfolio)) - continue; - - if (possibleMatch.transaction.getType() != neededType) - continue; - - if (!possibleMatch.transaction.getDateTime().equals(suspect.transaction.getDateTime())) - continue; - - if (!possibleMatch.transaction.getSecurity().equals(suspect.transaction.getSecurity())) - continue; - - if (possibleMatch.transaction.getShares() != suspect.transaction.getShares()) - continue; - - if (possibleMatch.transaction.getAmount() != suspect.transaction.getAmount()) - continue; - - match = possibleMatch; - break; - } - - if (match == null) - { - matched.add(suspect); - issues.add(new MissingPortfolioTransferIssue(client, suspect.portfolio, suspect.transaction)); - } + case BUY: + case SELL: + if (transaction.getSecurity() == null) + issues.add(new PortfolioTransactionWithoutSecurityCheck.MissingSecurityIssue(client, portfolio, + transaction)); else - { - PortfolioTransferEntry crossentry = null; - - if (suspect.transaction.getType() == PortfolioTransaction.Type.TRANSFER_IN) - crossentry = new PortfolioTransferEntry(match.portfolio, suspect.portfolio); - else - crossentry = new PortfolioTransferEntry(suspect.portfolio, match.portfolio); - - crossentry.setDate(match.transaction.getDateTime()); - crossentry.setSecurity(match.transaction.getSecurity()); - crossentry.setShares(match.transaction.getShares()); - crossentry.setAmount(match.transaction.getAmount()); - crossentry.setCurrencyCode(match.transaction.getCurrencyCode()); - crossentry.insert(); - - suspect.portfolio.getTransactions().remove(suspect.transaction); - match.portfolio.getTransactions().remove(match.transaction); - - matched.add(suspect); - matched.add(match); - } - } - - portfolioTransactions.removeAll(matched); + issues.add(new MissingBuySellAccountIssue(client, portfolio, transaction)); + break; + case TRANSFER_IN: + case TRANSFER_OUT: + if (transaction.getSecurity() == null) + issues.add(new PortfolioTransactionWithoutSecurityCheck.MissingSecurityIssue(client, portfolio, + transaction)); + else + issues.add(new MissingPortfolioTransferIssue(client, portfolio, transaction)); + break; + default: + break; } } } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/DeleteTransactionFix.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/DeleteTransactionFix.java index 9e569f78d4..2d5fe63006 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/DeleteTransactionFix.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/DeleteTransactionFix.java @@ -6,6 +6,8 @@ import name.abuchen.portfolio.model.Transaction; import name.abuchen.portfolio.model.TransactionOwner; import name.abuchen.portfolio.model.TransactionPair; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerTransactionDeleter; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; /* package */class DeleteTransactionFix implements QuickFix { @@ -40,6 +42,12 @@ public String getDoneLabel() @Override public void execute() { + if (transaction instanceof LedgerBackedTransaction ledgerBackedTransaction) + { + new LedgerTransactionDeleter(client).delete(ledgerBackedTransaction); + return; + } + owner.deleteTransaction(transaction, client); } } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/DividendsAndInterestCheck.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/DividendsAndInterestCheck.java index dc4033d4da..2a423b9bb8 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/DividendsAndInterestCheck.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/DividendsAndInterestCheck.java @@ -37,40 +37,7 @@ public String getLabel() @Override public List getAvailableFixes() { - List answer = new ArrayList(); - answer.add(new ConvertFix(transaction, target)); - answer.add(new DeleteTransactionFix(client, account, transaction)); - return answer; - } - } - - private static final class ConvertFix implements QuickFix - { - private AccountTransaction transaction; - private Type target; - - public ConvertFix(AccountTransaction transaction, Type target) - { - this.transaction = transaction; - this.target = target; - } - - @Override - public String getLabel() - { - return MessageFormat.format(Messages.FixConvertToType, target); - } - - @Override - public void execute() - { - transaction.setType(target); - } - - @Override - public String getDoneLabel() - { - return MessageFormat.format(Messages.FixConvertToTypeDone, target); + return List.of(new DeleteTransactionFix(client, account, transaction)); } } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/FixDepositsWithSecurityCheck.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/FixDepositsWithSecurityCheck.java deleted file mode 100644 index da4385faf3..0000000000 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/FixDepositsWithSecurityCheck.java +++ /dev/null @@ -1,37 +0,0 @@ -package name.abuchen.portfolio.checks.impl; - -import java.util.Collections; -import java.util.EnumSet; -import java.util.List; -import java.util.Set; - -import name.abuchen.portfolio.checks.Check; -import name.abuchen.portfolio.checks.Issue; -import name.abuchen.portfolio.model.AccountTransaction; -import name.abuchen.portfolio.model.Client; - -/** - * Historically it was possible to assign a security to a deposit. This check - * removes the illegal assignment. - */ -public class FixDepositsWithSecurityCheck implements Check -{ - - @Override - public List execute(Client client) - { - Set set = EnumSet.of(AccountTransaction.Type.DEPOSIT, // - AccountTransaction.Type.REMOVAL, // - AccountTransaction.Type.TRANSFER_IN, // - AccountTransaction.Type.TRANSFER_OUT, // - AccountTransaction.Type.INTEREST_CHARGE); - - client.getAccounts().stream() // - .flatMap(a -> a.getTransactions().stream()) // - .filter(t -> t.getSecurity() != null) // - .filter(t -> set.contains(t.getType())) // - .forEach(t -> t.setSecurity(null)); - - return Collections.emptyList(); - } -} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/FixTaxRefundsCheck.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/FixTaxRefundsCheck.java deleted file mode 100644 index 6a6ab0538c..0000000000 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/FixTaxRefundsCheck.java +++ /dev/null @@ -1,48 +0,0 @@ -package name.abuchen.portfolio.checks.impl; - -import java.util.Collections; -import java.util.List; - -import name.abuchen.portfolio.checks.Check; -import name.abuchen.portfolio.checks.Issue; -import name.abuchen.portfolio.model.Account; -import name.abuchen.portfolio.model.AccountTransaction; -import name.abuchen.portfolio.model.Client; -import name.abuchen.portfolio.model.Security; - -/** - * Due to bug #467, tax refunds can contain a reference to an empty security - * when in fact the transaction should not be bound to a security at all. Remove - * it. - */ -public class FixTaxRefundsCheck implements Check -{ - - @Override - public List execute(Client client) - { - // issue is fixed version 29 - if (client.getFileVersionAfterRead() > 29) - return Collections.emptyList(); - - for (Account account : client.getAccounts()) - { - for (AccountTransaction t : account.getTransactions()) - { - if (t.getType() != AccountTransaction.Type.TAX_REFUND) - continue; - - if (t.getSecurity() == null) - continue; - - Security security = t.getSecurity(); - - if (!client.getSecurities().contains(security)) - t.setSecurity(null); - } - } - - return Collections.emptyList(); - } - -} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/FixWrongTransfersCheck.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/FixWrongTransfersCheck.java deleted file mode 100644 index 877ec8ba82..0000000000 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/FixWrongTransfersCheck.java +++ /dev/null @@ -1,53 +0,0 @@ -package name.abuchen.portfolio.checks.impl; - -import java.util.Collections; -import java.util.List; -import java.util.stream.Collectors; - -import name.abuchen.portfolio.checks.Check; -import name.abuchen.portfolio.checks.Issue; -import name.abuchen.portfolio.model.BuySellEntry; -import name.abuchen.portfolio.model.Client; -import name.abuchen.portfolio.model.Portfolio; -import name.abuchen.portfolio.model.PortfolioTransaction; -import name.abuchen.portfolio.model.Transaction.Unit; - -/** - * Search for transactions of type TRANSFER_IN where the crossEntry is an - * instance of BuySellEntry instead of PortfolioTransferEntry. This indicates - * that this malformed transaction was created through PDF import and needs to - * be corrected. Issue #1931 relates to this fix. - */ -public class FixWrongTransfersCheck implements Check -{ - - @Override - public List execute(Client client) - { - - List transactions = client.getPortfolios().stream() - .flatMap(p -> p.getTransactions().stream()) - .filter(t -> PortfolioTransaction.Type.TRANSFER_IN.equals(t.getType())) - .collect(Collectors.toList()); - - for (PortfolioTransaction t : transactions) - { - if (t.getCrossEntry() instanceof BuySellEntry) - { - // create new transaction because crossEntry cannot be removed - PortfolioTransaction copy = new PortfolioTransaction(t.getDateTime(), t.getCurrencyCode(), - t.getAmount(), t.getSecurity(), t.getShares(), - PortfolioTransaction.Type.DELIVERY_INBOUND, t.getUnitSum(Unit.Type.FEE).getAmount(), - t.getUnitSum(Unit.Type.TAX).getAmount()); - // copy note text - copy.setNote(t.getNote()); - - // replace transaction in portfolio - ((Portfolio) t.getCrossEntry().getOwner(t)).addTransaction(copy); - ((Portfolio) t.getCrossEntry().getOwner(t)).deleteTransaction(t, client); - } - } - - return Collections.emptyList(); - } -} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/LedgerCheckSupport.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/LedgerCheckSupport.java new file mode 100644 index 0000000000..e4675bdf0d --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/LedgerCheckSupport.java @@ -0,0 +1,39 @@ +package name.abuchen.portfolio.checks.impl; + +import name.abuchen.portfolio.model.AccountTransferEntry; +import name.abuchen.portfolio.model.BuySellEntry; +import name.abuchen.portfolio.model.PortfolioTransferEntry; +import name.abuchen.portfolio.model.Transaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; + +/** + * Detects whether check issues involve ledger-backed transaction projections. + * This is internal check support. Repairs should use Ledger-aware delete or mutation paths + * instead of changing runtime projections directly. + */ +/* package */final class LedgerCheckSupport +{ + private LedgerCheckSupport() + { + } + + static boolean isLedgerBacked(Transaction transaction) + { + return transaction instanceof LedgerBackedTransaction; + } + + static boolean isLedgerBacked(BuySellEntry entry) + { + return isLedgerBacked(entry.getAccountTransaction()) || isLedgerBacked(entry.getPortfolioTransaction()); + } + + static boolean isLedgerBacked(AccountTransferEntry entry) + { + return isLedgerBacked(entry.getSourceTransaction()) || isLedgerBacked(entry.getTargetTransaction()); + } + + static boolean isLedgerBacked(PortfolioTransferEntry entry) + { + return isLedgerBacked(entry.getSourceTransaction()) || isLedgerBacked(entry.getTargetTransaction()); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/MissingAccountTransferIssue.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/MissingAccountTransferIssue.java index 06f343d658..411327c00a 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/MissingAccountTransferIssue.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/MissingAccountTransferIssue.java @@ -2,71 +2,15 @@ import java.text.MessageFormat; import java.util.List; -import java.util.stream.Collectors; import name.abuchen.portfolio.Messages; import name.abuchen.portfolio.checks.QuickFix; import name.abuchen.portfolio.model.Account; import name.abuchen.portfolio.model.AccountTransaction; -import name.abuchen.portfolio.model.AccountTransferEntry; import name.abuchen.portfolio.model.Client; /* package */class MissingAccountTransferIssue extends AbstractAccountIssue { - private final class CreateTransferFix implements QuickFix - { - private Account crossAccount; - - private CreateTransferFix(Account crossAccount) - { - this.crossAccount = crossAccount; - } - - @Override - public String getLabel() - { - return MessageFormat.format(Messages.FixCreateTransfer, crossAccount.getName()); - } - - @Override - public String getDoneLabel() - { - AccountTransaction.Type target = null; - if (transaction.getType() == AccountTransaction.Type.TRANSFER_IN) - target = AccountTransaction.Type.TRANSFER_OUT; - else - target = AccountTransaction.Type.TRANSFER_IN; - - return MessageFormat.format(Messages.FixCreateTransferDone, target.toString()); - } - - @Override - public void execute() - { - Account from = null; - Account to = null; - - if (transaction.getType() == AccountTransaction.Type.TRANSFER_IN) - { - from = crossAccount; - to = account; - } - else - { - from = account; - to = crossAccount; - } - - AccountTransferEntry entry = new AccountTransferEntry(from, to); - entry.setDate(transaction.getDateTime()); - entry.setAmount(transaction.getAmount()); - entry.setCurrencyCode(transaction.getCurrencyCode()); - entry.insert(); - - account.getTransactions().remove(transaction); - } - } - public MissingAccountTransferIssue(Client client, Account account, AccountTransaction transaction) { super(client, account, transaction); @@ -81,12 +25,7 @@ public String getLabel() @Override public List getAvailableFixes() { - List answer = client.getAccounts().stream() // - .filter(a -> !a.equals(account)) // - .map(a -> new CreateTransferFix(a)) // - .collect(Collectors.toList()); - answer.add(new DeleteTransactionFix(client, account, transaction)); - return answer; + return List.of(new DeleteTransactionFix(client, account, transaction)); } } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/MissingBuySellAccountIssue.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/MissingBuySellAccountIssue.java index 0c7ff73ec8..a445203f46 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/MissingBuySellAccountIssue.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/MissingBuySellAccountIssue.java @@ -1,13 +1,10 @@ package name.abuchen.portfolio.checks.impl; import java.text.MessageFormat; -import java.util.ArrayList; import java.util.List; import name.abuchen.portfolio.Messages; import name.abuchen.portfolio.checks.QuickFix; -import name.abuchen.portfolio.model.Account; -import name.abuchen.portfolio.model.BuySellEntry; import name.abuchen.portfolio.model.Client; import name.abuchen.portfolio.model.Portfolio; import name.abuchen.portfolio.model.PortfolioTransaction; @@ -15,89 +12,6 @@ /* package */class MissingBuySellAccountIssue extends AbstractPortfolioIssue { - private final class ConvertToDeliveryFix implements QuickFix - { - PortfolioTransaction.Type target; - - public ConvertToDeliveryFix() - { - if (transaction.getType() == PortfolioTransaction.Type.BUY) - target = PortfolioTransaction.Type.DELIVERY_INBOUND; - else if (transaction.getType() == PortfolioTransaction.Type.SELL) - target = PortfolioTransaction.Type.DELIVERY_OUTBOUND; - else - throw new UnsupportedOperationException(); - } - - @Override - public String getLabel() - { - return MessageFormat.format(Messages.FixConvertToDelivery, target.toString()); - } - - @Override - public String getDoneLabel() - { - return MessageFormat.format(Messages.FixConvertToDeliveryDone, target.toString()); - } - - @Override - public void execute() - { - PortfolioTransaction t = new PortfolioTransaction(); - t.setType(target); - t.setCurrencyCode(transaction.getCurrencyCode()); - t.setDateTime(transaction.getDateTime()); - t.setSecurity(transaction.getSecurity()); - t.setShares(transaction.getShares()); - t.setAmount(transaction.getAmount()); - t.addUnits(transaction.getUnits()); - - portfolio.addTransaction(t); - - portfolio.getTransactions().remove(transaction); - } - } - - private final class CreateBuySellEntryFix implements QuickFix - { - private Account account; - - private CreateBuySellEntryFix(Account account) - { - this.account = account; - } - - @Override - public String getLabel() - { - return MessageFormat.format(Messages.FixCreateCrossEntryAccount, account.getName()); - } - - @Override - public String getDoneLabel() - { - return MessageFormat.format(Messages.FixCreateCrossEntryDone, transaction.getType().toString()); - } - - @Override - public void execute() - { - BuySellEntry entry = new BuySellEntry(portfolio, account); - entry.setCurrencyCode(transaction.getCurrencyCode()); - entry.setDate(transaction.getDateTime()); - entry.setType(transaction.getType()); - entry.setSecurity(transaction.getSecurity()); - entry.setShares(transaction.getShares()); - entry.setAmount(transaction.getAmount()); - entry.getPortfolioTransaction().addUnits(transaction.getUnits()); - - entry.insert(); - - portfolio.getTransactions().remove(transaction); - } - } - public MissingBuySellAccountIssue(Client client, Portfolio portfolio, PortfolioTransaction transaction) { super(client, portfolio, transaction); @@ -116,22 +30,6 @@ public String getLabel() @Override public List getAvailableFixes() { - List answer = new ArrayList<>(); - - answer.add(new ConvertToDeliveryFix()); - - if (portfolio.getReferenceAccount() != null) - answer.add(new CreateBuySellEntryFix(portfolio.getReferenceAccount())); - - for (final Account account : client.getAccounts()) - { - if (account.equals(portfolio.getReferenceAccount())) - continue; - answer.add(new CreateBuySellEntryFix(account)); - } - - answer.add(new DeleteTransactionFix(client, portfolio, transaction)); - - return answer; + return List.of(new DeleteTransactionFix(client, portfolio, transaction)); } } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/MissingBuySellPortfolioIssue.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/MissingBuySellPortfolioIssue.java index 051cc102d7..7831019119 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/MissingBuySellPortfolioIssue.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/MissingBuySellPortfolioIssue.java @@ -1,58 +1,16 @@ package name.abuchen.portfolio.checks.impl; import java.text.MessageFormat; -import java.util.ArrayList; import java.util.List; import name.abuchen.portfolio.Messages; import name.abuchen.portfolio.checks.QuickFix; import name.abuchen.portfolio.model.Account; import name.abuchen.portfolio.model.AccountTransaction; -import name.abuchen.portfolio.model.BuySellEntry; import name.abuchen.portfolio.model.Client; -import name.abuchen.portfolio.model.Portfolio; -import name.abuchen.portfolio.model.PortfolioTransaction; -import name.abuchen.portfolio.money.Values; /* package */class MissingBuySellPortfolioIssue extends AbstractAccountIssue { - private final class CreateBuySellEntryFix implements QuickFix - { - private final Portfolio portfolio; - - private CreateBuySellEntryFix(Portfolio portfolio) - { - this.portfolio = portfolio; - } - - @Override - public String getLabel() - { - return MessageFormat.format(Messages.FixCreateCrossEntryPortfolio, portfolio.getName()); - } - - @Override - public String getDoneLabel() - { - return MessageFormat.format(Messages.FixCreateCrossEntryDone, transaction.getType().toString()); - } - - @Override - public void execute() - { - BuySellEntry entry = new BuySellEntry(portfolio, account); - entry.setDate(transaction.getDateTime()); - entry.setType(PortfolioTransaction.Type.valueOf(transaction.getType().name())); - entry.setSecurity(transaction.getSecurity()); - entry.setShares(Values.Share.factor()); - entry.setAmount(transaction.getAmount()); - entry.setCurrencyCode(transaction.getCurrencyCode()); - entry.insert(); - - account.getTransactions().remove(transaction); - } - } - public MissingBuySellPortfolioIssue(Client client, Account account, AccountTransaction transaction) { super(client, account, transaction); @@ -68,9 +26,6 @@ public String getLabel() @Override public List getAvailableFixes() { - List answer = new ArrayList(); - answer.add(new DeleteTransactionFix(client, account, transaction)); - client.getPortfolios().stream().forEach(p -> answer.add(new CreateBuySellEntryFix(p))); - return answer; + return List.of(new DeleteTransactionFix(client, account, transaction)); } } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/MissingPortfolioTransferIssue.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/MissingPortfolioTransferIssue.java index 9d73fcdda0..f994aa8a38 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/MissingPortfolioTransferIssue.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/MissingPortfolioTransferIssue.java @@ -2,74 +2,16 @@ import java.text.MessageFormat; import java.util.List; -import java.util.stream.Collectors; import name.abuchen.portfolio.Messages; import name.abuchen.portfolio.checks.QuickFix; import name.abuchen.portfolio.model.Client; import name.abuchen.portfolio.model.Portfolio; import name.abuchen.portfolio.model.PortfolioTransaction; -import name.abuchen.portfolio.model.PortfolioTransferEntry; import name.abuchen.portfolio.money.Values; /* package */class MissingPortfolioTransferIssue extends AbstractPortfolioIssue { - private final class CreateTransferFix implements QuickFix - { - private Portfolio crossPortfolio; - - private CreateTransferFix(Portfolio crossPortfolio) - { - this.crossPortfolio = crossPortfolio; - } - - @Override - public String getLabel() - { - return MessageFormat.format(Messages.FixCreateTransfer, crossPortfolio.getName()); - } - - @Override - public String getDoneLabel() - { - PortfolioTransaction.Type target; - if (transaction.getType() == PortfolioTransaction.Type.TRANSFER_IN) - target = PortfolioTransaction.Type.TRANSFER_OUT; - else - target = PortfolioTransaction.Type.TRANSFER_IN; - - return MessageFormat.format(Messages.FixCreateTransferDone, target.toString()); - } - - @Override - public void execute() - { - Portfolio from; - Portfolio to; - - if (transaction.getType() == PortfolioTransaction.Type.TRANSFER_IN) - { - from = crossPortfolio; - to = portfolio; - } - else - { - from = portfolio; - to = crossPortfolio; - } - - PortfolioTransferEntry entry = new PortfolioTransferEntry(from, to); - entry.setDate(transaction.getDateTime()); - entry.setSecurity(transaction.getSecurity()); - entry.setShares(transaction.getShares()); - entry.setAmount(transaction.getAmount()); - entry.setCurrencyCode(transaction.getCurrencyCode()); - entry.insert(); - - portfolio.getTransactions().remove(transaction); - } - } - public MissingPortfolioTransferIssue(Client client, Portfolio portfolio, PortfolioTransaction transaction) { super(client, portfolio, transaction); @@ -88,14 +30,7 @@ public String getLabel() @Override public List getAvailableFixes() { - List answer = client.getPortfolios().stream() // - .filter(p -> !p.equals(portfolio)) // - .map(p -> new CreateTransferFix(p)) // - .collect(Collectors.toList()); - - answer.add(new DeleteTransactionFix(client, portfolio, transaction)); - - return answer; + return List.of(new DeleteTransactionFix(client, portfolio, transaction)); } } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/MissingTransactionDateCheck.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/MissingTransactionDateCheck.java deleted file mode 100644 index 409703c0e8..0000000000 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/MissingTransactionDateCheck.java +++ /dev/null @@ -1,123 +0,0 @@ -package name.abuchen.portfolio.checks.impl; - -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.List; - -import name.abuchen.portfolio.Messages; -import name.abuchen.portfolio.checks.Check; -import name.abuchen.portfolio.checks.Issue; -import name.abuchen.portfolio.checks.QuickFix; -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.Transaction; -import name.abuchen.portfolio.model.TransactionOwner; - -public class MissingTransactionDateCheck implements Check -{ - public class MissingDateFix implements QuickFix - { - private Transaction tx; - - public MissingDateFix(Transaction tx) - { - this.tx = tx; - } - - @Override - public String getLabel() - { - return Messages.FixSetDateToToday; - } - - @Override - public String getDoneLabel() - { - return Messages.FixSetDateToTodayDone; - } - - @Override - public void execute() - { - tx.setDateTime(LocalDate.now().atStartOfDay()); - } - } - - private final class MissingDateIssue implements Issue - { - private final TransactionOwner owner; - private final T tx; - - public MissingDateIssue(TransactionOwner owner, T tx) - { - this.owner = owner; - this.tx = tx; - } - - @Override - public LocalDate getDate() - { - // if the transaction has been fixed, the date should be - return tx.getDateTime() != null ? tx.getDateTime().toLocalDate() : null; - } - - @Override - public Object getEntity() - { - return owner; - } - - @Override - public Long getAmount() - { - return tx.getAmount(); - } - - @Override - public String getLabel() - { - return Messages.IssueTransactionWithoutDate; - } - - @Override - public List getAvailableFixes() - { - List fixes = new ArrayList<>(); - fixes.add(new MissingDateFix(tx)); - return fixes; - } - } - - @Override - public List execute(Client client) - { - var answer = new ArrayList(); - - for (Account account : client.getAccounts()) - { - for (AccountTransaction tx : account.getTransactions()) - { - if (tx.getDateTime() == null) - { - answer.add(new MissingDateIssue<>(account, tx)); - } - } - } - - for (Portfolio portfolio : client.getPortfolios()) - { - for (PortfolioTransaction tx : portfolio.getTransactions()) - { - if (tx.getDateTime() == null) - { - answer.add(new MissingDateIssue<>(portfolio, tx)); - } - } - } - - return answer; - } -} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/NegativeExchangeRateCheck.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/NegativeExchangeRateCheck.java index 88e2a8a26f..8e2c8cb6dd 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/NegativeExchangeRateCheck.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/NegativeExchangeRateCheck.java @@ -3,7 +3,10 @@ import java.text.MessageFormat; import java.time.LocalDate; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Optional; +import java.util.Set; import java.util.stream.Collectors; import name.abuchen.portfolio.Messages; @@ -18,6 +21,9 @@ import name.abuchen.portfolio.model.PortfolioTransaction; import name.abuchen.portfolio.model.Transaction; import name.abuchen.portfolio.model.TransactionPair; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; import name.abuchen.portfolio.money.Values; /** @@ -78,11 +84,20 @@ public List getAvailableFixes() public List execute(Client client) { List issues = new ArrayList<>(); + Set reportedLedgerEntries = new HashSet<>(); for (Account account : client.getAccounts()) { for (AccountTransaction t : account.getTransactions()) // NOSONAR { + if (t instanceof LedgerBackedTransaction ledgerBacked) + { + if (reportedLedgerEntries.add(ledgerBacked.getLedgerEntry().getUUID())) + addLedgerBackedIssue(client, issues, new TransactionPair<>(account, t), + ledgerBacked.getLedgerEntry()); + continue; + } + if (t.getCrossEntry() instanceof BuySellEntry) continue; @@ -107,6 +122,14 @@ public List execute(Client client) { for (PortfolioTransaction t : portfolio.getTransactions()) // NOSONAR { + if (t instanceof LedgerBackedTransaction ledgerBacked) + { + if (reportedLedgerEntries.add(ledgerBacked.getLedgerEntry().getUUID())) + addLedgerBackedIssue(client, issues, new TransactionPair<>(portfolio, t), + ledgerBacked.getLedgerEntry()); + continue; + } + if (t.getType() == PortfolioTransaction.Type.TRANSFER_IN) continue; @@ -126,4 +149,29 @@ public List execute(Client client) return issues; } + + private void addLedgerBackedIssue(Client client, List issues, TransactionPair pair, LedgerEntry entry) + { + firstNegativeExchangeRatePosting(entry).ifPresent(posting -> issues.add( + new NegativeExchangeRateIssue(client, pair, + MessageFormat.format(Messages.IssueExchangeRateIsNegative, + Values.ExchangeRate.format(posting.getExchangeRate()), + transactionType(pair.getTransaction()))))); + } + + private Optional firstNegativeExchangeRatePosting(LedgerEntry entry) + { + return entry.getPostings().stream() // + .filter(posting -> posting.getExchangeRate() != null) + .filter(posting -> posting.getExchangeRate().signum() < 0) // + .findFirst(); + } + + private Object transactionType(Transaction transaction) + { + if (transaction instanceof AccountTransaction accountTransaction) + return accountTransaction.getType(); + + return ((PortfolioTransaction) transaction).getType(); + } } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/OnlyAccountTransactionsWithSecurityCanHaveExDateCheck.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/OnlyAccountTransactionsWithSecurityCanHaveExDateCheck.java index 1db24bddb3..8e16d7aa14 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/OnlyAccountTransactionsWithSecurityCanHaveExDateCheck.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/OnlyAccountTransactionsWithSecurityCanHaveExDateCheck.java @@ -1,11 +1,19 @@ package name.abuchen.portfolio.checks.impl; +import java.time.Instant; import java.util.ArrayList; import java.util.List; import name.abuchen.portfolio.checks.Check; import name.abuchen.portfolio.checks.Issue; import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.Transaction; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedAccountTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionService; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionSupport; public class OnlyAccountTransactionsWithSecurityCanHaveExDateCheck implements Check { @@ -16,15 +24,58 @@ public List execute(Client client) for (var account : client.getAccounts()) { - for (var tx : account.getTransactions()) + for (var tx : List.copyOf(account.getTransactions())) { - if (tx.getExDate() != null && tx.getSecurity() == null) + if (tx instanceof LedgerBackedAccountTransaction ledgerBacked) { - tx.setExDate(null); + clearLedgerBackedExDate(client, ledgerBacked); + continue; } + + if (tx.getExDate() != null && tx.getSecurity() == null) + tx.setExDate(null); } } return answer; } + + private void clearLedgerBackedExDate(Client client, LedgerBackedAccountTransaction transaction) + { + var posting = LedgerProjectionSupport.primaryPosting(transaction.getLedgerEntry(), + transaction.getLedgerProjectionRef()); + + if (posting.getSecurity() != null || !removeExDateParameters(posting)) + return; + + transaction.getLedgerEntry().setUpdatedAt(Instant.now()); + refreshLedgerEntryProjections(client, transaction.getLedgerEntry().getUUID()); + } + + private boolean removeExDateParameters(LedgerPosting posting) + { + var parameters = posting.getParameters().stream() // + .filter(parameter -> parameter.getType() == LedgerParameterType.EX_DATE) // + .toList(); + + parameters.forEach(posting::removeParameter); + + return !parameters.isEmpty(); + } + + private void refreshLedgerEntryProjections(Client client, String entryUUID) + { + client.getAccounts().forEach(owner -> owner.getTransactions() + .removeIf(transaction -> isProjectionOfEntry(transaction, entryUUID))); + client.getPortfolios().forEach(owner -> owner.getTransactions() + .removeIf(transaction -> isProjectionOfEntry(transaction, entryUUID))); + + LedgerProjectionService.materialize(client); + } + + private boolean isProjectionOfEntry(Transaction transaction, String entryUUID) + { + return transaction instanceof LedgerBackedTransaction ledgerBacked + && ledgerBacked.getLedgerEntry().getUUID().equals(entryUUID); + } } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/PortfolioTransactionWithoutSecurityCheck.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/PortfolioTransactionWithoutSecurityCheck.java index efaae545b4..23516854ff 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/PortfolioTransactionWithoutSecurityCheck.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/PortfolioTransactionWithoutSecurityCheck.java @@ -1,6 +1,5 @@ package name.abuchen.portfolio.checks.impl; -import java.text.MessageFormat; import java.time.LocalDate; import java.util.ArrayList; import java.util.List; @@ -12,7 +11,6 @@ import name.abuchen.portfolio.model.Client; import name.abuchen.portfolio.model.Portfolio; import name.abuchen.portfolio.model.PortfolioTransaction; -import name.abuchen.portfolio.model.Security; public class PortfolioTransactionWithoutSecurityCheck implements Check { @@ -56,46 +54,7 @@ public String getLabel() @Override public List getAvailableFixes() { - List fixes = new ArrayList(); - - fixes.add(new DeleteTransactionFix(client, portfolio, transaction)); - - for (Security security : client.getSecurities()) - fixes.add(new SetSecurityFix(security, transaction)); - - return fixes; - } - } - - public static class SetSecurityFix implements QuickFix - { - private Security security; - private PortfolioTransaction transaction; - - public SetSecurityFix(Security security, PortfolioTransaction transaction) - { - this.security = security; - this.transaction = transaction; - } - - @Override - public String getLabel() - { - return MessageFormat.format(Messages.FixSetSecurity, security.getName()); - } - - @Override - public String getDoneLabel() - { - return MessageFormat.format(Messages.FixSetSecurityDone, security.getName()); - } - - @Override - public void execute() - { - transaction.setSecurity(security); - if (transaction.getCrossEntry() != null) - transaction.getCrossEntry().updateFrom(transaction); + return List.of(new DeleteTransactionFix(client, portfolio, transaction)); } } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/TransactionCurrencyCheck.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/TransactionCurrencyCheck.java index 185f7b0151..6f59abc11c 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/TransactionCurrencyCheck.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/TransactionCurrencyCheck.java @@ -5,7 +5,6 @@ import java.util.ArrayList; import java.util.HashSet; import java.util.List; -import java.util.Objects; import java.util.Set; import name.abuchen.portfolio.Messages; @@ -23,72 +22,21 @@ import name.abuchen.portfolio.model.Transaction; import name.abuchen.portfolio.model.TransactionOwner; import name.abuchen.portfolio.model.TransactionPair; -import name.abuchen.portfolio.money.CurrencyUnit; /** * Checks if there is at least one account or security without a currency. */ public class TransactionCurrencyCheck implements Check { - public static class TransactionCurrencyQuickFix implements QuickFix - { - private TransactionPair pair; - private String currencyCode; - - public TransactionCurrencyQuickFix(Client client, TransactionPair pair) - { - this.pair = pair; - - // either take currency from account or from security. Use base - // currency as a fallback - this.currencyCode = pair.getOwner() instanceof Account ? ((Account) pair.getOwner()).getCurrencyCode() - : (pair.getOwner() instanceof Portfolio ? ((PortfolioTransaction) pair.getTransaction()) - .getSecurity().getCurrencyCode() : client.getBaseCurrency()); - } - - @Override - public String getLabel() - { - return CurrencyUnit.getInstance(currencyCode).getLabel(); - } - - @Override - public String getDoneLabel() - { - return MessageFormat.format(Messages.FixAssignCurrencyCodeDone, currencyCode); - } - - @Override - public void execute() - { - pair.getTransaction().setCurrencyCode(currencyCode); - - // since currency fixes are only created if the currency is - // identical, we can safely set the currency on both transactions - if (pair.getTransaction().getCrossEntry() != null) - { - pair.getTransaction().getCrossEntry().getCrossTransaction(pair.getTransaction()) - .setCurrencyCode(currencyCode); - } - } - } - private static class TransactionMissingCurrencyIssue implements Issue { private Client client; private TransactionPair pair; - private boolean isFixable; public TransactionMissingCurrencyIssue(Client client, TransactionPair pair) - { - this(client, pair, true); - } - - public TransactionMissingCurrencyIssue(Client client, TransactionPair pair, boolean isFixable) { this.client = client; this.pair = pair; - this.isFixable = isFixable; } @Override @@ -121,13 +69,7 @@ public String getLabel() @Override public List getAvailableFixes() { - List fixes = new ArrayList(); - - fixes.add(new DeleteTransactionFix(client, pair.getOwner(), pair.getTransaction())); - if (isFixable) - fixes.add(new TransactionCurrencyQuickFix(client, pair)); - - return fixes; + return List.of(new DeleteTransactionFix(client, pair.getOwner(), pair.getTransaction())); } } @@ -166,40 +108,22 @@ public List execute(Client client) } else if (t instanceof BuySellEntry entry) { - // attempt to fix it if both currencies are identical. If a fix - // involves currency conversion plus exchange rates, just offer - // to delete the transaction. - - String accountCurrency = entry.getAccount().getCurrencyCode(); - String securityCurrency = entry.getPortfolioTransaction().getSecurity().getCurrencyCode(); - @SuppressWarnings("unchecked") TransactionPair pair = new TransactionPair( (TransactionOwner) entry.getOwner(entry.getAccountTransaction()), entry.getAccountTransaction()); - issues.add(new TransactionMissingCurrencyIssue(client, pair, Objects.equals(accountCurrency, - securityCurrency))); + issues.add(new TransactionMissingCurrencyIssue(client, pair)); } else if (t instanceof AccountTransferEntry entry) { - // same story as with purchases: only offer to fix if currencies - // match - - String sourceCurrency = entry.getSourceAccount().getCurrencyCode(); - String targetCurrency = entry.getTargetAccount().getCurrencyCode(); - @SuppressWarnings("unchecked") TransactionPair pair = new TransactionPair( (TransactionOwner) entry.getOwner(entry.getSourceTransaction()), entry.getSourceTransaction()); - issues.add(new TransactionMissingCurrencyIssue(client, pair, Objects.equals(sourceCurrency, - targetCurrency))); + issues.add(new TransactionMissingCurrencyIssue(client, pair)); } else if (t instanceof PortfolioTransferEntry entry) { - // transferring a security involves no currency change because - // the currency is defined the security itself - @SuppressWarnings("unchecked") TransactionPair pair = new TransactionPair( (TransactionOwner) entry.getOwner(entry.getSourceTransaction()), diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/datatransfer/actions/DetectDuplicatesAction.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/datatransfer/actions/DetectDuplicatesAction.java index 66843e5fe7..da9fd3fa93 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/datatransfer/actions/DetectDuplicatesAction.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/datatransfer/actions/DetectDuplicatesAction.java @@ -47,18 +47,20 @@ public Status process(BuySellEntry entry, Account account, Portfolio portfolio) .filter(p -> p.getSecurity() != null && p.getSecurity().equals(entry.getPortfolioTransaction().getSecurity())) .iterator(); + int investmentPlanMatches = 0; while (i.hasNext()) { - var transactions = i.next().getTransactions(); - for (Transaction t : transactions) - { - // use portfolio transaction, because it contains the number of - // shares - if (isInvestmentPlanDuplicate(entry.getPortfolioTransaction(), t)) - return new Status(Status.Code.WARNING, Messages.InvestmentPlanItemImportToolTip); - } + var transactions = i.next().getTransactions(client); + var matchingTransactions = findInvestmentPlanTransactions(entry.getPortfolioTransaction(), + transactions.stream().map(pair -> (Transaction) pair.getTransaction()).toList()); + investmentPlanMatches += matchingTransactions.size(); } + if (investmentPlanMatches == 1) + return new Status(Status.Code.WARNING, Messages.InvestmentPlanItemImportToolTip); + else if (investmentPlanMatches > 1) + return new Status(Status.Code.WARNING, Messages.LabelPotentialDuplicate); + Status status = check(entry.getAccountTransaction(), account.getTransactions()); if (status.getCode() != Status.Code.OK) return status; @@ -74,18 +76,20 @@ public Status process(AccountTransferEntry entry, Account source, Account target @Override public Status process(PortfolioTransferEntry entry, Portfolio source, Portfolio target) { - return check(entry.getTargetTransaction(), source.getTransactions()); + Status status = check(entry.getSourceTransaction(), source.getTransactions()); + if (status.getCode() != Status.Code.OK) + return status; + return check(entry.getTargetTransaction(), target.getTransactions()); } public Transaction findInvestmentPlanTransaction(Transaction subject, List transactions) { - for (Transaction t : transactions) - { - // search investment plan transactions for potential duplicates - if (isInvestmentPlanDuplicate(subject, t)) - return t; - } - return null; + return findInvestmentPlanTransactions(subject, transactions).stream().findFirst().orElse(null); + } + + List findInvestmentPlanTransactions(Transaction subject, List transactions) + { + return transactions.stream().filter(t -> isInvestmentPlanDuplicate(subject, t)).toList(); } private Status check(AccountTransaction subject, List transactions) diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/datatransfer/actions/InsertAction.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/datatransfer/actions/InsertAction.java index 4384310051..18b9a8bac7 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/datatransfer/actions/InsertAction.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/datatransfer/actions/InsertAction.java @@ -1,8 +1,11 @@ package name.abuchen.portfolio.datatransfer.actions; +import java.text.MessageFormat; +import java.util.ArrayList; import java.util.Iterator; import java.util.List; +import name.abuchen.portfolio.Messages; import name.abuchen.portfolio.datatransfer.ImportAction; import name.abuchen.portfolio.model.Account; import name.abuchen.portfolio.model.AccountTransaction; @@ -10,12 +13,20 @@ import name.abuchen.portfolio.model.BuySellEntry; import name.abuchen.portfolio.model.Client; import name.abuchen.portfolio.model.InvestmentPlan; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; import name.abuchen.portfolio.model.Portfolio; import name.abuchen.portfolio.model.PortfolioTransaction; import name.abuchen.portfolio.model.PortfolioTransferEntry; import name.abuchen.portfolio.model.Security; import name.abuchen.portfolio.model.SecurityPrice; import name.abuchen.portfolio.model.Transaction; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountOnlyTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountTransferTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerBuySellTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerDeliveryTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerDividendTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferTransactionCreator; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; public class InsertAction implements ImportAction { @@ -67,16 +78,27 @@ public Status process(AccountTransaction transaction, Account account) // security via the dialog) if (transaction.getSecurity() != null) process(transaction.getSecurity()); - account.addTransaction(transaction); + + if (transaction.getType() == AccountTransaction.Type.DIVIDENDS) + { + new LedgerDividendTransactionCreator(client).create(account, transaction.getDateTime(), + transaction.getAmount(), transaction.getCurrencyCode(), transaction.getSecurity(), + transaction.getShares(), transaction.getExDate(), null, null, transaction.getUnits().toList(), + transaction.getNote(), transaction.getSource()); + } + else + { + new LedgerAccountOnlyTransactionCreator(client).create(account, transaction.getType(), + transaction.getDateTime(), transaction.getAmount(), transaction.getCurrencyCode(), + transaction.getSecurity(), transaction.getUnits().toList(), transaction.getNote(), + transaction.getSource()); + } if (removeDividends && transaction.getType() == AccountTransaction.Type.DIVIDENDS) { - AccountTransaction removal = new AccountTransaction(transaction.getDateTime(), - transaction.getCurrencyCode(), transaction.getAmount(), null, - AccountTransaction.Type.REMOVAL); - removal.setNote(transaction.getNote()); - removal.setSource(transaction.getSource()); - account.addTransaction(removal); + new LedgerAccountOnlyTransactionCreator(client).create(account, AccountTransaction.Type.REMOVAL, + transaction.getDateTime(), transaction.getAmount(), transaction.getCurrencyCode(), null, + List.of(), transaction.getNote(), transaction.getSource()); } return Status.OK_STATUS; @@ -88,7 +110,11 @@ public Status process(PortfolioTransaction transaction, Portfolio portfolio) // ensure consistency (in case the user deleted the creation of the // security via the dialog) process(transaction.getSecurity()); - portfolio.addTransaction(transaction); + + new LedgerDeliveryTransactionCreator(client).create(portfolio, transaction.getType(), transaction.getDateTime(), + transaction.getAmount(), transaction.getCurrencyCode(), transaction.getSecurity(), + transaction.getShares(), null, null, transaction.getUnits().toList(), transaction.getNote(), + transaction.getSource()); return Status.OK_STATUS; } @@ -104,7 +130,7 @@ public Status process(BuySellEntry entry, Account account, Portfolio portfolio) if (investmentPlanItem) { DetectDuplicatesAction action = new DetectDuplicatesAction(client); - Transaction existingTransaction = null; + List matchingInvestmentPlanTransactions = new ArrayList<>(); PortfolioTransaction t = entry.getPortfolioTransaction(); // search for a match in existing investment plan transactions @@ -114,30 +140,41 @@ public Status process(BuySellEntry entry, Account account, Portfolio portfolio) while (i.hasNext()) { - List transactions = i.next().getTransactions(); - existingTransaction = action.findInvestmentPlanTransaction(t, transactions); - // update existingTransaction when found and return - if (existingTransaction != null) + List transactions = i.next().getTransactions(client).stream() // + .map(pair -> (Transaction) pair.getTransaction()).toList(); + matchingInvestmentPlanTransactions.addAll(action.findInvestmentPlanTransactions(t, transactions)); + } + + if (matchingInvestmentPlanTransactions.size() > 1) + return new Status(Status.Code.WARNING, Messages.LabelPotentialDuplicate); + + if (matchingInvestmentPlanTransactions.size() == 1) + { + Transaction existingTransaction = matchingInvestmentPlanTransactions.get(0); + if (existingTransaction instanceof LedgerBackedTransaction) { - existingTransaction.setDateTime(t.getDateTime()); - existingTransaction.setNote(t.getNote()); - existingTransaction.setSource(t.getSource()); - existingTransaction.setShares(t.getShares()); - existingTransaction.setAmount(t.getAmount()); - existingTransaction.clearUnits(); - t.getUnits().forEach(existingTransaction::addUnit); - - if (existingTransaction.getCrossEntry() != null) - { - Transaction crossTransaction = existingTransaction.getCrossEntry() - .getCrossTransaction(existingTransaction); - crossTransaction.setDateTime(t.getDateTime()); - crossTransaction.setAmount(t.getAmount()); - crossTransaction.setNote(t.getNote()); - crossTransaction.setSource(t.getSource()); - } + updateLedgerBackedInvestmentPlanTransaction(existingTransaction, entry); return Status.OK_STATUS; } + + existingTransaction.setDateTime(t.getDateTime()); + existingTransaction.setNote(t.getNote()); + existingTransaction.setSource(t.getSource()); + existingTransaction.setShares(t.getShares()); + existingTransaction.setAmount(t.getAmount()); + existingTransaction.clearUnits(); + t.getUnits().forEach(existingTransaction::addUnit); + + if (existingTransaction.getCrossEntry() != null) + { + Transaction crossTransaction = existingTransaction.getCrossEntry() + .getCrossTransaction(existingTransaction); + crossTransaction.setDateTime(t.getDateTime()); + crossTransaction.setAmount(t.getAmount()); + crossTransaction.setNote(t.getNote()); + crossTransaction.setSource(t.getSource()); + } + return Status.OK_STATUS; } } @@ -145,35 +182,112 @@ public Status process(BuySellEntry entry, Account account, Portfolio portfolio) { PortfolioTransaction t = entry.getPortfolioTransaction(); - PortfolioTransaction delivery = new PortfolioTransaction(); - delivery.setType(t.getType() == PortfolioTransaction.Type.BUY ? PortfolioTransaction.Type.DELIVERY_INBOUND - : PortfolioTransaction.Type.DELIVERY_OUTBOUND); - - delivery.setDateTime(t.getDateTime()); - delivery.setSecurity(t.getSecurity()); - delivery.setMonetaryAmount(t.getMonetaryAmount()); - delivery.setNote(t.getNote()); - delivery.setSource(t.getSource()); - delivery.setShares(t.getShares()); - delivery.addUnits(t.getUnits()); - - return process(delivery, portfolio); + new LedgerDeliveryTransactionCreator(client).create(portfolio, + t.getType() == PortfolioTransaction.Type.BUY ? PortfolioTransaction.Type.DELIVERY_INBOUND + : PortfolioTransaction.Type.DELIVERY_OUTBOUND, + t.getDateTime(), t.getAmount(), t.getCurrencyCode(), t.getSecurity(), t.getShares(), null, + null, t.getUnits().toList(), t.getNote(), t.getSource()); } else { - entry.setPortfolio(portfolio); - entry.setAccount(account); - entry.insert(); - return Status.OK_STATUS; + PortfolioTransaction t = entry.getPortfolioTransaction(); + new LedgerBuySellTransactionCreator(client).create(portfolio, account, t.getType(), t.getDateTime(), + t.getAmount(), t.getCurrencyCode(), t.getSecurity(), t.getShares(), t.getUnits().toList(), + t.getNote(), t.getSource()); } + + return Status.OK_STATUS; + } + + private void updateLedgerBackedInvestmentPlanTransaction(Transaction existingTransaction, BuySellEntry importedEntry) + { + if (!(existingTransaction instanceof PortfolioTransaction existingPortfolioTransaction)) + throw new UnsupportedOperationException( + LedgerDiagnosticCode.LEDGER_UI_003.message( + Messages.LedgerInsertActionInvestmentPlanLegacySettersNotSupported)); + + PortfolioTransaction importedPortfolioTransaction = importedEntry.getPortfolioTransaction(); + + switch (existingPortfolioTransaction.getType()) + { + case BUY: + updateGeneratedBuy(existingPortfolioTransaction, importedPortfolioTransaction); + break; + case DELIVERY_INBOUND: + updateGeneratedInboundDelivery(existingPortfolioTransaction, importedPortfolioTransaction); + break; + case SELL, DELIVERY_OUTBOUND, TRANSFER_IN, TRANSFER_OUT: + throw new UnsupportedOperationException( + LedgerDiagnosticCode.LEDGER_UI_004.message(MessageFormat.format( + Messages.LedgerInsertActionUnsupportedInvestmentPlanTransactionUpdateType, + existingPortfolioTransaction.getType()))); + default: + throw new UnsupportedOperationException( + LedgerDiagnosticCode.LEDGER_UI_005.message(MessageFormat.format( + Messages.LedgerInsertActionUnsupportedInvestmentPlanTransactionUpdateType, + existingPortfolioTransaction.getType()))); + } + } + + private void updateGeneratedBuy(PortfolioTransaction existingPortfolioTransaction, + PortfolioTransaction importedPortfolioTransaction) + { + if (importedPortfolioTransaction.getType() != PortfolioTransaction.Type.BUY) + throw new UnsupportedOperationException( + LedgerDiagnosticCode.LEDGER_UI_006.message( + Messages.LedgerInsertActionGeneratedBuyTypeMismatch)); + + if (!(existingPortfolioTransaction.getCrossEntry() instanceof BuySellEntry existingEntry)) + throw new UnsupportedOperationException( + LedgerDiagnosticCode.LEDGER_UI_007 + .message(Messages.LedgerInsertActionGeneratedBuyMissingBuySellEntry)); + + new LedgerBuySellTransactionCreator(client).update(existingEntry, existingEntry.getPortfolio(), + existingEntry.getAccount(), PortfolioTransaction.Type.BUY, + importedPortfolioTransaction.getDateTime(), importedPortfolioTransaction.getAmount(), + importedPortfolioTransaction.getCurrencyCode(), importedPortfolioTransaction.getSecurity(), + importedPortfolioTransaction.getShares(), importedPortfolioTransaction.getUnits().toList(), + importedPortfolioTransaction.getNote(), importedPortfolioTransaction.getSource()); + } + + private void updateGeneratedInboundDelivery(PortfolioTransaction existingPortfolioTransaction, + PortfolioTransaction importedPortfolioTransaction) + { + if (importedPortfolioTransaction.getType() != PortfolioTransaction.Type.BUY) + throw new UnsupportedOperationException( + LedgerDiagnosticCode.LEDGER_UI_008.message( + Messages.LedgerInsertActionGeneratedDeliveryTypeMismatch)); + + new LedgerDeliveryTransactionCreator(client).update(existingPortfolioTransaction, + ownerOf(existingPortfolioTransaction), PortfolioTransaction.Type.DELIVERY_INBOUND, + importedPortfolioTransaction.getDateTime(), importedPortfolioTransaction.getAmount(), + importedPortfolioTransaction.getCurrencyCode(), importedPortfolioTransaction.getSecurity(), + importedPortfolioTransaction.getShares(), null, null, + importedPortfolioTransaction.getUnits().toList(), importedPortfolioTransaction.getNote(), + importedPortfolioTransaction.getSource()); + } + + private Portfolio ownerOf(PortfolioTransaction transaction) + { + return client.getPortfolios().stream().filter(portfolio -> portfolio.getTransactions().contains(transaction)) + .findFirst() + .orElseThrow(() -> new IllegalStateException( + Messages.LedgerInsertActionGeneratedDeliveryOwnerNotFound)); } @Override public Status process(AccountTransferEntry entry, Account source, Account target) { - entry.setSourceAccount(source); - entry.setTargetAccount(target); - entry.insert(); + var sourceTransaction = entry.getSourceTransaction(); + var targetTransaction = entry.getTargetTransaction(); + var sourceForex = sourceTransaction.getUnit(Transaction.Unit.Type.GROSS_VALUE); + + new LedgerAccountTransferTransactionCreator(client).create(source, target, sourceTransaction.getDateTime(), + sourceTransaction.getAmount(), sourceTransaction.getCurrencyCode(), targetTransaction.getAmount(), + targetTransaction.getCurrencyCode(), sourceForex.map(Transaction.Unit::getForex).orElse(null), + sourceForex.map(Transaction.Unit::getExchangeRate).orElse(null), sourceTransaction.getNote(), + sourceTransaction.getSource()); + return Status.OK_STATUS; } @@ -184,9 +298,12 @@ public Status process(PortfolioTransferEntry entry, Portfolio source, Portfolio // security via the dialog) process(entry.getSourceTransaction().getSecurity()); - entry.setSourcePortfolio(source); - entry.setTargetPortfolio(target); - entry.insert(); + var sourceTransaction = entry.getSourceTransaction(); + + new LedgerPortfolioTransferTransactionCreator(client).create(source, target, sourceTransaction.getSecurity(), + sourceTransaction.getDateTime(), sourceTransaction.getShares(), sourceTransaction.getAmount(), + sourceTransaction.getCurrencyCode(), sourceTransaction.getNote(), sourceTransaction.getSource()); + return Status.OK_STATUS; } } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/InvestmentPlan.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/InvestmentPlan.java index f1149ea33a..4acdc89788 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/InvestmentPlan.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/InvestmentPlan.java @@ -10,6 +10,12 @@ import name.abuchen.portfolio.Messages; import name.abuchen.portfolio.model.Transaction.Unit; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountOnlyTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerBuySellTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerDeliveryTransactionCreator; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; import name.abuchen.portfolio.money.CurrencyConverter; import name.abuchen.portfolio.money.Money; import name.abuchen.portfolio.money.Values; @@ -19,6 +25,16 @@ public class InvestmentPlan implements Named, Adaptable, Attributable { + public static final class UnsupportedLedgerGenerationException extends IOException + { + private static final long serialVersionUID = 1L; + + public UnsupportedLedgerGenerationException(String message) + { + super(message); + } + } + public enum Type { PURCHASE_OR_DELIVERY, DEPOSIT, REMOVAL, INTEREST @@ -66,6 +82,7 @@ public enum Type private Type type; private List transactions = new ArrayList<>(); + private List ledgerExecutionRefs = new ArrayList<>(); public InvestmentPlan() { @@ -225,6 +242,19 @@ public List getTransactions() return this.transactions; } + public List getLedgerExecutionRefs() + { + if (ledgerExecutionRefs == null) + ledgerExecutionRefs = new ArrayList<>(); + + return ledgerExecutionRefs; + } + + public void addLedgerExecutionRef(LedgerExecutionRef executionRef) + { + getLedgerExecutionRefs().add(executionRef); + } + /** * Returns a list of transaction pairs, i.e. transaction and the owner * (account or portfolio). As the list of transactions is part of the XML @@ -243,6 +273,17 @@ public List> getTransactions(Client client) (PortfolioTransaction) t)); } + for (LedgerExecutionRef ref : getLedgerExecutionRefs()) + { + var transaction = ref.resolve(client); + + if (transaction instanceof AccountTransaction at) + answer.add(new TransactionPair<>(lookupOwner(client, at), at)); + else + answer.add(new TransactionPair<>(lookupOwner(client, (PortfolioTransaction) transaction), + (PortfolioTransaction) transaction)); + } + return answer; } @@ -277,11 +318,111 @@ private Portfolio lookupOwner(Client client, PortfolioTransaction t) public void removeTransaction(PortfolioTransaction transaction) { this.transactions.remove(transaction); + removeLedgerExecutionRef(transaction); } public void removeTransaction(AccountTransaction transaction) { this.transactions.remove(transaction); + removeLedgerExecutionRef(transaction); + } + + private void removeLedgerExecutionRef(Transaction transaction) + { + if (!(transaction instanceof LedgerBackedTransaction ledgerBackedTransaction)) + return; + + var ref = LedgerExecutionRef.of(ledgerBackedTransaction); + getLedgerExecutionRefs().removeIf(existing -> sameExecutionRef(existing, ref)); + } + + public void removeLedgerExecutionRefs(LedgerEntry entry) + { + java.util.Objects.requireNonNull(entry); + + getLedgerExecutionRefs().removeIf(existing -> entry.getProjectionRefs().stream().anyMatch(projection -> // + sameExecutionRef(existing, + new LedgerExecutionRef(entry.getUUID(), projection.getUUID(), + projection.getRole())))); + } + + private boolean sameExecutionRef(LedgerExecutionRef left, LedgerExecutionRef right) + { + return java.util.Objects.equals(left.getLedgerEntryUUID(), right.getLedgerEntryUUID()) + && java.util.Objects.equals(left.getProjectionUUID(), right.getProjectionUUID()) + && left.getProjectionRole() == right.getProjectionRole(); + } + + public static final class LedgerExecutionRef + { + private String ledgerEntryUUID; + private String projectionUUID; + private LedgerProjectionRole projectionRole; + + public LedgerExecutionRef() + { + // needed for xstream de-serialization + } + + public LedgerExecutionRef(String ledgerEntryUUID, String projectionUUID, LedgerProjectionRole projectionRole) + { + this.ledgerEntryUUID = ledgerEntryUUID; + this.projectionUUID = projectionUUID; + this.projectionRole = projectionRole; + } + + public static LedgerExecutionRef of(LedgerBackedTransaction transaction) + { + return new LedgerExecutionRef(transaction.getLedgerEntry().getUUID(), + transaction.getLedgerProjectionRef().getUUID(), + transaction.getLedgerProjectionRef().getRole()); + } + + public String getLedgerEntryUUID() + { + return ledgerEntryUUID; + } + + public String getProjectionUUID() + { + return projectionUUID; + } + + public LedgerProjectionRole getProjectionRole() + { + return projectionRole; + } + + private Transaction resolve(Client client) + { + var candidates = new ArrayList(); + + for (var account : client.getAccounts()) + account.getTransactions().stream().filter(this::matches).forEach(candidates::add); + + for (var portfolio : client.getPortfolios()) + portfolio.getTransactions().stream().filter(this::matches).forEach(candidates::add); + + if (candidates.size() == 1) + return candidates.get(0); + + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CORE_001 + .message("Ambiguous ledger execution reference " + ledgerEntryUUID)); //$NON-NLS-1$ + } + + private boolean matches(Transaction transaction) + { + if (!(transaction instanceof LedgerBackedTransaction ledgerBackedTransaction)) + return false; + + return ledgerEntryUUID.equals(ledgerBackedTransaction.getLedgerEntry().getUUID()) + && (projectionUUID == null + || projectionUUID.equals( + ledgerBackedTransaction.getLedgerProjectionRef().getUUID())) + && (projectionRole == null + || projectionRole == ledgerBackedTransaction.getLedgerProjectionRef() + .getRole()); + } } public String getCurrencyCode() @@ -318,6 +459,20 @@ public Optional getLastDate() return Optional.ofNullable(last); } + public Optional getLastDate(Client client) + { + LocalDate last = getLastDate().orElse(null); + + for (LedgerExecutionRef ref : getLedgerExecutionRefs()) + { + LocalDate date = ref.resolve(client).getDateTime().toLocalDate(); + if (last == null || last.isBefore(date)) + last = date; + } + + return Optional.ofNullable(last); + } + /** * Returns the date for the next transaction to be generated based on the * interval @@ -430,6 +585,54 @@ public List> generateTransactions(CurrencyConverter converter return newlyCreated; } + public LocalDate getDateOfNextTransactionToBeGenerated(Client client) + { + Optional lastDate = getLastDate(client); + + if (lastDate.isPresent()) + { + return next(lastDate.get()); + } + else + { + LocalDate startDate = start.toLocalDate(); + + TradeCalendar tradeCalendar = security != null ? TradeCalendarManager.getInstance(security) + : TradeCalendarManager.getDefaultInstance(); + while (tradeCalendar.isHoliday(startDate)) + startDate = startDate.plusDays(1); + + return startDate; + } + } + + public List> generateTransactions(Client client, CurrencyConverter converter) throws IOException + { + LocalDate transactionDate = getDateOfNextTransactionToBeGenerated(client); + List> newlyCreated = new ArrayList<>(); + + LocalDate now = LocalDate.now(); + + while (!transactionDate.isAfter(now)) + { + TransactionPair transaction = createLedgerTransaction(client, converter, transactionDate); + addLedgerExecutionRefIfAbsent((LedgerBackedTransaction) transaction.getTransaction()); + newlyCreated.add(transaction); + + transactionDate = next(transactionDate); + } + + return newlyCreated; + } + + private void addLedgerExecutionRefIfAbsent(LedgerBackedTransaction transaction) + { + var ref = LedgerExecutionRef.of(transaction); + + if (getLedgerExecutionRefs().stream().noneMatch(existing -> sameExecutionRef(existing, ref))) + addLedgerExecutionRef(ref); + } + private TransactionPair createTransaction(CurrencyConverter converter, LocalDate tDate) throws IOException { Type planType = getPlanType(); @@ -442,6 +645,20 @@ else if (planType == Type.DEPOSIT || planType == Type.REMOVAL || planType == Typ throw new IllegalArgumentException("unsupported plan type " + planType.name()); //$NON-NLS-1$ } + private TransactionPair createLedgerTransaction(Client client, CurrencyConverter converter, LocalDate tDate) + throws IOException + { + Type planType = getPlanType(); + + if (planType == Type.PURCHASE_OR_DELIVERY) + return createLedgerSecurityTx(client, converter, tDate); + else if (planType == Type.DEPOSIT || planType == Type.REMOVAL || planType == Type.INTEREST) + return createLedgerAccountTx(client, converter, tDate); + else + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CORE_002 + .message("unsupported plan type " + planType.name())); //$NON-NLS-1$ + } + private TransactionPair createSecurityTx(CurrencyConverter converter, LocalDate tDate) throws IOException { String targetCurrencyCode = getCurrencyCode(); @@ -525,6 +742,70 @@ private TransactionPair createSecurityTx(CurrencyConverter converter, LocalDa } } + private TransactionPair createLedgerSecurityTx(Client client, CurrencyConverter converter, LocalDate tDate) + throws IOException + { + var generated = generatedSecurityFacts(converter, tDate); + var note = MessageFormat.format(Messages.InvestmentPlanAutoNoteLabel, + Values.DateTime.format(LocalDateTime.now()), name); + + if (account != null) + { + var entry = new LedgerBuySellTransactionCreator(client).create(portfolio, account, + PortfolioTransaction.Type.BUY, tDate.atStartOfDay(), amount, generated.currencyCode(), + getSecurity(), generated.shares(), generated.units(), note, null); + return new TransactionPair<>(portfolio, entry.getPortfolioTransaction()); + } + else + { + var transaction = new LedgerDeliveryTransactionCreator(client).create(portfolio, + PortfolioTransaction.Type.DELIVERY_INBOUND, tDate.atStartOfDay(), amount, + generated.currencyCode(), security, generated.shares(), null, null, generated.units(), note, + null); + return new TransactionPair<>(portfolio, transaction); + } + } + + private GeneratedSecurityFacts generatedSecurityFacts(CurrencyConverter converter, LocalDate tDate) + throws IOException + { + String targetCurrencyCode = getCurrencyCode(); + boolean needsCurrencyConversion = !targetCurrencyCode.equals(security.getCurrencyCode()); + + Transaction.Unit forex = null; + long price = getSecurity().getSecurityPrice(tDate).getValue(); + + if (price == 0L) + throw new IOException(MessageFormat.format( + Messages.MsgErrorInvestmentPlanMissingSecurityPricesToGenerateTransaction, + getSecurity().getName())); + + long availableAmount = amount - fees - taxes; + + if (needsCurrencyConversion) + { + Money availableMoney = Money.of(targetCurrencyCode, availableAmount); + availableAmount = converter.with(security.getCurrencyCode()).convert(tDate, availableMoney).getAmount(); + + forex = new Transaction.Unit(Unit.Type.GROSS_VALUE, availableMoney, + Money.of(security.getCurrencyCode(), availableAmount), + converter.with(targetCurrencyCode).getRate(tDate, security.getCurrencyCode()).getValue()); + } + + long shares = Math + .round((double) availableAmount * Values.Share.factor() * Values.Quote.factorToMoney() / price); + + var units = new ArrayList(); + if (fees != 0) + units.add(new Transaction.Unit(Unit.Type.FEE, Money.of(targetCurrencyCode, fees))); + if (taxes != 0) + units.add(new Transaction.Unit(Unit.Type.TAX, Money.of(targetCurrencyCode, taxes))); + if (forex != null) + units.add(forex); + + return new GeneratedSecurityFacts(targetCurrencyCode, shares, units); + } + private TransactionPair createAccountTx(CurrencyConverter converter, LocalDate tDate) { AccountTransaction.Type transactionType; @@ -566,6 +847,55 @@ private TransactionPair createAccountTx(CurrencyConverter converter, LocalDat return new TransactionPair<>(account, transaction); } + private TransactionPair createLedgerAccountTx(Client client, CurrencyConverter converter, LocalDate tDate) + throws UnsupportedLedgerGenerationException + { + AccountTransaction.Type transactionType; + + switch (type) + { + case DEPOSIT: + transactionType = AccountTransaction.Type.DEPOSIT; + break; + case REMOVAL: + transactionType = AccountTransaction.Type.REMOVAL; + break; + case INTEREST: + transactionType = AccountTransaction.Type.INTEREST; + break; + default: + throw new IllegalArgumentException(); + } + + Money monetaryAmount = Money.of(getCurrencyCode(), amount); + + boolean needsCurrencyConversion = !getCurrencyCode().equals(account.getCurrencyCode()); + if (needsCurrencyConversion) + monetaryAmount = converter.with(account.getCurrencyCode()).at(tDate).apply(monetaryAmount); + + var units = new ArrayList(); + if (taxes != 0) + units.add(new Transaction.Unit(Transaction.Unit.Type.TAX, Money.of(account.getCurrencyCode(), taxes))); + + if (!units.isEmpty() + && (transactionType == AccountTransaction.Type.DEPOSIT + || transactionType == AccountTransaction.Type.REMOVAL)) + throw new UnsupportedLedgerGenerationException(LedgerDiagnosticCode.LEDGER_CORE_003 + .message("Ledger investment plan generation cannot preserve units for " + transactionType)); //$NON-NLS-1$ + + var transaction = new LedgerAccountOnlyTransactionCreator(client).create(account, transactionType, + tDate.atStartOfDay(), monetaryAmount.getAmount(), monetaryAmount.getCurrencyCode(), null, units, + MessageFormat.format(Messages.InvestmentPlanAutoNoteLabel, + Values.DateTime.format(LocalDateTime.now()), name), + null); + + return new TransactionPair<>(account, transaction); + } + + private record GeneratedSecurityFacts(String currencyCode, long shares, List units) + { + } + @Override public String toString() { diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerPlanReferenceSupport.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerPlanReferenceSupport.java new file mode 100644 index 0000000000..9d07509c5c --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerPlanReferenceSupport.java @@ -0,0 +1,89 @@ +package name.abuchen.portfolio.model; + +import java.util.Objects; + +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; + +/** + * Checks whether generated transaction references can follow a Ledger conversion. + * This is internal model support for investment-plan references. It treats plan references + * as links to generated transactions, not as ownership of the transaction. + */ +final class LedgerPlanReferenceSupport +{ + private LedgerPlanReferenceSupport() + { + } + + static RoleChange roleChange(String projectionUUID, LedgerProjectionRole sourceRole, + LedgerProjectionRole targetRole) + { + return new RoleChange(Objects.requireNonNull(projectionUUID), Objects.requireNonNull(sourceRole), + Objects.requireNonNull(targetRole)); + } + + static boolean currentRefsResolveUniquely(Client client, LedgerEntry entry) + { + return refsForEntry(client, entry).allMatch(ref -> matchingProjections(entry, ref) == 1); + } + + static boolean refsFollowRoleChanges(Client client, LedgerEntry entry, RoleChange... changes) + { + return currentRefsResolveUniquely(client, entry) + && refsForEntry(client, entry).allMatch(ref -> canFollowAny(ref, changes)); + } + + static String projectionUUID(LedgerEntry entry, LedgerProjectionRole role) + { + return entry.getProjectionRefs().stream() // + .filter(projection -> projection.getRole() == role) // + .map(LedgerProjectionRef::getUUID) // + .findFirst() // + .orElse(""); //$NON-NLS-1$ + } + + private static java.util.stream.Stream refsForEntry(Client client, + LedgerEntry entry) + { + return client.getPlans().stream() // + .flatMap(plan -> plan.getLedgerExecutionRefs().stream()) // + .filter(ref -> entry.getUUID().equals(ref.getLedgerEntryUUID())); + } + + private static long matchingProjections(LedgerEntry entry, InvestmentPlan.LedgerExecutionRef ref) + { + return entry.getProjectionRefs().stream().filter(projection -> matches(ref, projection)).count(); + } + + private static boolean matches(InvestmentPlan.LedgerExecutionRef ref, LedgerProjectionRef projection) + { + return (ref.getProjectionUUID() == null || ref.getProjectionUUID().equals(projection.getUUID())) + && (ref.getProjectionRole() == null || ref.getProjectionRole() == projection.getRole()); + } + + private static boolean canFollowAny(InvestmentPlan.LedgerExecutionRef ref, RoleChange... changes) + { + for (var change : changes) + if (canFollow(ref, change)) + return true; + + return false; + } + + private static boolean canFollow(InvestmentPlan.LedgerExecutionRef ref, RoleChange change) + { + if (ref.getProjectionUUID() != null && !ref.getProjectionUUID().equals(change.projectionUUID())) + return false; + + if (ref.getProjectionUUID() == null && ref.getProjectionRole() == null) + return false; + + return ref.getProjectionRole() == null || ref.getProjectionRole() == change.sourceRole(); + } + + record RoleChange(String projectionUUID, LedgerProjectionRole sourceRole, LedgerProjectionRole targetRole) + { + } +} From 93f8839701756b5095d12b071160f5450e7a6a86 Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Fri, 26 Jun 2026 14:30:46 +0200 Subject: [PATCH 10/68] Add Ledger reporting snapshot bridge Add the Ledger bridge for reporting snapshots and related scenario coverage. This keeps reporting effects reviewable separately from transaction write paths. Diagnostics, NLS, persistence, and documentation are intentionally left unchanged in this commit. --- .../ClientPerformanceSnapshotTest.java | 205 ++++++++++++++++++ .../AccountPerformanceTaxRefundTestCase.java | 22 +- .../SecurityPerformanceTaxRefundTestCase.java | 12 +- .../snapshot/ClientPerformanceSnapshot.java | 34 ++- 4 files changed, 266 insertions(+), 7 deletions(-) diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/snapshot/ClientPerformanceSnapshotTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/snapshot/ClientPerformanceSnapshotTest.java index 583c88861d..e736e2a253 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/snapshot/ClientPerformanceSnapshotTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/snapshot/ClientPerformanceSnapshotTest.java @@ -28,6 +28,30 @@ import name.abuchen.portfolio.model.SecurityPrice; import name.abuchen.portfolio.model.Transaction; import name.abuchen.portfolio.model.Transaction.Unit; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.LedgerTransactionMetadata; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountCashLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerCreationUnit; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerCreationUnits; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerTransactionCreator; +import name.abuchen.portfolio.model.ledger.configuration.CashCompensationKind; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionKind; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionSubtype; +import name.abuchen.portfolio.model.ledger.configuration.EventStage; +import name.abuchen.portfolio.model.ledger.configuration.FeeReason; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.nativeentry.LedgerNativeEntryAssembler; +import name.abuchen.portfolio.model.ledger.nativeentry.NativeCashCompensation; +import name.abuchen.portfolio.model.ledger.nativeentry.NativeCorporateActionEvent; +import name.abuchen.portfolio.model.ledger.nativeentry.NativeEntryMetadata; +import name.abuchen.portfolio.model.ledger.nativeentry.NativeFee; +import name.abuchen.portfolio.model.ledger.nativeentry.NativeSecurityLeg; +import name.abuchen.portfolio.model.ledger.nativeentry.NativeTax; +import name.abuchen.portfolio.model.ledger.nativeentry.Ratio; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionService; import name.abuchen.portfolio.money.CurrencyConverter; import name.abuchen.portfolio.money.CurrencyUnit; import name.abuchen.portfolio.money.Money; @@ -311,6 +335,117 @@ public void testCapitalGainsWithPartialSellDuringReportPeriodWithFees() .subtract(snapshot.getValue(CategoryType.INITIAL_VALUE)))); } + @Test + public void testLedgerNativeDepositProjectionUnitsCountedAsFeesAndTaxes() + { + Client client = new Client(); + Account account = account(); + Portfolio portfolio = new Portfolio(); + Security security = new Security("SpinCo", CurrencyUnit.EUR); + + client.addAccount(account); + client.addPortfolio(portfolio); + client.addSecurity(security); + + LedgerNativeEntryAssembler.forClient(client).forType(LedgerEntryType.STOCK_DIVIDEND) + .metadata(nativeMetadata()) + .event(nativeEvent(LedgerEntryType.STOCK_DIVIDEND)) + .securityLeg(NativeSecurityLeg.target() // + .portfolio(portfolio) // + .security(security) // + .shares(Values.Share.factorize(1)) // + .amount(Money.of(CurrencyUnit.EUR, 50_00)) // + .targetSecurity(security) // + .ratio(Ratio.of(BigDecimal.ONE, BigDecimal.ONE)) // + .projectAs(LedgerProjectionRole.DELIVERY_INBOUND) // + .build()) // + .cashCompensation(NativeCashCompensation.builder() // + .account(account) // + .amount(Money.of(CurrencyUnit.EUR, 5_00)) // + .kind(CashCompensationKind.CASH_IN_LIEU) // + .build()) // + .fee(NativeFee.of(account, Money.of(CurrencyUnit.EUR, 2_00), + FeeReason.CORPORATE_ACTION_FEE)) // + .tax(NativeTax.withholding(account, Money.of(CurrencyUnit.EUR, 1_00))) // + .buildAndAdd(); + LedgerProjectionService.materialize(client); + + ClientPerformanceSnapshot snapshot = new ClientPerformanceSnapshot(client, new TestCurrencyConverter(), + startDate, endDate); + + assertThat(snapshot.getValue(CategoryType.FEES), is(Money.of(CurrencyUnit.EUR, 2_00))); + assertThat(snapshot.getValue(CategoryType.TAXES), is(Money.of(CurrencyUnit.EUR, 1_00))); + assertThat(snapshot.getFees().size(), is(1)); + assertThat(snapshot.getTaxes().size(), is(1)); + } + + @Test + public void testLedgerNativeRemovalProjectionUnitsCountedAsFeesAndTaxes() + { + Client client = new Client(); + Account account = account(); + var transaction = ledgerBackedAccountTransaction(LedgerEntryType.RIGHTS_DISTRIBUTION, + AccountTransaction.Type.REMOVAL, 5_00); + + transaction.addUnit(new Unit(Unit.Type.FEE, Money.of(CurrencyUnit.EUR, 2_00))); + transaction.addUnit(new Unit(Unit.Type.TAX, Money.of(CurrencyUnit.EUR, 1_00))); + account.addTransaction(transaction); + client.addAccount(account); + + ClientPerformanceSnapshot snapshot = new ClientPerformanceSnapshot(client, new TestCurrencyConverter(), + startDate, endDate); + + assertThat(snapshot.getValue(CategoryType.FEES), is(Money.of(CurrencyUnit.EUR, 2_00))); + assertThat(snapshot.getValue(CategoryType.TAXES), is(Money.of(CurrencyUnit.EUR, 1_00))); + assertThat(snapshot.getValue(CategoryType.TRANSFERS), is(Money.of(CurrencyUnit.EUR, -5_00))); + } + + @Test + public void testManualDepositAndRemovalUnitsAreNotCountedAsFeesAndTaxes() + { + Client client = new Client(); + Account account = account(); + var deposit = accountTransaction(AccountTransaction.Type.DEPOSIT, 5_00); + var removal = accountTransaction(AccountTransaction.Type.REMOVAL, 6_00); + + deposit.addUnit(new Unit(Unit.Type.FEE, Money.of(CurrencyUnit.EUR, 2_00))); + deposit.addUnit(new Unit(Unit.Type.TAX, Money.of(CurrencyUnit.EUR, 1_00))); + removal.addUnit(new Unit(Unit.Type.FEE, Money.of(CurrencyUnit.EUR, 3_00))); + removal.addUnit(new Unit(Unit.Type.TAX, Money.of(CurrencyUnit.EUR, 4_00))); + account.addTransaction(deposit); + account.addTransaction(removal); + client.addAccount(account); + + ClientPerformanceSnapshot snapshot = new ClientPerformanceSnapshot(client, new TestCurrencyConverter(), + startDate, endDate); + + assertThat(snapshot.getValue(CategoryType.FEES), is(Money.of(CurrencyUnit.EUR, 0))); + assertThat(snapshot.getValue(CategoryType.TAXES), is(Money.of(CurrencyUnit.EUR, 0))); + } + + @Test + public void testLegacyFixedLedgerBackedDepositAndRemovalUnitsAreNotCountedAsFeesAndTaxes() + { + Client client = new Client(); + Account account = account(); + var units = LedgerCreationUnits.of(LedgerCreationUnit.fee(Money.of(CurrencyUnit.EUR, 2_00)), + LedgerCreationUnit.tax(Money.of(CurrencyUnit.EUR, 1_00))); + var creator = new LedgerTransactionCreator(client); + + client.addAccount(account); + creator.createDeposit(LedgerTransactionMetadata.of(LocalDateTime.of(2011, Month.MARCH, 1, 0, 0)), + LedgerAccountCashLeg.of(account, Money.of(CurrencyUnit.EUR, 5_00)), units); + creator.createRemoval(LedgerTransactionMetadata.of(LocalDateTime.of(2011, Month.MARCH, 2, 0, 0)), + LedgerAccountCashLeg.of(account, Money.of(CurrencyUnit.EUR, 6_00)), units); + LedgerProjectionService.materialize(client); + + ClientPerformanceSnapshot snapshot = new ClientPerformanceSnapshot(client, new TestCurrencyConverter(), + startDate, endDate); + + assertThat(snapshot.getValue(CategoryType.FEES), is(Money.of(CurrencyUnit.EUR, 0))); + assertThat(snapshot.getValue(CategoryType.TAXES), is(Money.of(CurrencyUnit.EUR, 0))); + } + @Test public void testCurrencyGainsWithIntermediateTransactions() { @@ -705,6 +840,52 @@ public void testForexCapitalGainsWithShortSale() .getPosition().getShares(), is(Values.Share.factorize(-1))); } + private Account account() + { + var account = new Account(); + account.setCurrencyCode(CurrencyUnit.EUR); + return account; + } + + private AccountTransaction accountTransaction(AccountTransaction.Type type, long amount) + { + var transaction = new AccountTransaction(); + transaction.setType(type); + transaction.setDateTime(LocalDateTime.of(2011, Month.MARCH, 1, 0, 0)); + transaction.setCurrencyCode(CurrencyUnit.EUR); + transaction.setAmount(amount); + return transaction; + } + + private NativeEntryMetadata nativeMetadata() + { + return NativeEntryMetadata.of(LocalDateTime.of(2011, Month.MARCH, 1, 0, 0)); + } + + private NativeCorporateActionEvent nativeEvent(LedgerEntryType entryType) + { + return NativeCorporateActionEvent.builder() // + .kind(CorporateActionKind.valueOf(entryType.name())) // + .subtype(CorporateActionSubtype.STANDARD) // + .stage(EventStage.SETTLED) // + .effectiveDate(LocalDate.of(2011, Month.MARCH, 1)) // + .build(); + } + + private LedgerBackedAccountTransactionStub ledgerBackedAccountTransaction(LedgerEntryType entryType, + AccountTransaction.Type transactionType, long amount) + { + var entry = new LedgerEntry(); + entry.setType(entryType); + + var transaction = new LedgerBackedAccountTransactionStub(entry); + transaction.setType(transactionType); + transaction.setDateTime(LocalDateTime.of(2011, Month.MARCH, 1, 0, 0)); + transaction.setCurrencyCode(CurrencyUnit.EUR); + transaction.setAmount(amount); + return transaction; + } + public static void assertThatCalculationWorksOut(ClientPerformanceSnapshot snapshot, CurrencyConverter converter) { MutableMoney valueAtEndOfPeriod = MutableMoney.of(converter.getTermCurrency()); @@ -719,4 +900,28 @@ public static void assertThatCalculationWorksOut(ClientPerformanceSnapshot snaps assertThat(valueAtEndOfPeriod.toMoney(), is(snapshot.getValue(CategoryType.FINAL_VALUE))); } + + private static final class LedgerBackedAccountTransactionStub extends AccountTransaction + implements LedgerBackedTransaction + { + private final LedgerEntry entry; + private final LedgerProjectionRef projectionRef = new LedgerProjectionRef(); + + private LedgerBackedAccountTransactionStub(LedgerEntry entry) + { + this.entry = entry; + } + + @Override + public LedgerEntry getLedgerEntry() + { + return entry; + } + + @Override + public LedgerProjectionRef getLedgerProjectionRef() + { + return projectionRef; + } + } } diff --git a/name.abuchen.portfolio.tests/src/scenarios/AccountPerformanceTaxRefundTestCase.java b/name.abuchen.portfolio.tests/src/scenarios/AccountPerformanceTaxRefundTestCase.java index 39976ca314..d1b2e04b39 100644 --- a/name.abuchen.portfolio.tests/src/scenarios/AccountPerformanceTaxRefundTestCase.java +++ b/name.abuchen.portfolio.tests/src/scenarios/AccountPerformanceTaxRefundTestCase.java @@ -20,6 +20,9 @@ import name.abuchen.portfolio.model.Client; import name.abuchen.portfolio.model.ClientFactory; import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountTransactionEdit; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountTransactionEditor; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedAccountTransaction; import name.abuchen.portfolio.money.CurrencyConverter; import name.abuchen.portfolio.snapshot.PerformanceIndex; import name.abuchen.portfolio.util.Interval; @@ -57,12 +60,27 @@ public void testAccountPerformanceTaxRefund() throws IOException // if the tax_refund is for a security, it must not be included in the // performance of the account - AccountTransaction taxRefund = account.getTransactions().get(1); + AccountTransaction taxRefund = account.getTransactions().stream() // + .filter(transaction -> transaction.getType() == AccountTransaction.Type.TAX_REFUND) // + .findFirst().orElseThrow(); assertThat(taxRefund.getType(), is(AccountTransaction.Type.TAX_REFUND)); - taxRefund.setSecurity(new Security()); + setSecurity(taxRefund, new Security()); accountPerformance = PerformanceIndex.forAccount(client, converter, account, period, warnings); assertThat(warnings, empty()); assertThat(accountPerformance.getFinalAccumulatedPercentage(), lessThan(calculatedTtwror)); } + + private void setSecurity(AccountTransaction transaction, Security security) + { + if (transaction instanceof LedgerBackedAccountTransaction ledgerBacked) + { + new LedgerAccountTransactionEditor().apply(ledgerBacked, + LedgerAccountTransactionEdit.builder().security(security).build()); + } + else + { + transaction.setSecurity(security); + } + } } diff --git a/name.abuchen.portfolio.tests/src/scenarios/SecurityPerformanceTaxRefundTestCase.java b/name.abuchen.portfolio.tests/src/scenarios/SecurityPerformanceTaxRefundTestCase.java index ad18928df1..598c1a3ad2 100644 --- a/name.abuchen.portfolio.tests/src/scenarios/SecurityPerformanceTaxRefundTestCase.java +++ b/name.abuchen.portfolio.tests/src/scenarios/SecurityPerformanceTaxRefundTestCase.java @@ -22,13 +22,13 @@ import name.abuchen.portfolio.model.Classification; import name.abuchen.portfolio.model.Client; import name.abuchen.portfolio.model.ClientFactory; +import name.abuchen.portfolio.model.CostMethod; import name.abuchen.portfolio.model.Portfolio; import name.abuchen.portfolio.model.PortfolioTransaction; import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.TaxesAndFees; import name.abuchen.portfolio.model.Transaction; import name.abuchen.portfolio.model.Transaction.Unit; -import name.abuchen.portfolio.model.CostMethod; -import name.abuchen.portfolio.model.TaxesAndFees; import name.abuchen.portfolio.money.CurrencyConverter; import name.abuchen.portfolio.money.CurrencyUnit; import name.abuchen.portfolio.money.Money; @@ -156,8 +156,12 @@ public void testSecurityPerformanceTaxRefundAllSold() throws IOException SecurityTestCase.class.getResourceAsStream("security_performance_tax_refund_all_sold.xml")); Portfolio portfolio = client.getPortfolios().get(0); - PortfolioTransaction delivery = portfolio.getTransactions().get(0); - PortfolioTransaction sell = portfolio.getTransactions().get(1); + PortfolioTransaction delivery = portfolio.getTransactions().stream() // + .filter(transaction -> transaction.getType() == PortfolioTransaction.Type.DELIVERY_INBOUND) // + .findFirst().orElseThrow(); + PortfolioTransaction sell = portfolio.getTransactions().stream() // + .filter(transaction -> transaction.getType() == PortfolioTransaction.Type.SELL) // + .findFirst().orElseThrow(); Interval period = Interval.of(LocalDate.parse("2013-12-06"), LocalDate.parse("2014-12-06")); TestCurrencyConverter converter = new TestCurrencyConverter(); LazySecurityPerformanceSnapshot snapshot = LazySecurityPerformanceSnapshot.create(client, converter, period); diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/snapshot/ClientPerformanceSnapshot.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/snapshot/ClientPerformanceSnapshot.java index cd4c730499..f48f478e41 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/snapshot/ClientPerformanceSnapshot.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/snapshot/ClientPerformanceSnapshot.java @@ -18,18 +18,19 @@ import name.abuchen.portfolio.model.Adaptable; import name.abuchen.portfolio.model.Adaptor; import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.CostMethod; 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.model.Transaction.Unit; import name.abuchen.portfolio.model.TransactionPair; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; import name.abuchen.portfolio.money.CurrencyConverter; import name.abuchen.portfolio.money.Money; import name.abuchen.portfolio.money.MoneyCollectors; import name.abuchen.portfolio.money.MutableMoney; import name.abuchen.portfolio.money.Values; -import name.abuchen.portfolio.model.CostMethod; import name.abuchen.portfolio.snapshot.security.CapitalGainsRecord; import name.abuchen.portfolio.snapshot.security.LazySecurityPerformanceRecord; import name.abuchen.portfolio.snapshot.security.LazySecurityPerformanceSnapshot; @@ -458,9 +459,11 @@ private void addEarnings() break; case DEPOSIT: mDeposits.add(value); + addNativeAccountProjectionUnits(account, t, mFees, mTaxes, feesBySecurity, taxesBySecurity); break; case REMOVAL: mRemovals.add(value); + addNativeAccountProjectionUnits(account, t, mFees, mTaxes, feesBySecurity, taxesBySecurity); break; case FEES: mFees.add(value); @@ -570,6 +573,35 @@ private void addEarnings() .add(new Position(Messages.LabelRemovals, mRemovals.toMoney(), null)); } + private void addNativeAccountProjectionUnits(Account account, AccountTransaction transaction, MutableMoney mFees, + MutableMoney mTaxes, Map feesBySecurity, + Map taxesBySecurity) + { + if (!(transaction instanceof LedgerBackedTransaction ledgerBackedTransaction) + || ledgerBackedTransaction.getLedgerEntry() == null + || ledgerBackedTransaction.getLedgerEntry().getType() == null + || !ledgerBackedTransaction.getLedgerEntry().getType().isLedgerNativeTargeted()) + return; + + Money fee = transaction.getUnitSum(Unit.Type.FEE, converter); + if (!fee.isZero()) + { + mFees.add(fee); + this.fees.add(new TransactionPair(account, transaction)); + feesBySecurity.computeIfAbsent(transaction.getSecurity(), s -> MutableMoney.of(converter.getTermCurrency())) + .add(fee); + } + + Money tax = transaction.getUnitSum(Unit.Type.TAX, converter); + if (!tax.isZero()) + { + mTaxes.add(tax); + this.taxes.add(new TransactionPair(account, transaction)); + taxesBySecurity.computeIfAbsent(transaction.getSecurity(), s -> MutableMoney.of(converter.getTermCurrency())) + .add(tax); + } + } + private void addEarningTransaction(Account account, AccountTransaction transaction, MutableMoney mEarnings, Map earningsBySecurity, MutableMoney mFees, MutableMoney mTaxes, Map feesBySecurity, Map taxesBySecurity) From 47ce2a618ed04526f6181379b86599e6999e0178 Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Fri, 26 Jun 2026 14:31:09 +0200 Subject: [PATCH 11/68] Add Ledger diagnostic codes and NLS entries Add Ledger diagnostic codes, diagnostic formatting, NLS entries, translations, and locale-safe tests. This groups the support and user-facing diagnostic text work after functional wiring. XML/protobuf schema, UI routing, import behavior, and documentation are intentionally left unchanged in this commit. --- .../name/abuchen/portfolio/MessagesTest.java | 12 + .../ledger/LedgerDiagnosticCodeTest.java | 48 +++ .../LedgerDiagnosticMessageFormatterTest.java | 113 ++++++ .../abuchen/portfolio/ui/MessagesTest.java | 12 + .../name/abuchen/portfolio/ui/Messages.java | 51 +++ .../abuchen/portfolio/ui/messages.properties | 102 +++++ .../portfolio/ui/messages_ca.properties | 102 +++++ .../portfolio/ui/messages_cs.properties | 102 +++++ .../portfolio/ui/messages_da.properties | 102 +++++ .../portfolio/ui/messages_de.properties | 102 +++++ .../portfolio/ui/messages_es.properties | 102 +++++ .../portfolio/ui/messages_fi.properties | 102 +++++ .../portfolio/ui/messages_fr.properties | 102 +++++ .../portfolio/ui/messages_it.properties | 102 +++++ .../portfolio/ui/messages_nl.properties | 102 +++++ .../portfolio/ui/messages_pl.properties | 102 +++++ .../portfolio/ui/messages_pt.properties | 102 +++++ .../portfolio/ui/messages_pt_BR.properties | 102 +++++ .../portfolio/ui/messages_ru.properties | 102 +++++ .../portfolio/ui/messages_sk.properties | 102 +++++ .../portfolio/ui/messages_tr.properties | 102 +++++ .../portfolio/ui/messages_vi.properties | 102 +++++ .../portfolio/ui/messages_zh.properties | 102 +++++ .../portfolio/ui/messages_zh_TW.properties | 102 +++++ .../src/name/abuchen/portfolio/Messages.java | 71 ++++ .../abuchen/portfolio/messages.properties | 142 +++++++ .../abuchen/portfolio/messages_ca.properties | 142 +++++++ .../abuchen/portfolio/messages_cs.properties | 142 +++++++ .../abuchen/portfolio/messages_da.properties | 142 +++++++ .../abuchen/portfolio/messages_de.properties | 142 +++++++ .../abuchen/portfolio/messages_es.properties | 142 +++++++ .../abuchen/portfolio/messages_fi.properties | 142 +++++++ .../abuchen/portfolio/messages_fr.properties | 142 +++++++ .../abuchen/portfolio/messages_it.properties | 142 +++++++ .../abuchen/portfolio/messages_nl.properties | 142 +++++++ .../abuchen/portfolio/messages_pl.properties | 142 +++++++ .../abuchen/portfolio/messages_pt.properties | 142 +++++++ .../portfolio/messages_pt_BR.properties | 142 +++++++ .../abuchen/portfolio/messages_ru.properties | 142 +++++++ .../abuchen/portfolio/messages_sk.properties | 142 +++++++ .../abuchen/portfolio/messages_tr.properties | 142 +++++++ .../abuchen/portfolio/messages_vi.properties | 142 +++++++ .../abuchen/portfolio/messages_zh.properties | 142 +++++++ .../portfolio/messages_zh_TW.properties | 142 +++++++ .../portfolio/model/LedgerDiagnosticCode.java | 331 ++++++++++++++++ .../LedgerDiagnosticMessageFormatter.java | 356 ++++++++++++++++++ 46 files changed, 5630 insertions(+) create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerDiagnosticCodeTest.java create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerDiagnosticMessageFormatterTest.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerDiagnosticCode.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerDiagnosticMessageFormatter.java diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/MessagesTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/MessagesTest.java index 9902a52201..f0bcd94f6f 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/MessagesTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/MessagesTest.java @@ -1,5 +1,7 @@ package name.abuchen.portfolio; +import static org.junit.Assert.assertFalse; + import java.util.Collection; import java.util.Locale; import java.util.ResourceBundle; @@ -39,6 +41,16 @@ public void testMessages() test("name.abuchen.portfolio.messages"); //$NON-NLS-1$ } + @Test + public void testLedgerMessagesDoNotContainDiagnosticCodes() + { + ResourceBundle resources = ResourceBundle.getBundle("name.abuchen.portfolio.messages", //$NON-NLS-1$ + Locale.forLanguageTag(language)); + + resources.keySet().stream().filter(key -> key.startsWith("Ledger")) //$NON-NLS-1$ + .forEach(key -> assertFalse(key, resources.getString(key).contains("LEDGER-"))); //$NON-NLS-1$ + } + @Test public void testHolidayNameLabels() { 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..b65973b71b --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerDiagnosticMessageFormatterTest.java @@ -0,0 +1,113 @@ +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}")); + assertThat(Messages.LedgerRuntimeProjectionRestorerInvalidLedger, containsString("{0}")); + assertFalse(Messages.LedgerDiagnosticMessageFormatterTransactionContext.contains("LEDGER-")); + assertFalse(Messages.LedgerStructuralValidatorPostingCurrencyRequired.contains("LEDGER-")); + assertFalse(Messages.LedgerRuntimeProjectionRestorerInvalidLedger.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.ui.tests/src/name/abuchen/portfolio/ui/MessagesTest.java b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/MessagesTest.java index 18e9c51600..cbc42400ca 100644 --- a/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/MessagesTest.java +++ b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/MessagesTest.java @@ -1,5 +1,7 @@ package name.abuchen.portfolio.ui; +import static org.junit.Assert.assertFalse; + import java.util.Collection; import java.util.Locale; import java.util.ResourceBundle; @@ -33,6 +35,16 @@ public void testMessages() test("name.abuchen.portfolio.ui.messages", "LabelJSONPathHint"); //$NON-NLS-1$ //$NON-NLS-2$ } + @Test + public void testLedgerMessagesDoNotContainDiagnosticCodes() + { + ResourceBundle resources = ResourceBundle.getBundle("name.abuchen.portfolio.ui.messages", //$NON-NLS-1$ + Locale.forLanguageTag(language)); + + resources.keySet().stream().filter(key -> key.startsWith("Ledger")) //$NON-NLS-1$ + .forEach(key -> assertFalse(key, resources.getString(key).contains("LEDGER-"))); //$NON-NLS-1$ + } + @Test public void testSampleMessages() { diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/Messages.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/Messages.java index 1b7aaaf65c..dd3ecfd0d7 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/Messages.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/Messages.java @@ -990,6 +990,57 @@ public class Messages extends NLS public static String LabelYellowWhiteBlack; public static String LabelYes; public static String LabelYTD; + public static String LedgerAccountTransactionsPaneUnsupportedSharesInlineEdit; + public static String LedgerConvertPortfolioCompositeTypeActionUnsupportedLedgerBackedTransition; + public static String LedgerConvertTransferToDepositRemovalActionCannotConvertLedgerBackedTransfer; + public static String LedgerConvertTransferToDepositRemovalActionUnsupportedTransferEntry; + public static String LedgerExDateEditingSupportDividendOwnerNotFound; + public static String LedgerExDateEditingSupportNoSafeEditorLedgerBacked; + public static String LedgerExDateEditingSupportNoSafeEditorPolicyBlocked; + public static String LedgerNativeComponentInspectorCardinality; + public static String LedgerNativeComponentInspectorCode; + public static String LedgerNativeComponentInspectorDateTime; + public static String LedgerNativeComponentInspectorDialogTitle; + public static String LedgerNativeComponentInspectorDomain; + public static String LedgerNativeComponentInspectorEntryParameters; + public static String LedgerNativeComponentInspectorEntryType; + public static String LedgerNativeComponentInspectorEntryUUID; + public static String LedgerNativeComponentInspectorField; + public static String LedgerNativeComponentInspectorFunctionalLegs; + public static String LedgerNativeComponentInspectorGroupExpected; + public static String LedgerNativeComponentInspectorHeader; + public static String LedgerNativeComponentInspectorLegRole; + public static String LedgerNativeComponentInspectorMenu; + public static String LedgerNativeComponentInspectorNativeTargeted; + public static String LedgerNativeComponentInspectorNoNativeDefinition; + public static String LedgerNativeComponentInspectorOwner; + public static String LedgerNativeComponentInspectorParameter; + public static String LedgerNativeComponentInspectorPostingGroupUUID; + public static String LedgerNativeComponentInspectorPostingParameters; + public static String LedgerNativeComponentInspectorPostings; + public static String LedgerNativeComponentInspectorPostingType; + public static String LedgerNativeComponentInspectorPostingUUID; + public static String LedgerNativeComponentInspectorPrimaryExpected; + public static String LedgerNativeComponentInspectorPrimaryPostingUUID; + public static String LedgerNativeComponentInspectorProjectionRefs; + public static String LedgerNativeComponentInspectorProjectionRole; + public static String LedgerNativeComponentInspectorProjectionUUID; + public static String LedgerNativeComponentInspectorSelectedProjectionUUID; + public static String LedgerNativeComponentInspectorSelectedPostingGroupUUID; + public static String LedgerNativeComponentInspectorSelectedPrimaryPostingUUID; + public static String LedgerNativeComponentInspectorSelectedProjectionRole; + public static String LedgerNativeComponentInspectorShape; + public static String LedgerNativeComponentInspectorValue; + public static String LedgerNativeComponentInspectorValueKind; + public static String LedgerPropertyEditingSupportUnsupportedInlineEdit; + public static String LedgerTransactionOwnerListEditingSupportAccountTransferOwnerNotFound; + public static String LedgerTransactionOwnerListEditingSupportDistinctOwnersRequired; + public static String LedgerTransactionOwnerListEditingSupportPortfolioTransferOwnerNotFound; + public static String LedgerTransactionOwnerListEditingSupportUnsupportedOwnerEdit; + public static String LedgerTransactionOwnerListEditingSupportUnsupportedOwnerType; + public static String LedgerTransactionsViewerUnsupportedSharesInlineEdit; + public static String LedgerTransactionTypeEditingSupportPolicyBlockedTransition; + public static String LedgerTransactionTypeEditingSupportUnsupportedTransition; public static String MarkSecurityPageDescription; public static String MarkSecurityPageTitle; public static String MenuActivateDiscreetMode; diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages.properties index 5087306ef2..665571f0d5 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages.properties @@ -1972,6 +1972,108 @@ LabelYellowWhiteBlack = Yellow - White - Black LabelYes = Yes +LedgerAccountTransactionsPaneUnsupportedSharesInlineEdit = This transaction is managed by the Ledger. Shares cannot be edited directly in this table; open the transaction editor instead. + +LedgerConvertPortfolioCompositeTypeActionUnsupportedLedgerBackedTransition = This portfolio transaction is managed by the Ledger. Its type cannot be changed from this action. + +LedgerConvertTransferToDepositRemovalActionCannotConvertLedgerBackedTransfer = This account transfer is managed by the Ledger. It cannot be converted into separate deposit/removal transactions here. + +LedgerConvertTransferToDepositRemovalActionUnsupportedTransferEntry = Transaction {0} is not a supported account-transfer entry and cannot be converted. + +LedgerExDateEditingSupportDividendOwnerNotFound = The Ledger dividend needs an account projection owner, but none was found. + +LedgerExDateEditingSupportNoSafeEditorLedgerBacked = The ex-date for this Ledger-managed account transaction cannot be edited safely here. Open the full transaction editor. + +LedgerExDateEditingSupportNoSafeEditorPolicyBlocked = Editing the ex-date is disabled for this Ledger-managed account transaction type to keep the Ledger entry consistent. + +LedgerNativeComponentInspectorCardinality = Cardinality + +LedgerNativeComponentInspectorCode = Code + +LedgerNativeComponentInspectorDateTime = Date/time + +LedgerNativeComponentInspectorDialogTitle = Inspect Ledger entry details + +LedgerNativeComponentInspectorDomain = Domain + +LedgerNativeComponentInspectorEntryParameters = Entry parameters + +LedgerNativeComponentInspectorEntryType = Entry type + +LedgerNativeComponentInspectorEntryUUID = Entry UUID + +LedgerNativeComponentInspectorField = Field + +LedgerNativeComponentInspectorFunctionalLegs = Functional legs + +LedgerNativeComponentInspectorGroupExpected = Group expected + +LedgerNativeComponentInspectorHeader = Ledger entry + +LedgerNativeComponentInspectorLegRole = Leg role + +LedgerNativeComponentInspectorMenu = Inspect Ledger Entry... + +LedgerNativeComponentInspectorNativeTargeted = Native targeted + +LedgerNativeComponentInspectorNoNativeDefinition = No native Ledger definition is configured for this entry type. + +LedgerNativeComponentInspectorOwner = Owner + +LedgerNativeComponentInspectorParameter = Parameter + +LedgerNativeComponentInspectorPostingGroupUUID = Posting group UUID + +LedgerNativeComponentInspectorPostingParameters = Posting parameters + +LedgerNativeComponentInspectorPostingType = Posting type + +LedgerNativeComponentInspectorPostingUUID = Posting UUID + +LedgerNativeComponentInspectorPostings = Postings + +LedgerNativeComponentInspectorPrimaryExpected = Primary expected + +LedgerNativeComponentInspectorPrimaryPostingUUID = Primary posting UUID + +LedgerNativeComponentInspectorProjectionRefs = Projection references + +LedgerNativeComponentInspectorProjectionRole = Projection role + +LedgerNativeComponentInspectorProjectionUUID = Projection UUID + +LedgerNativeComponentInspectorSelectedPostingGroupUUID = Selected posting group UUID + +LedgerNativeComponentInspectorSelectedPrimaryPostingUUID = Selected primary posting UUID + +LedgerNativeComponentInspectorSelectedProjectionRole = Selected projection role + +LedgerNativeComponentInspectorSelectedProjectionUUID = Selected projection UUID + +LedgerNativeComponentInspectorShape = Shape + +LedgerNativeComponentInspectorValue = Value + +LedgerNativeComponentInspectorValueKind = Value kind + +LedgerPropertyEditingSupportUnsupportedInlineEdit = This value is managed by the Ledger and cannot be edited directly for {0}. Use a supported Ledger editor. + +LedgerTransactionOwnerListEditingSupportAccountTransferOwnerNotFound = The account-transfer owner could not be found: {0} + +LedgerTransactionOwnerListEditingSupportDistinctOwnersRequired = A transfer must keep two different owners. Choose a different source or target. + +LedgerTransactionOwnerListEditingSupportPortfolioTransferOwnerNotFound = The portfolio-transfer owner could not be found: {0} + +LedgerTransactionOwnerListEditingSupportUnsupportedOwnerEdit = The owner of this Ledger-managed transaction cannot be changed here: {0} + +LedgerTransactionOwnerListEditingSupportUnsupportedOwnerType = Owner type {0} is not supported for this Ledger transaction. + +LedgerTransactionTypeEditingSupportPolicyBlockedTransition = The Ledger rules for this transaction do not allow changing its transaction type here. + +LedgerTransactionTypeEditingSupportUnsupportedTransition = This Ledger-managed transaction type cannot be changed by this action. + +LedgerTransactionsViewerUnsupportedSharesInlineEdit = This transaction is managed by the Ledger. Shares cannot be edited directly in this table; open the transaction editor instead. + MarkSecurityPageDescription = The selected items will be marked as stock indexes. Indexes have no currency. MarkSecurityPageTitle = Mark securities as stock indexes diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_ca.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_ca.properties index a6e30375de..45a0d35581 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_ca.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_ca.properties @@ -1961,6 +1961,108 @@ LabelYellowWhiteBlack = Groc - Blanc - Negre LabelYes = S\u00ED +LedgerAccountTransactionsPaneUnsupportedSharesInlineEdit = Aquesta operaci\u00F3 la gestiona el Ledger. Els t\u00EDtols no es poden editar directament en aquesta taula; obriu l\u2019editor d\u2019operaci\u00F3. + +LedgerConvertPortfolioCompositeTypeActionUnsupportedLedgerBackedTransition = Aquesta operaci\u00F3 del compte de valors la gestiona el Ledger. El tipus no es pot canviar amb aquesta acci\u00F3. + +LedgerConvertTransferToDepositRemovalActionCannotConvertLedgerBackedTransfer = Aquesta transfer\u00E8ncia entre comptes la gestiona el Ledger. Aqu\u00ED no es pot convertir en un ingr\u00E9s i una retirada separats. + +LedgerConvertTransferToDepositRemovalActionUnsupportedTransferEntry = L\u2019operaci\u00F3 {0} no \u00E9s una transfer\u00E8ncia de compte admesa i no es pot convertir. + +LedgerExDateEditingSupportDividendOwnerNotFound = El dividend del Ledger necessita un propietari de projecci\u00F3 de compte, per\u00F2 no se n\u2019ha trobat cap. + +LedgerExDateEditingSupportNoSafeEditorLedgerBacked = La data ex-dividend d\u2019aquesta operaci\u00F3 de compte gestionada pel Ledger no es pot editar aqu\u00ED amb seguretat. Obriu l\u2019editor complet de l\u2019operaci\u00F3. + +LedgerExDateEditingSupportNoSafeEditorPolicyBlocked = L\u2019edici\u00F3 de la data ex-dividend est\u00E0 desactivada per a aquest tipus d\u2019operaci\u00F3 de compte gestionada pel Ledger per mantenir coherent l\u2019entrada. + +LedgerNativeComponentInspectorCardinality = Cardinalitat + +LedgerNativeComponentInspectorCode = Codi + +LedgerNativeComponentInspectorDateTime = Data/hora + +LedgerNativeComponentInspectorDialogTitle = Inspecciona els detalls de l\u2019entrada Ledger + +LedgerNativeComponentInspectorDomain = Domini + +LedgerNativeComponentInspectorEntryParameters = Par\u00E0metres de l\u2019entrada + +LedgerNativeComponentInspectorEntryType = Tipus d\u2019entrada + +LedgerNativeComponentInspectorEntryUUID = UUID de l\u2019entrada + +LedgerNativeComponentInspectorField = Camp + +LedgerNativeComponentInspectorFunctionalLegs = Components funcionals + +LedgerNativeComponentInspectorGroupExpected = Grup esperat + +LedgerNativeComponentInspectorHeader = Entrada Ledger + +LedgerNativeComponentInspectorLegRole = Rol del component + +LedgerNativeComponentInspectorMenu = Inspecciona l\u2019entrada Ledger... + +LedgerNativeComponentInspectorNativeTargeted = Natiu dirigit + +LedgerNativeComponentInspectorNoNativeDefinition = No hi ha cap definici\u00F3 Ledger nativa configurada per a aquest tipus d\u2019entrada. + +LedgerNativeComponentInspectorOwner = Propietari + +LedgerNativeComponentInspectorParameter = Par\u00E0metre + +LedgerNativeComponentInspectorPostingGroupUUID = UUID del grup d\u2019assentaments + +LedgerNativeComponentInspectorPostingParameters = Par\u00E0metres de l\u2019assentament + +LedgerNativeComponentInspectorPostingType = Tipus d\u2019assentament + +LedgerNativeComponentInspectorPostingUUID = UUID de l\u2019assentament + +LedgerNativeComponentInspectorPostings = Assentaments + +LedgerNativeComponentInspectorPrimaryExpected = Refer\u00E8ncia principal esperada + +LedgerNativeComponentInspectorPrimaryPostingUUID = UUID de l\u2019assentament principal + +LedgerNativeComponentInspectorProjectionRefs = Refer\u00E8ncies de projecci\u00F3 + +LedgerNativeComponentInspectorProjectionRole = Rol de projecci\u00F3 + +LedgerNativeComponentInspectorProjectionUUID = UUID de projecci\u00F3 + +LedgerNativeComponentInspectorSelectedPostingGroupUUID = UUID del grup d\u2019assentaments seleccionat + +LedgerNativeComponentInspectorSelectedPrimaryPostingUUID = UUID de l\u2019assentament principal seleccionat + +LedgerNativeComponentInspectorSelectedProjectionRole = Rol de projecci\u00F3 seleccionat + +LedgerNativeComponentInspectorSelectedProjectionUUID = UUID de projecci\u00F3 seleccionat + +LedgerNativeComponentInspectorShape = Estructura + +LedgerNativeComponentInspectorValue = Valor + +LedgerNativeComponentInspectorValueKind = Tipus de valor + +LedgerPropertyEditingSupportUnsupportedInlineEdit = Aquest valor el gestiona el Ledger i no es pot editar directament per a {0}. Useu un editor Ledger adm\u00E8s. + +LedgerTransactionOwnerListEditingSupportAccountTransferOwnerNotFound = No s\u2019ha trobat el propietari de la transfer\u00E8ncia entre comptes: {0} + +LedgerTransactionOwnerListEditingSupportDistinctOwnersRequired = Una transfer\u00E8ncia ha de mantenir dos propietaris diferents. Trieu un origen o una destinaci\u00F3 diferent. + +LedgerTransactionOwnerListEditingSupportPortfolioTransferOwnerNotFound = No s\u2019ha trobat el propietari de la transfer\u00E8ncia del compte de valors: {0} + +LedgerTransactionOwnerListEditingSupportUnsupportedOwnerEdit = El propietari d\u2019aquesta operaci\u00F3 gestionada pel Ledger no es pot canviar aqu\u00ED: {0} + +LedgerTransactionOwnerListEditingSupportUnsupportedOwnerType = El tipus de propietari {0} no \u00E9s adm\u00E8s per a aquesta operaci\u00F3 Ledger. + +LedgerTransactionTypeEditingSupportPolicyBlockedTransition = Les regles Ledger d\u2019aquesta operaci\u00F3 no permeten canviar-ne el tipus aqu\u00ED. + +LedgerTransactionTypeEditingSupportUnsupportedTransition = Aquest tipus d\u2019operaci\u00F3 gestionada pel Ledger no es pot canviar amb aquesta acci\u00F3. + +LedgerTransactionsViewerUnsupportedSharesInlineEdit = Aquesta operaci\u00F3 la gestiona el Ledger. Els t\u00EDtols no es poden editar directament en aquesta taula; obriu l\u2019editor d\u2019operaci\u00F3. + MarkSecurityPageDescription = Els elements seleccionats es marcaran com a \u00EDndexs borsaris (que no tenen divisa). MarkSecurityPageTitle = Marcar els valors com a \u00EDndexs borsaris diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_cs.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_cs.properties index ae2e202935..bf6800a113 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_cs.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_cs.properties @@ -1964,6 +1964,108 @@ LabelYellowWhiteBlack = \u017Dlut\u00E1 - B\u00EDl\u00E1 - \u010Cern\u00E1 LabelYes = Ano +LedgerAccountTransactionsPaneUnsupportedSharesInlineEdit = Tuto transakci spravuje Ledger. Po\u010Det akci\u00ED nelze upravit p\u0159\u00EDmo v t\u00E9to tabulce; otev\u0159ete editor transakce. + +LedgerConvertPortfolioCompositeTypeActionUnsupportedLedgerBackedTransition = Tuto transakci \u00FA\u010Dtu cenn\u00FDch pap\u00EDr\u016F spravuje Ledger. Jej\u00ED typ nelze touto akc\u00ED zm\u011Bnit. + +LedgerConvertTransferToDepositRemovalActionCannotConvertLedgerBackedTransfer = Tento p\u0159evod mezi \u00FA\u010Dty spravuje Ledger. Zde jej nelze p\u0159ev\u00E9st na samostatn\u00FD vklad a v\u00FDb\u011Br. + +LedgerConvertTransferToDepositRemovalActionUnsupportedTransferEntry = Transakce {0} nen\u00ED podporovan\u00FD p\u0159evod \u00FA\u010Dtu a nelze ji p\u0159ev\u00E9st. + +LedgerExDateEditingSupportDividendOwnerNotFound = Dividenda Ledger vy\u017Eaduje vlastn\u00EDka projekce \u00FA\u010Dtu, ale \u017E\u00E1dn\u00FD nebyl nalezen. + +LedgerExDateEditingSupportNoSafeEditorLedgerBacked = Ex-date t\u00E9to \u00FA\u010Detn\u00ED transakce spravovan\u00E9 Ledgerem zde nelze bezpe\u010Dn\u011B upravit. Otev\u0159ete \u00FApln\u00FD editor transakce. + +LedgerExDateEditingSupportNoSafeEditorPolicyBlocked = \u00DAprava ex-date je pro tento typ \u00FA\u010Detn\u00ED transakce spravovan\u00E9 Ledgerem vypnuta, aby z\u00E1znam Ledgeru z\u016Fstal konzistentn\u00ED. + +LedgerNativeComponentInspectorCardinality = Kardinalita + +LedgerNativeComponentInspectorCode = K\u00F3d + +LedgerNativeComponentInspectorDateTime = Datum/\u010Das + +LedgerNativeComponentInspectorDialogTitle = Zkontrolovat detaily z\u00E1znamu Ledger + +LedgerNativeComponentInspectorDomain = Dom\u00E9na + +LedgerNativeComponentInspectorEntryParameters = Parametry z\u00E1znamu + +LedgerNativeComponentInspectorEntryType = Typ z\u00E1znamu + +LedgerNativeComponentInspectorEntryUUID = UUID z\u00E1znamu + +LedgerNativeComponentInspectorField = Pole + +LedgerNativeComponentInspectorFunctionalLegs = Funk\u010Dn\u00ED \u010D\u00E1sti + +LedgerNativeComponentInspectorGroupExpected = O\u010Dek\u00E1v\u00E1na skupina + +LedgerNativeComponentInspectorHeader = Z\u00E1znam Ledger + +LedgerNativeComponentInspectorLegRole = Role \u010D\u00E1sti + +LedgerNativeComponentInspectorMenu = Zkontrolovat z\u00E1znam Ledger... + +LedgerNativeComponentInspectorNativeTargeted = Nativn\u011B c\u00EDlen\u00E9 + +LedgerNativeComponentInspectorNoNativeDefinition = Pro tento typ z\u00E1znamu nen\u00ED nastavena \u017E\u00E1dn\u00E1 nativn\u00ED definice Ledger. + +LedgerNativeComponentInspectorOwner = Vlastn\u00EDk + +LedgerNativeComponentInspectorParameter = Parametr + +LedgerNativeComponentInspectorPostingGroupUUID = UUID skupiny polo\u017Eek + +LedgerNativeComponentInspectorPostingParameters = Parametry polo\u017Eky + +LedgerNativeComponentInspectorPostingType = Typ polo\u017Eky + +LedgerNativeComponentInspectorPostingUUID = UUID polo\u017Eky + +LedgerNativeComponentInspectorPostings = Polo\u017Eky + +LedgerNativeComponentInspectorPrimaryExpected = O\u010Dek\u00E1v\u00E1n prim\u00E1rn\u00ED odkaz + +LedgerNativeComponentInspectorPrimaryPostingUUID = UUID prim\u00E1rn\u00ED polo\u017Eky + +LedgerNativeComponentInspectorProjectionRefs = Odkazy projekc\u00ED + +LedgerNativeComponentInspectorProjectionRole = Role projekce + +LedgerNativeComponentInspectorProjectionUUID = UUID projekce + +LedgerNativeComponentInspectorSelectedPostingGroupUUID = Vybran\u00E9 UUID skupiny polo\u017Eek + +LedgerNativeComponentInspectorSelectedPrimaryPostingUUID = Vybran\u00E9 UUID prim\u00E1rn\u00ED polo\u017Eky + +LedgerNativeComponentInspectorSelectedProjectionRole = Vybran\u00E1 role projekce + +LedgerNativeComponentInspectorSelectedProjectionUUID = Vybran\u00E9 UUID projekce + +LedgerNativeComponentInspectorShape = Struktura + +LedgerNativeComponentInspectorValue = Hodnota + +LedgerNativeComponentInspectorValueKind = Druh hodnoty + +LedgerPropertyEditingSupportUnsupportedInlineEdit = Tuto hodnotu spravuje Ledger a nelze ji pro {0} upravit p\u0159\u00EDmo. Pou\u017Eijte podporovan\u00FD editor Ledger. + +LedgerTransactionOwnerListEditingSupportAccountTransferOwnerNotFound = Vlastn\u00EDk p\u0159evodu mezi \u00FA\u010Dty nebyl nalezen: {0} + +LedgerTransactionOwnerListEditingSupportDistinctOwnersRequired = P\u0159evod mus\u00ED m\u00EDt dva r\u016Fzn\u00E9 vlastn\u00EDky. Vyberte jin\u00FD zdroj nebo c\u00EDl. + +LedgerTransactionOwnerListEditingSupportPortfolioTransferOwnerNotFound = Vlastn\u00EDk p\u0159evodu \u00FA\u010Dtu cenn\u00FDch pap\u00EDr\u016F nebyl nalezen: {0} + +LedgerTransactionOwnerListEditingSupportUnsupportedOwnerEdit = Vlastn\u00EDka t\u00E9to transakce spravovan\u00E9 Ledgerem zde nelze zm\u011Bnit: {0} + +LedgerTransactionOwnerListEditingSupportUnsupportedOwnerType = Typ vlastn\u00EDka {0} nen\u00ED pro tuto transakci Ledger podporov\u00E1n. + +LedgerTransactionTypeEditingSupportPolicyBlockedTransition = Pravidla Ledger pro tuto transakci zde neumo\u017E\u0148uj\u00ED zm\u011Bnit typ transakce. + +LedgerTransactionTypeEditingSupportUnsupportedTransition = Tento typ transakce spravovan\u00E9 Ledgerem nelze touto akc\u00ED zm\u011Bnit. + +LedgerTransactionsViewerUnsupportedSharesInlineEdit = Tuto transakci spravuje Ledger. Po\u010Det akci\u00ED nelze upravit p\u0159\u00EDmo v t\u00E9to tabulce; otev\u0159ete editor transakce. + MarkSecurityPageDescription = Vybran\u00E9 polo\u017Eky budou ozna\u010Den\u00E9 jako akciov\u00E9 indexy. Indexy nemaj\u00ED m\u011Bnu. MarkSecurityPageTitle = Ozna\u010Dit cenn\u00E9 pap\u00EDry jako akciov\u00E9 indexy diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_da.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_da.properties index 94d022b5ad..7223d47e3d 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_da.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_da.properties @@ -1961,6 +1961,108 @@ LabelYellowWhiteBlack = Gul - Hvid - Sort LabelYes = Ja +LedgerAccountTransactionsPaneUnsupportedSharesInlineEdit = Denne transaktion administreres af Ledger. Antal kan ikke redigeres direkte i denne tabel; \u00E5bn transaktionseditoren i stedet. + +LedgerConvertPortfolioCompositeTypeActionUnsupportedLedgerBackedTransition = Denne v\u00E6rdipapirkontotransaktion administreres af Ledger. Typen kan ikke \u00E6ndres med denne handling. + +LedgerConvertTransferToDepositRemovalActionCannotConvertLedgerBackedTransfer = Denne kontooverf\u00F8rsel administreres af Ledger. Den kan ikke konverteres her til en separat indbetaling og h\u00E6vning. + +LedgerConvertTransferToDepositRemovalActionUnsupportedTransferEntry = Transaktionen {0} er ikke en underst\u00F8ttet kontooverf\u00F8rsel og kan ikke konverteres. + +LedgerExDateEditingSupportDividendOwnerNotFound = Ledger-udbyttet kr\u00E6ver en ejer for kontoprojektionen, men ingen blev fundet. + +LedgerExDateEditingSupportNoSafeEditorLedgerBacked = Ex-datoen for denne Ledger-administrerede kontotransaktion kan ikke redigeres sikkert her. \u00C5bn den fulde transaktionseditor. + +LedgerExDateEditingSupportNoSafeEditorPolicyBlocked = Redigering af ex-datoen er deaktiveret for denne type Ledger-administreret kontotransaktion for at holde Ledger-posten konsistent. + +LedgerNativeComponentInspectorCardinality = Kardinalitet + +LedgerNativeComponentInspectorCode = Kode + +LedgerNativeComponentInspectorDateTime = Dato/tid + +LedgerNativeComponentInspectorDialogTitle = Inspicer detaljer for Ledger-post + +LedgerNativeComponentInspectorDomain = Dom\u00E6ne + +LedgerNativeComponentInspectorEntryParameters = Postparametre + +LedgerNativeComponentInspectorEntryType = Posttype + +LedgerNativeComponentInspectorEntryUUID = Post-UUID + +LedgerNativeComponentInspectorField = Felt + +LedgerNativeComponentInspectorFunctionalLegs = Funktionelle dele + +LedgerNativeComponentInspectorGroupExpected = Gruppe forventet + +LedgerNativeComponentInspectorHeader = Ledger-post + +LedgerNativeComponentInspectorLegRole = Delrolle + +LedgerNativeComponentInspectorMenu = Inspicer Ledger-post... + +LedgerNativeComponentInspectorNativeTargeted = Native m\u00E5lrettet + +LedgerNativeComponentInspectorNoNativeDefinition = Der er ikke konfigureret en native Ledger-definition for denne posttype. + +LedgerNativeComponentInspectorOwner = Ejer + +LedgerNativeComponentInspectorParameter = Parameter + +LedgerNativeComponentInspectorPostingGroupUUID = Postinggruppe-UUID + +LedgerNativeComponentInspectorPostingParameters = Postingparametre + +LedgerNativeComponentInspectorPostingType = Postingtype + +LedgerNativeComponentInspectorPostingUUID = Posting-UUID + +LedgerNativeComponentInspectorPostings = Postinger + +LedgerNativeComponentInspectorPrimaryExpected = Prim\u00E6r reference forventet + +LedgerNativeComponentInspectorPrimaryPostingUUID = Prim\u00E6r posting-UUID + +LedgerNativeComponentInspectorProjectionRefs = Projektionsreferencer + +LedgerNativeComponentInspectorProjectionRole = Projektionsrolle + +LedgerNativeComponentInspectorProjectionUUID = Projektions-UUID + +LedgerNativeComponentInspectorSelectedPostingGroupUUID = Valgt postinggruppe-UUID + +LedgerNativeComponentInspectorSelectedPrimaryPostingUUID = Valgt prim\u00E6r posting-UUID + +LedgerNativeComponentInspectorSelectedProjectionRole = Valgt projektionsrolle + +LedgerNativeComponentInspectorSelectedProjectionUUID = Valgt projektions-UUID + +LedgerNativeComponentInspectorShape = Struktur + +LedgerNativeComponentInspectorValue = V\u00E6rdi + +LedgerNativeComponentInspectorValueKind = V\u00E6rditype + +LedgerPropertyEditingSupportUnsupportedInlineEdit = Denne v\u00E6rdi administreres af Ledger og kan ikke redigeres direkte for {0}. Brug en underst\u00F8ttet Ledger-editor. + +LedgerTransactionOwnerListEditingSupportAccountTransferOwnerNotFound = Ejeren af kontooverf\u00F8rslen blev ikke fundet: {0} + +LedgerTransactionOwnerListEditingSupportDistinctOwnersRequired = En overf\u00F8rsel skal have to forskellige ejere. V\u00E6lg en anden kilde eller destination. + +LedgerTransactionOwnerListEditingSupportPortfolioTransferOwnerNotFound = Ejeren af v\u00E6rdipapirkontooverf\u00F8rslen blev ikke fundet: {0} + +LedgerTransactionOwnerListEditingSupportUnsupportedOwnerEdit = Ejeren af denne Ledger-administrerede transaktion kan ikke \u00E6ndres her: {0} + +LedgerTransactionOwnerListEditingSupportUnsupportedOwnerType = Ejertypen {0} underst\u00F8ttes ikke for denne Ledger-transaktion. + +LedgerTransactionTypeEditingSupportPolicyBlockedTransition = Ledger-reglerne for denne transaktion tillader ikke at \u00E6ndre transaktionstypen her. + +LedgerTransactionTypeEditingSupportUnsupportedTransition = Denne Ledger-administrerede transaktionstype kan ikke \u00E6ndres med denne handling. + +LedgerTransactionsViewerUnsupportedSharesInlineEdit = Denne transaktion administreres af Ledger. Antal kan ikke redigeres direkte i denne tabel; \u00E5bn transaktionseditoren i stedet. + MarkSecurityPageDescription = De valgte enheder vil blive markeret som aktieindeks. Indekser har ingen valuta. MarkSecurityPageTitle = Marker v\u00E6rdipapirer som aktieindekser diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_de.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_de.properties index d55c9ab525..7c1e9bd5cb 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_de.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_de.properties @@ -1965,6 +1965,108 @@ LabelYellowWhiteBlack = Gelb - Wei\u00DF - Schwarz LabelYes = Ja +LedgerAccountTransactionsPaneUnsupportedSharesInlineEdit = Diese Buchung wird vom Ledger verwaltet. Die St\u00FCckzahl kann in dieser Tabelle nicht direkt ge\u00E4ndert werden; \u00F6ffnen Sie stattdessen den Buchungseditor. + +LedgerConvertPortfolioCompositeTypeActionUnsupportedLedgerBackedTransition = Diese Depotbuchung wird vom Ledger verwaltet. Der Typ kann \u00FCber diese Aktion nicht ge\u00E4ndert werden. + +LedgerConvertTransferToDepositRemovalActionCannotConvertLedgerBackedTransfer = Diese Umbuchung zwischen Konten wird vom Ledger verwaltet. Sie kann hier nicht in separate Ein- und Auszahlungen umgewandelt werden. + +LedgerConvertTransferToDepositRemovalActionUnsupportedTransferEntry = Die Buchung {0} ist keine unterst\u00FCtzte Konto-Umbuchung und kann nicht umgewandelt werden. + +LedgerExDateEditingSupportDividendOwnerNotFound = Die Ledger-Dividende ben\u00F6tigt ein Konto als Besitzer der Projektion, aber es wurde keines gefunden. + +LedgerExDateEditingSupportNoSafeEditorLedgerBacked = Das Ex-Datum dieser vom Ledger verwalteten Kontobuchung kann hier nicht sicher ge\u00E4ndert werden. \u00D6ffnen Sie den vollst\u00E4ndigen Buchungseditor. + +LedgerExDateEditingSupportNoSafeEditorPolicyBlocked = Das Bearbeiten des Ex-Datums ist f\u00FCr diesen vom Ledger verwalteten Kontobuchungstyp deaktiviert, damit der Ledger-Eintrag konsistent bleibt. + +LedgerNativeComponentInspectorCardinality = Kardinalit\u00E4t + +LedgerNativeComponentInspectorCode = Code + +LedgerNativeComponentInspectorDateTime = Datum/Uhrzeit + +LedgerNativeComponentInspectorDialogTitle = Ledger-Eintrag pr\u00FCfen + +LedgerNativeComponentInspectorDomain = Dom\u00E4ne + +LedgerNativeComponentInspectorEntryParameters = Eintragsparameter + +LedgerNativeComponentInspectorEntryType = Eintragstyp + +LedgerNativeComponentInspectorEntryUUID = Eintrags-UUID + +LedgerNativeComponentInspectorField = Feld + +LedgerNativeComponentInspectorFunctionalLegs = Funktionale Komponenten + +LedgerNativeComponentInspectorGroupExpected = Gruppe erwartet + +LedgerNativeComponentInspectorHeader = Ledger-Eintrag + +LedgerNativeComponentInspectorLegRole = Komponentenrolle + +LedgerNativeComponentInspectorMenu = Ledger-Eintrag pr\u00FCfen... + +LedgerNativeComponentInspectorNativeTargeted = Nativ zielgerichtet + +LedgerNativeComponentInspectorNoNativeDefinition = F\u00FCr diesen Eintragstyp ist keine native Ledger-Definition konfiguriert. + +LedgerNativeComponentInspectorOwner = Besitzer + +LedgerNativeComponentInspectorParameter = Parameter + +LedgerNativeComponentInspectorPostingGroupUUID = Buchungsgruppen-UUID + +LedgerNativeComponentInspectorPostingParameters = Buchungsparameter + +LedgerNativeComponentInspectorPostingType = Buchungstyp + +LedgerNativeComponentInspectorPostingUUID = Buchungs-UUID + +LedgerNativeComponentInspectorPostings = Buchungen + +LedgerNativeComponentInspectorPrimaryExpected = Prim\u00E4rer Bezug erwartet + +LedgerNativeComponentInspectorPrimaryPostingUUID = Prim\u00E4re Buchungs-UUID + +LedgerNativeComponentInspectorProjectionRefs = Projektionsreferenzen + +LedgerNativeComponentInspectorProjectionRole = Projektionsrolle + +LedgerNativeComponentInspectorProjectionUUID = Projektions-UUID + +LedgerNativeComponentInspectorSelectedPostingGroupUUID = Ausgew\u00E4hlte Buchungsgruppen-UUID + +LedgerNativeComponentInspectorSelectedPrimaryPostingUUID = Ausgew\u00E4hlte prim\u00E4re Buchungs-UUID + +LedgerNativeComponentInspectorSelectedProjectionRole = Ausgew\u00E4hlte Projektionsrolle + +LedgerNativeComponentInspectorSelectedProjectionUUID = Ausgew\u00E4hlte Projektions-UUID + +LedgerNativeComponentInspectorShape = Struktur + +LedgerNativeComponentInspectorValue = Wert + +LedgerNativeComponentInspectorValueKind = Wertart + +LedgerPropertyEditingSupportUnsupportedInlineEdit = Dieser Wert wird vom Ledger verwaltet und kann f\u00FCr {0} nicht direkt ge\u00E4ndert werden. Verwenden Sie einen unterst\u00FCtzten Ledger-Editor. + +LedgerTransactionOwnerListEditingSupportAccountTransferOwnerNotFound = Der Besitzer der Konto-Umbuchung wurde nicht gefunden: {0} + +LedgerTransactionOwnerListEditingSupportDistinctOwnersRequired = Eine Umbuchung ben\u00F6tigt zwei unterschiedliche Besitzer. W\u00E4hlen Sie eine andere Quelle oder ein anderes Ziel. + +LedgerTransactionOwnerListEditingSupportPortfolioTransferOwnerNotFound = Der Besitzer der Depot-Umbuchung wurde nicht gefunden: {0} + +LedgerTransactionOwnerListEditingSupportUnsupportedOwnerEdit = Der Besitzer dieser vom Ledger verwalteten Buchung kann hier nicht ge\u00E4ndert werden: {0} + +LedgerTransactionOwnerListEditingSupportUnsupportedOwnerType = Der Besitzertyp {0} wird f\u00FCr diese Ledger-Buchung nicht unterst\u00FCtzt. + +LedgerTransactionTypeEditingSupportPolicyBlockedTransition = Die Ledger-Regeln f\u00FCr diese Buchung erlauben hier keine \u00C4nderung des Buchungstyps. + +LedgerTransactionTypeEditingSupportUnsupportedTransition = Dieser vom Ledger verwaltete Buchungstyp kann \u00FCber diese Aktion nicht ge\u00E4ndert werden. + +LedgerTransactionsViewerUnsupportedSharesInlineEdit = Diese Buchung wird vom Ledger verwaltet. Die St\u00FCckzahl kann in dieser Tabelle nicht direkt ge\u00E4ndert werden; \u00F6ffnen Sie stattdessen den Buchungseditor. + MarkSecurityPageDescription = Im Gegensatz zu Aktien haben Indizes keine W\u00E4hrung. Die ausgew\u00E4hlten Papiere werden ohne W\u00E4hrung konfiguriert. MarkSecurityPageTitle = Wertpapiere als Aktienindizes markieren diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_es.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_es.properties index 3669918d62..5fddfa55c6 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_es.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_es.properties @@ -1961,6 +1961,108 @@ LabelYellowWhiteBlack = Amarillo - Blanco - Negro LabelYes = S\u00ED +LedgerAccountTransactionsPaneUnsupportedSharesInlineEdit = Esta operaci\u00F3n est\u00E1 gestionada por el Ledger. Las participaciones no se pueden editar directamente en esta tabla; abra el editor de operaci\u00F3n. + +LedgerConvertPortfolioCompositeTypeActionUnsupportedLedgerBackedTransition = Esta operaci\u00F3n de cuenta de valores est\u00E1 gestionada por el Ledger. Su tipo no se puede cambiar con esta acci\u00F3n. + +LedgerConvertTransferToDepositRemovalActionCannotConvertLedgerBackedTransfer = Esta transferencia entre cuentas est\u00E1 gestionada por el Ledger. Aqu\u00ED no se puede convertir en un ingreso y una retirada separados. + +LedgerConvertTransferToDepositRemovalActionUnsupportedTransferEntry = La operaci\u00F3n {0} no es una transferencia de cuenta admitida y no se puede convertir. + +LedgerExDateEditingSupportDividendOwnerNotFound = El dividendo del Ledger necesita un propietario de proyecci\u00F3n de cuenta, pero no se ha encontrado ninguno. + +LedgerExDateEditingSupportNoSafeEditorLedgerBacked = La fecha ex-dividendo de esta operaci\u00F3n de cuenta gestionada por el Ledger no se puede editar aqu\u00ED con seguridad. Abra el editor completo de la operaci\u00F3n. + +LedgerExDateEditingSupportNoSafeEditorPolicyBlocked = La edici\u00F3n de la fecha ex-dividendo est\u00E1 desactivada para este tipo de operaci\u00F3n de cuenta gestionada por el Ledger para mantener la entrada consistente. + +LedgerNativeComponentInspectorCardinality = Cardinalidad + +LedgerNativeComponentInspectorCode = C\u00F3digo + +LedgerNativeComponentInspectorDateTime = Fecha/hora + +LedgerNativeComponentInspectorDialogTitle = Inspeccionar detalles de la entrada Ledger + +LedgerNativeComponentInspectorDomain = Dominio + +LedgerNativeComponentInspectorEntryParameters = Par\u00E1metros de la entrada + +LedgerNativeComponentInspectorEntryType = Tipo de entrada + +LedgerNativeComponentInspectorEntryUUID = UUID de la entrada + +LedgerNativeComponentInspectorField = Campo + +LedgerNativeComponentInspectorFunctionalLegs = Componentes funcionales + +LedgerNativeComponentInspectorGroupExpected = Grupo esperado + +LedgerNativeComponentInspectorHeader = Entrada Ledger + +LedgerNativeComponentInspectorLegRole = Rol del componente + +LedgerNativeComponentInspectorMenu = Inspeccionar entrada Ledger... + +LedgerNativeComponentInspectorNativeTargeted = Nativo dirigido + +LedgerNativeComponentInspectorNoNativeDefinition = No hay una definici\u00F3n Ledger nativa configurada para este tipo de entrada. + +LedgerNativeComponentInspectorOwner = Propietario + +LedgerNativeComponentInspectorParameter = Par\u00E1metro + +LedgerNativeComponentInspectorPostingGroupUUID = UUID del grupo de asiento + +LedgerNativeComponentInspectorPostingParameters = Par\u00E1metros del asiento + +LedgerNativeComponentInspectorPostingType = Tipo de asiento + +LedgerNativeComponentInspectorPostingUUID = UUID del asiento + +LedgerNativeComponentInspectorPostings = Asientos + +LedgerNativeComponentInspectorPrimaryExpected = Referencia principal esperada + +LedgerNativeComponentInspectorPrimaryPostingUUID = UUID del asiento principal + +LedgerNativeComponentInspectorProjectionRefs = Referencias de proyecci\u00F3n + +LedgerNativeComponentInspectorProjectionRole = Rol de proyecci\u00F3n + +LedgerNativeComponentInspectorProjectionUUID = UUID de proyecci\u00F3n + +LedgerNativeComponentInspectorSelectedPostingGroupUUID = UUID del grupo de asiento seleccionado + +LedgerNativeComponentInspectorSelectedPrimaryPostingUUID = UUID del asiento principal seleccionado + +LedgerNativeComponentInspectorSelectedProjectionRole = Rol de proyecci\u00F3n seleccionado + +LedgerNativeComponentInspectorSelectedProjectionUUID = UUID de proyecci\u00F3n seleccionado + +LedgerNativeComponentInspectorShape = Estructura + +LedgerNativeComponentInspectorValue = Valor + +LedgerNativeComponentInspectorValueKind = Tipo de valor + +LedgerPropertyEditingSupportUnsupportedInlineEdit = Este valor est\u00E1 gestionado por el Ledger y no se puede editar directamente para {0}. Use un editor Ledger admitido. + +LedgerTransactionOwnerListEditingSupportAccountTransferOwnerNotFound = No se ha encontrado el propietario de la transferencia entre cuentas: {0} + +LedgerTransactionOwnerListEditingSupportDistinctOwnersRequired = Una transferencia debe conservar dos propietarios distintos. Elija otro origen o destino. + +LedgerTransactionOwnerListEditingSupportPortfolioTransferOwnerNotFound = No se ha encontrado el propietario de la transferencia de cuenta de valores: {0} + +LedgerTransactionOwnerListEditingSupportUnsupportedOwnerEdit = El propietario de esta operaci\u00F3n gestionada por el Ledger no se puede cambiar aqu\u00ED: {0} + +LedgerTransactionOwnerListEditingSupportUnsupportedOwnerType = El tipo de propietario {0} no est\u00E1 admitido para esta operaci\u00F3n Ledger. + +LedgerTransactionTypeEditingSupportPolicyBlockedTransition = Las reglas Ledger de esta operaci\u00F3n no permiten cambiar aqu\u00ED el tipo de operaci\u00F3n. + +LedgerTransactionTypeEditingSupportUnsupportedTransition = Este tipo de operaci\u00F3n gestionada por el Ledger no se puede cambiar con esta acci\u00F3n. + +LedgerTransactionsViewerUnsupportedSharesInlineEdit = Esta operaci\u00F3n est\u00E1 gestionada por el Ledger. Las participaciones no se pueden editar directamente en esta tabla; abra el editor de operaci\u00F3n. + MarkSecurityPageDescription = Los elementos seleccionados se marcar\u00E1n como \u00EDndices burs\u00E1tiles (que no tienen divisa). MarkSecurityPageTitle = Marcar los valores como \u00EDndices burs\u00E1tiles diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_fi.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_fi.properties index aecc845d09..3cc732f359 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_fi.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_fi.properties @@ -1961,6 +1961,108 @@ LabelYellowWhiteBlack = Keltainen - valkoinen - musta LabelYes = Kyll\u00E4 +LedgerAccountTransactionsPaneUnsupportedSharesInlineEdit = T\u00E4t\u00E4 tapahtumaa hallitsee Ledger. Osuuksia ei voi muokata suoraan t\u00E4ss\u00E4 taulukossa; avaa tapahtuman muokkain. + +LedgerConvertPortfolioCompositeTypeActionUnsupportedLedgerBackedTransition = T\u00E4t\u00E4 s\u00E4ilytystilin tapahtumaa hallitsee Ledger. Sen tyyppi\u00E4 ei voi muuttaa t\u00E4ll\u00E4 toiminnolla. + +LedgerConvertTransferToDepositRemovalActionCannotConvertLedgerBackedTransfer = T\u00E4t\u00E4 tilisiirtoa hallitsee Ledger. Sit\u00E4 ei voi muuntaa t\u00E4ss\u00E4 erilliseksi talletukseksi ja nostoksi. + +LedgerConvertTransferToDepositRemovalActionUnsupportedTransferEntry = Tapahtuma {0} ei ole tuettu tilisiirto eik\u00E4 sit\u00E4 voi muuntaa. + +LedgerExDateEditingSupportDividendOwnerNotFound = Ledger-osinko tarvitsee tiliprojektion omistajan, mutta sit\u00E4 ei l\u00F6ytynyt. + +LedgerExDateEditingSupportNoSafeEditorLedgerBacked = T\u00E4m\u00E4n Ledgerin hallitseman tilitapahtuman ex-p\u00E4iv\u00E4\u00E4 ei voi muokata t\u00E4ss\u00E4 turvallisesti. Avaa koko tapahtuman muokkain. + +LedgerExDateEditingSupportNoSafeEditorPolicyBlocked = Ex-p\u00E4iv\u00E4n muokkaus on poistettu k\u00E4yt\u00F6st\u00E4 t\u00E4lle Ledgerin hallitsemalle tilitapahtumatyypille, jotta Ledger-kirjaus pysyy yhten\u00E4isen\u00E4. + +LedgerNativeComponentInspectorCardinality = Kardinaliteetti + +LedgerNativeComponentInspectorCode = Koodi + +LedgerNativeComponentInspectorDateTime = P\u00E4iv\u00E4/aika + +LedgerNativeComponentInspectorDialogTitle = Tarkista Ledger-kirjauksen tiedot + +LedgerNativeComponentInspectorDomain = Toimialue + +LedgerNativeComponentInspectorEntryParameters = Kirjauksen parametrit + +LedgerNativeComponentInspectorEntryType = Kirjauksen tyyppi + +LedgerNativeComponentInspectorEntryUUID = Kirjauksen UUID + +LedgerNativeComponentInspectorField = Kentt\u00E4 + +LedgerNativeComponentInspectorFunctionalLegs = Toiminnalliset osat + +LedgerNativeComponentInspectorGroupExpected = Ryhm\u00E4 odotetaan + +LedgerNativeComponentInspectorHeader = Ledger-kirjaus + +LedgerNativeComponentInspectorLegRole = Osan rooli + +LedgerNativeComponentInspectorMenu = Tarkista Ledger-kirjaus... + +LedgerNativeComponentInspectorNativeTargeted = Natiivisti kohdistettu + +LedgerNativeComponentInspectorNoNativeDefinition = T\u00E4lle kirjaustyypille ei ole m\u00E4\u00E4ritetty natiivia Ledger-m\u00E4\u00E4rityst\u00E4. + +LedgerNativeComponentInspectorOwner = Omistaja + +LedgerNativeComponentInspectorParameter = Parametri + +LedgerNativeComponentInspectorPostingGroupUUID = Kirjausryhm\u00E4n UUID + +LedgerNativeComponentInspectorPostingParameters = Kirjausrivin parametrit + +LedgerNativeComponentInspectorPostingType = Kirjausrivin tyyppi + +LedgerNativeComponentInspectorPostingUUID = Kirjausrivin UUID + +LedgerNativeComponentInspectorPostings = Kirjausrivit + +LedgerNativeComponentInspectorPrimaryExpected = Ensisijainen viite odotetaan + +LedgerNativeComponentInspectorPrimaryPostingUUID = Ensisijaisen kirjausrivin UUID + +LedgerNativeComponentInspectorProjectionRefs = Projektioviitteet + +LedgerNativeComponentInspectorProjectionRole = Projektion rooli + +LedgerNativeComponentInspectorProjectionUUID = Projektion UUID + +LedgerNativeComponentInspectorSelectedPostingGroupUUID = Valitun kirjausryhm\u00E4n UUID + +LedgerNativeComponentInspectorSelectedPrimaryPostingUUID = Valitun ensisijaisen kirjausrivin UUID + +LedgerNativeComponentInspectorSelectedProjectionRole = Valittu projektion rooli + +LedgerNativeComponentInspectorSelectedProjectionUUID = Valitun projektion UUID + +LedgerNativeComponentInspectorShape = Rakenne + +LedgerNativeComponentInspectorValue = Arvo + +LedgerNativeComponentInspectorValueKind = Arvon tyyppi + +LedgerPropertyEditingSupportUnsupportedInlineEdit = T\u00E4t\u00E4 arvoa hallitsee Ledger eik\u00E4 sit\u00E4 voi muokata suoraan kohteelle {0}. K\u00E4yt\u00E4 tuettua Ledger-muokkainta. + +LedgerTransactionOwnerListEditingSupportAccountTransferOwnerNotFound = Tilisiirron omistajaa ei l\u00F6ytynyt: {0} + +LedgerTransactionOwnerListEditingSupportDistinctOwnersRequired = Siirrossa on oltava kaksi eri omistajaa. Valitse eri l\u00E4hde tai kohde. + +LedgerTransactionOwnerListEditingSupportPortfolioTransferOwnerNotFound = S\u00E4ilytystilisiirron omistajaa ei l\u00F6ytynyt: {0} + +LedgerTransactionOwnerListEditingSupportUnsupportedOwnerEdit = T\u00E4m\u00E4n Ledgerin hallitseman tapahtuman omistajaa ei voi muuttaa t\u00E4ss\u00E4: {0} + +LedgerTransactionOwnerListEditingSupportUnsupportedOwnerType = Omistajatyyppi\u00E4 {0} ei tueta t\u00E4lle Ledger-tapahtumalle. + +LedgerTransactionTypeEditingSupportPolicyBlockedTransition = T\u00E4m\u00E4n tapahtuman Ledger-s\u00E4\u00E4nn\u00F6t eiv\u00E4t salli tapahtumatyypin muuttamista t\u00E4ss\u00E4. + +LedgerTransactionTypeEditingSupportUnsupportedTransition = T\u00E4t\u00E4 Ledgerin hallitsemaa tapahtumatyyppi\u00E4 ei voi muuttaa t\u00E4ll\u00E4 toiminnolla. + +LedgerTransactionsViewerUnsupportedSharesInlineEdit = T\u00E4t\u00E4 tapahtumaa hallitsee Ledger. Osuuksia ei voi muokata suoraan t\u00E4ss\u00E4 taulukossa; avaa tapahtuman muokkain. + MarkSecurityPageDescription = Valinnat merkataan p\u00F6rssi-indekseiksi. Indekseill\u00E4 ei ole valuuttaa. MarkSecurityPageTitle = Merkkaa arvopaperit p\u00F6rssi-indekseiksi diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_fr.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_fr.properties index 366866908a..1b793b140a 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_fr.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_fr.properties @@ -1962,6 +1962,108 @@ LabelYellowWhiteBlack = Jaune - Blanc - Noir LabelYes = Oui +LedgerAccountTransactionsPaneUnsupportedSharesInlineEdit = Cette transaction est g\u00E9r\u00E9e par le Ledger. Les parts ne peuvent pas \u00EAtre modifi\u00E9es directement dans ce tableau ; ouvrez plut\u00F4t l\u2019\u00E9diteur de transaction. + +LedgerConvertPortfolioCompositeTypeActionUnsupportedLedgerBackedTransition = Cette transaction de compte titres est g\u00E9r\u00E9e par le Ledger. Son type ne peut pas \u00EAtre modifi\u00E9 avec cette action. + +LedgerConvertTransferToDepositRemovalActionCannotConvertLedgerBackedTransfer = Ce virement entre comptes est g\u00E9r\u00E9 par le Ledger. Il ne peut pas \u00EAtre converti ici en d\u00E9p\u00F4t et retrait s\u00E9par\u00E9s. + +LedgerConvertTransferToDepositRemovalActionUnsupportedTransferEntry = La transaction {0} n\u2019est pas une entr\u00E9e de virement prise en charge et ne peut pas \u00EAtre convertie. + +LedgerExDateEditingSupportDividendOwnerNotFound = Le dividende Ledger n\u00E9cessite un propri\u00E9taire de projection de compte, mais aucun n\u2019a \u00E9t\u00E9 trouv\u00E9. + +LedgerExDateEditingSupportNoSafeEditorLedgerBacked = La date ex-dividende de cette transaction de compte g\u00E9r\u00E9e par le Ledger ne peut pas \u00EAtre modifi\u00E9e ici en toute s\u00E9curit\u00E9. Ouvrez l\u2019\u00E9diteur complet de transaction. + +LedgerExDateEditingSupportNoSafeEditorPolicyBlocked = La modification de la date ex-dividende est d\u00E9sactiv\u00E9e pour ce type de transaction de compte g\u00E9r\u00E9e par le Ledger afin de garder l\u2019\u00E9criture coh\u00E9rente. + +LedgerNativeComponentInspectorCardinality = Cardinalit\u00E9 + +LedgerNativeComponentInspectorCode = Code + +LedgerNativeComponentInspectorDateTime = Date/heure + +LedgerNativeComponentInspectorDialogTitle = Inspecter les d\u00E9tails de l\u2019\u00E9criture Ledger + +LedgerNativeComponentInspectorDomain = Domaine + +LedgerNativeComponentInspectorEntryParameters = Param\u00E8tres de l\u2019\u00E9criture + +LedgerNativeComponentInspectorEntryType = Type d\u2019\u00E9criture + +LedgerNativeComponentInspectorEntryUUID = UUID de l\u2019\u00E9criture + +LedgerNativeComponentInspectorField = Champ + +LedgerNativeComponentInspectorFunctionalLegs = Composants fonctionnels + +LedgerNativeComponentInspectorGroupExpected = Groupe attendu + +LedgerNativeComponentInspectorHeader = \u00C9criture Ledger + +LedgerNativeComponentInspectorLegRole = R\u00F4le du composant + +LedgerNativeComponentInspectorMenu = Inspecter l\u2019\u00E9criture Ledger... + +LedgerNativeComponentInspectorNativeTargeted = Natif cibl\u00E9 + +LedgerNativeComponentInspectorNoNativeDefinition = Aucune d\u00E9finition Ledger native n\u2019est configur\u00E9e pour ce type d\u2019\u00E9criture. + +LedgerNativeComponentInspectorOwner = Propri\u00E9taire + +LedgerNativeComponentInspectorParameter = Param\u00E8tre + +LedgerNativeComponentInspectorPostingGroupUUID = UUID du groupe de posting + +LedgerNativeComponentInspectorPostingParameters = Param\u00E8tres du posting + +LedgerNativeComponentInspectorPostingType = Type de posting + +LedgerNativeComponentInspectorPostingUUID = UUID du posting + +LedgerNativeComponentInspectorPostings = Postings + +LedgerNativeComponentInspectorPrimaryExpected = R\u00E9f\u00E9rence principale attendue + +LedgerNativeComponentInspectorPrimaryPostingUUID = UUID du posting principal + +LedgerNativeComponentInspectorProjectionRefs = R\u00E9f\u00E9rences de projection + +LedgerNativeComponentInspectorProjectionRole = R\u00F4le de projection + +LedgerNativeComponentInspectorProjectionUUID = UUID de projection + +LedgerNativeComponentInspectorSelectedPostingGroupUUID = UUID du groupe de posting s\u00E9lectionn\u00E9 + +LedgerNativeComponentInspectorSelectedPrimaryPostingUUID = UUID du posting principal s\u00E9lectionn\u00E9 + +LedgerNativeComponentInspectorSelectedProjectionRole = R\u00F4le de projection s\u00E9lectionn\u00E9 + +LedgerNativeComponentInspectorSelectedProjectionUUID = UUID de projection s\u00E9lectionn\u00E9 + +LedgerNativeComponentInspectorShape = Structure + +LedgerNativeComponentInspectorValue = Valeur + +LedgerNativeComponentInspectorValueKind = Type de valeur + +LedgerPropertyEditingSupportUnsupportedInlineEdit = Cette valeur est g\u00E9r\u00E9e par le Ledger et ne peut pas \u00EAtre modifi\u00E9e directement pour {0}. Utilisez un \u00E9diteur Ledger pris en charge. + +LedgerTransactionOwnerListEditingSupportAccountTransferOwnerNotFound = Le propri\u00E9taire du virement entre comptes est introuvable : {0} + +LedgerTransactionOwnerListEditingSupportDistinctOwnersRequired = Un virement doit conserver deux propri\u00E9taires diff\u00E9rents. Choisissez une source ou une cible diff\u00E9rente. + +LedgerTransactionOwnerListEditingSupportPortfolioTransferOwnerNotFound = Le propri\u00E9taire du transfert de compte titres est introuvable : {0} + +LedgerTransactionOwnerListEditingSupportUnsupportedOwnerEdit = Le propri\u00E9taire de cette transaction g\u00E9r\u00E9e par le Ledger ne peut pas \u00EAtre modifi\u00E9 ici : {0} + +LedgerTransactionOwnerListEditingSupportUnsupportedOwnerType = Le type de propri\u00E9taire {0} n\u2019est pas pris en charge pour cette transaction Ledger. + +LedgerTransactionTypeEditingSupportPolicyBlockedTransition = Les r\u00E8gles Ledger de cette transaction ne permettent pas de changer son type ici. + +LedgerTransactionTypeEditingSupportUnsupportedTransition = Ce type de transaction g\u00E9r\u00E9e par le Ledger ne peut pas \u00EAtre modifi\u00E9 par cette action. + +LedgerTransactionsViewerUnsupportedSharesInlineEdit = Cette transaction est g\u00E9r\u00E9e par le Ledger. Les parts ne peuvent pas \u00EAtre modifi\u00E9es directement dans ce tableau ; ouvrez plut\u00F4t l\u2019\u00E9diteur de transaction. + MarkSecurityPageDescription = Les \u00E9l\u00E9ments s\u00E9lectionn\u00E9s seront marqu\u00E9s comme indices boursiers. Les indices n'ont pas de devise. MarkSecurityPageTitle = Marquer titres comme indices boursiers diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_it.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_it.properties index 35327d0c06..a907ec50aa 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_it.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_it.properties @@ -1961,6 +1961,108 @@ LabelYellowWhiteBlack = Giallo - Bianco - Nero LabelYes = S\u00EC +LedgerAccountTransactionsPaneUnsupportedSharesInlineEdit = Questa operazione \u00E8 gestita dal Ledger. Le azioni non possono essere modificate direttamente in questa tabella; aprire l\u2019editor della transazione. + +LedgerConvertPortfolioCompositeTypeActionUnsupportedLedgerBackedTransition = Questa operazione del conto titoli \u00E8 gestita dal Ledger. Il tipo non pu\u00F2 essere cambiato con questa azione. + +LedgerConvertTransferToDepositRemovalActionCannotConvertLedgerBackedTransfer = Questo trasferimento tra conti \u00E8 gestito dal Ledger. Qui non pu\u00F2 essere convertito in deposito e prelievo separati. + +LedgerConvertTransferToDepositRemovalActionUnsupportedTransferEntry = La transazione {0} non \u00E8 un trasferimento di conto supportato e non pu\u00F2 essere convertita. + +LedgerExDateEditingSupportDividendOwnerNotFound = Il dividendo Ledger richiede un proprietario della proiezione conto, ma non ne \u00E8 stato trovato alcuno. + +LedgerExDateEditingSupportNoSafeEditorLedgerBacked = La data ex-dividendo di questa transazione di conto gestita dal Ledger non pu\u00F2 essere modificata qui in modo sicuro. Aprire l\u2019editor completo della transazione. + +LedgerExDateEditingSupportNoSafeEditorPolicyBlocked = La modifica della data ex-dividendo \u00E8 disattivata per questo tipo di transazione di conto gestita dal Ledger, per mantenere coerente la voce Ledger. + +LedgerNativeComponentInspectorCardinality = Cardinalit\u00E0 + +LedgerNativeComponentInspectorCode = Codice + +LedgerNativeComponentInspectorDateTime = Data/ora + +LedgerNativeComponentInspectorDialogTitle = Ispeziona i dettagli della voce Ledger + +LedgerNativeComponentInspectorDomain = Dominio + +LedgerNativeComponentInspectorEntryParameters = Parametri della voce + +LedgerNativeComponentInspectorEntryType = Tipo di voce + +LedgerNativeComponentInspectorEntryUUID = UUID della voce + +LedgerNativeComponentInspectorField = Campo + +LedgerNativeComponentInspectorFunctionalLegs = Componenti funzionali + +LedgerNativeComponentInspectorGroupExpected = Gruppo previsto + +LedgerNativeComponentInspectorHeader = Voce Ledger + +LedgerNativeComponentInspectorLegRole = Ruolo del componente + +LedgerNativeComponentInspectorMenu = Ispeziona voce Ledger... + +LedgerNativeComponentInspectorNativeTargeted = Nativo mirato + +LedgerNativeComponentInspectorNoNativeDefinition = Nessuna definizione Ledger nativa \u00E8 configurata per questo tipo di voce. + +LedgerNativeComponentInspectorOwner = Proprietario + +LedgerNativeComponentInspectorParameter = Parametro + +LedgerNativeComponentInspectorPostingGroupUUID = UUID del gruppo di registrazione + +LedgerNativeComponentInspectorPostingParameters = Parametri della registrazione + +LedgerNativeComponentInspectorPostingType = Tipo di registrazione + +LedgerNativeComponentInspectorPostingUUID = UUID della registrazione + +LedgerNativeComponentInspectorPostings = Registrazioni + +LedgerNativeComponentInspectorPrimaryExpected = Riferimento principale previsto + +LedgerNativeComponentInspectorPrimaryPostingUUID = UUID della registrazione principale + +LedgerNativeComponentInspectorProjectionRefs = Riferimenti di proiezione + +LedgerNativeComponentInspectorProjectionRole = Ruolo di proiezione + +LedgerNativeComponentInspectorProjectionUUID = UUID della proiezione + +LedgerNativeComponentInspectorSelectedPostingGroupUUID = UUID del gruppo di registrazione selezionato + +LedgerNativeComponentInspectorSelectedPrimaryPostingUUID = UUID della registrazione principale selezionata + +LedgerNativeComponentInspectorSelectedProjectionRole = Ruolo di proiezione selezionato + +LedgerNativeComponentInspectorSelectedProjectionUUID = UUID della proiezione selezionata + +LedgerNativeComponentInspectorShape = Struttura + +LedgerNativeComponentInspectorValue = Valore + +LedgerNativeComponentInspectorValueKind = Tipo di valore + +LedgerPropertyEditingSupportUnsupportedInlineEdit = Questo valore \u00E8 gestito dal Ledger e non pu\u00F2 essere modificato direttamente per {0}. Usare un editor Ledger supportato. + +LedgerTransactionOwnerListEditingSupportAccountTransferOwnerNotFound = Il proprietario del trasferimento tra conti non \u00E8 stato trovato: {0} + +LedgerTransactionOwnerListEditingSupportDistinctOwnersRequired = Un trasferimento deve mantenere due proprietari diversi. Scegliere una sorgente o destinazione diversa. + +LedgerTransactionOwnerListEditingSupportPortfolioTransferOwnerNotFound = Il proprietario del trasferimento tra conti titoli non \u00E8 stato trovato: {0} + +LedgerTransactionOwnerListEditingSupportUnsupportedOwnerEdit = Il proprietario di questa transazione gestita dal Ledger non pu\u00F2 essere modificato qui: {0} + +LedgerTransactionOwnerListEditingSupportUnsupportedOwnerType = Il tipo di proprietario {0} non \u00E8 supportato per questa transazione Ledger. + +LedgerTransactionTypeEditingSupportPolicyBlockedTransition = Le regole Ledger di questa transazione non consentono di cambiare qui il tipo di transazione. + +LedgerTransactionTypeEditingSupportUnsupportedTransition = Questo tipo di transazione gestita dal Ledger non pu\u00F2 essere cambiato con questa azione. + +LedgerTransactionsViewerUnsupportedSharesInlineEdit = Questa operazione \u00E8 gestita dal Ledger. Le azioni non possono essere modificate direttamente in questa tabella; aprire l\u2019editor della transazione. + MarkSecurityPageDescription = Le voci selezionate verranno contrassegnate come indici di borsa. Gli indici non hanno valuta. MarkSecurityPageTitle = Segna titoli come indici azionari diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_nl.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_nl.properties index c7ae2bbdc3..138d0c76ee 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_nl.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_nl.properties @@ -1961,6 +1961,108 @@ LabelYellowWhiteBlack = Geel - Wit - Zwart LabelYes = Ja +LedgerAccountTransactionsPaneUnsupportedSharesInlineEdit = Deze transactie wordt door de Ledger beheerd. Het aantal kan niet direct in deze tabel worden aangepast; open de transactiebewerker. + +LedgerConvertPortfolioCompositeTypeActionUnsupportedLedgerBackedTransition = Deze effectenrekeningtransactie wordt door de Ledger beheerd. Het type kan niet met deze actie worden gewijzigd. + +LedgerConvertTransferToDepositRemovalActionCannotConvertLedgerBackedTransfer = Deze overboeking tussen rekeningen wordt door de Ledger beheerd. Ze kan hier niet worden omgezet in een aparte storting en opname. + +LedgerConvertTransferToDepositRemovalActionUnsupportedTransferEntry = Transactie {0} is geen ondersteunde rekeningoverboeking en kan niet worden omgezet. + +LedgerExDateEditingSupportDividendOwnerNotFound = Het Ledger-dividend heeft een eigenaar van de rekeningprojectie nodig, maar die is niet gevonden. + +LedgerExDateEditingSupportNoSafeEditorLedgerBacked = De ex-dividenddatum van deze door de Ledger beheerde rekeningtransactie kan hier niet veilig worden aangepast. Open de volledige transactiebewerker. + +LedgerExDateEditingSupportNoSafeEditorPolicyBlocked = Het bewerken van de ex-dividenddatum is voor dit type door de Ledger beheerde rekeningtransactie uitgeschakeld om de Ledger-boeking consistent te houden. + +LedgerNativeComponentInspectorCardinality = Kardinaliteit + +LedgerNativeComponentInspectorCode = Code + +LedgerNativeComponentInspectorDateTime = Datum/tijd + +LedgerNativeComponentInspectorDialogTitle = Ledger-boekingsdetails inspecteren + +LedgerNativeComponentInspectorDomain = Domein + +LedgerNativeComponentInspectorEntryParameters = Boekingsparameters + +LedgerNativeComponentInspectorEntryType = Boekingstype + +LedgerNativeComponentInspectorEntryUUID = Boekings-UUID + +LedgerNativeComponentInspectorField = Veld + +LedgerNativeComponentInspectorFunctionalLegs = Functionele componenten + +LedgerNativeComponentInspectorGroupExpected = Groep verwacht + +LedgerNativeComponentInspectorHeader = Ledger-boeking + +LedgerNativeComponentInspectorLegRole = Componentrol + +LedgerNativeComponentInspectorMenu = Ledger-boeking inspecteren... + +LedgerNativeComponentInspectorNativeTargeted = Native gericht + +LedgerNativeComponentInspectorNoNativeDefinition = Voor dit boekingstype is geen native Ledger-definitie geconfigureerd. + +LedgerNativeComponentInspectorOwner = Eigenaar + +LedgerNativeComponentInspectorParameter = Parameter + +LedgerNativeComponentInspectorPostingGroupUUID = Postinggroep-UUID + +LedgerNativeComponentInspectorPostingParameters = Postingparameters + +LedgerNativeComponentInspectorPostingType = Postingtype + +LedgerNativeComponentInspectorPostingUUID = Posting-UUID + +LedgerNativeComponentInspectorPostings = Postings + +LedgerNativeComponentInspectorPrimaryExpected = Primaire verwijzing verwacht + +LedgerNativeComponentInspectorPrimaryPostingUUID = Primaire posting-UUID + +LedgerNativeComponentInspectorProjectionRefs = Projectieverwijzingen + +LedgerNativeComponentInspectorProjectionRole = Projectierol + +LedgerNativeComponentInspectorProjectionUUID = Projectie-UUID + +LedgerNativeComponentInspectorSelectedPostingGroupUUID = Geselecteerde postinggroep-UUID + +LedgerNativeComponentInspectorSelectedPrimaryPostingUUID = Geselecteerde primaire posting-UUID + +LedgerNativeComponentInspectorSelectedProjectionRole = Geselecteerde projectierol + +LedgerNativeComponentInspectorSelectedProjectionUUID = Geselecteerde projectie-UUID + +LedgerNativeComponentInspectorShape = Structuur + +LedgerNativeComponentInspectorValue = Waarde + +LedgerNativeComponentInspectorValueKind = Waardetype + +LedgerPropertyEditingSupportUnsupportedInlineEdit = Deze waarde wordt door de Ledger beheerd en kan voor {0} niet direct worden aangepast. Gebruik een ondersteunde Ledger-bewerker. + +LedgerTransactionOwnerListEditingSupportAccountTransferOwnerNotFound = De eigenaar van de rekeningoverboeking is niet gevonden: {0} + +LedgerTransactionOwnerListEditingSupportDistinctOwnersRequired = Een overboeking moet twee verschillende eigenaren behouden. Kies een andere bron of bestemming. + +LedgerTransactionOwnerListEditingSupportPortfolioTransferOwnerNotFound = De eigenaar van de effectenrekeningoverboeking is niet gevonden: {0} + +LedgerTransactionOwnerListEditingSupportUnsupportedOwnerEdit = De eigenaar van deze door de Ledger beheerde transactie kan hier niet worden gewijzigd: {0} + +LedgerTransactionOwnerListEditingSupportUnsupportedOwnerType = Eigenaarstype {0} wordt voor deze Ledger-transactie niet ondersteund. + +LedgerTransactionTypeEditingSupportPolicyBlockedTransition = De Ledger-regels voor deze transactie staan niet toe dat het transactietype hier wordt gewijzigd. + +LedgerTransactionTypeEditingSupportUnsupportedTransition = Dit door de Ledger beheerde transactietype kan niet met deze actie worden gewijzigd. + +LedgerTransactionsViewerUnsupportedSharesInlineEdit = Deze transactie wordt door de Ledger beheerd. Het aantal kan niet direct in deze tabel worden aangepast; open de transactiebewerker. + MarkSecurityPageDescription = De geselecteerde items worden als 'index' gelabeld. Indexen kennen geen valuta-aanduiding. MarkSecurityPageTitle = Label effect(en) als een 'index' diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_pl.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_pl.properties index fb07f108f3..f5da8cb7c9 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_pl.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_pl.properties @@ -1961,6 +1961,108 @@ LabelYellowWhiteBlack = \u017B\u00F3\u0142ty - Bia\u0142y - Czarny LabelYes = Tak +LedgerAccountTransactionsPaneUnsupportedSharesInlineEdit = Ta transakcja jest zarz\u0105dzana przez Ledger. Liczby udzia\u0142\u00F3w nie mo\u017Cna edytowa\u0107 bezpo\u015Brednio w tej tabeli; otw\u00F3rz edytor transakcji. + +LedgerConvertPortfolioCompositeTypeActionUnsupportedLedgerBackedTransition = Ta transakcja konta walor\u00F3w jest zarz\u0105dzana przez Ledger. Jej typu nie mo\u017Cna zmieni\u0107 t\u0105 akcj\u0105. + +LedgerConvertTransferToDepositRemovalActionCannotConvertLedgerBackedTransfer = Ten transfer mi\u0119dzy kontami jest zarz\u0105dzany przez Ledger. Nie mo\u017Cna go tutaj przekszta\u0142ci\u0107 w osobn\u0105 wp\u0142at\u0119 i wyp\u0142at\u0119. + +LedgerConvertTransferToDepositRemovalActionUnsupportedTransferEntry = Transakcja {0} nie jest obs\u0142ugiwanym transferem konta i nie mo\u017Ce zosta\u0107 przekszta\u0142cona. + +LedgerExDateEditingSupportDividendOwnerNotFound = Dywidenda Ledger wymaga w\u0142a\u015Bciciela projekcji konta, ale nie znaleziono \u017Cadnego. + +LedgerExDateEditingSupportNoSafeEditorLedgerBacked = Daty ex-dividend tej transakcji konta zarz\u0105dzanej przez Ledger nie mo\u017Cna tutaj bezpiecznie edytowa\u0107. Otw\u00F3rz pe\u0142ny edytor transakcji. + +LedgerExDateEditingSupportNoSafeEditorPolicyBlocked = Edycja daty ex-dividend jest wy\u0142\u0105czona dla tego typu transakcji konta zarz\u0105dzanej przez Ledger, aby wpis Ledger pozosta\u0142 sp\u00F3jny. + +LedgerNativeComponentInspectorCardinality = Krotno\u015B\u0107 + +LedgerNativeComponentInspectorCode = Kod + +LedgerNativeComponentInspectorDateTime = Data/czas + +LedgerNativeComponentInspectorDialogTitle = Sprawd\u017A szczeg\u00F3\u0142y wpisu Ledger + +LedgerNativeComponentInspectorDomain = Domena + +LedgerNativeComponentInspectorEntryParameters = Parametry wpisu + +LedgerNativeComponentInspectorEntryType = Typ wpisu + +LedgerNativeComponentInspectorEntryUUID = UUID wpisu + +LedgerNativeComponentInspectorField = Pole + +LedgerNativeComponentInspectorFunctionalLegs = Sk\u0142adniki funkcjonalne + +LedgerNativeComponentInspectorGroupExpected = Oczekiwana grupa + +LedgerNativeComponentInspectorHeader = Wpis Ledger + +LedgerNativeComponentInspectorLegRole = Rola sk\u0142adnika + +LedgerNativeComponentInspectorMenu = Sprawd\u017A wpis Ledger... + +LedgerNativeComponentInspectorNativeTargeted = Natywnie celowane + +LedgerNativeComponentInspectorNoNativeDefinition = Dla tego typu wpisu nie skonfigurowano natywnej definicji Ledger. + +LedgerNativeComponentInspectorOwner = W\u0142a\u015Bciciel + +LedgerNativeComponentInspectorParameter = Parametr + +LedgerNativeComponentInspectorPostingGroupUUID = UUID grupy ksi\u0119gowania + +LedgerNativeComponentInspectorPostingParameters = Parametry ksi\u0119gowania + +LedgerNativeComponentInspectorPostingType = Typ ksi\u0119gowania + +LedgerNativeComponentInspectorPostingUUID = UUID ksi\u0119gowania + +LedgerNativeComponentInspectorPostings = Ksi\u0119gowania + +LedgerNativeComponentInspectorPrimaryExpected = Oczekiwane odniesienie g\u0142\u00F3wne + +LedgerNativeComponentInspectorPrimaryPostingUUID = UUID g\u0142\u00F3wnego ksi\u0119gowania + +LedgerNativeComponentInspectorProjectionRefs = Referencje projekcji + +LedgerNativeComponentInspectorProjectionRole = Rola projekcji + +LedgerNativeComponentInspectorProjectionUUID = UUID projekcji + +LedgerNativeComponentInspectorSelectedPostingGroupUUID = Wybrane UUID grupy ksi\u0119gowania + +LedgerNativeComponentInspectorSelectedPrimaryPostingUUID = Wybrane UUID g\u0142\u00F3wnego ksi\u0119gowania + +LedgerNativeComponentInspectorSelectedProjectionRole = Wybrana rola projekcji + +LedgerNativeComponentInspectorSelectedProjectionUUID = Wybrane UUID projekcji + +LedgerNativeComponentInspectorShape = Struktura + +LedgerNativeComponentInspectorValue = Warto\u015B\u0107 + +LedgerNativeComponentInspectorValueKind = Rodzaj warto\u015Bci + +LedgerPropertyEditingSupportUnsupportedInlineEdit = Ta warto\u015B\u0107 jest zarz\u0105dzana przez Ledger i nie mo\u017Cna jej edytowa\u0107 bezpo\u015Brednio dla {0}. U\u017Cyj obs\u0142ugiwanego edytora Ledger. + +LedgerTransactionOwnerListEditingSupportAccountTransferOwnerNotFound = Nie znaleziono w\u0142a\u015Bciciela transferu konta: {0} + +LedgerTransactionOwnerListEditingSupportDistinctOwnersRequired = Transfer musi zachowa\u0107 dw\u00F3ch r\u00F3\u017Cnych w\u0142a\u015Bcicieli. Wybierz inne \u017Ar\u00F3d\u0142o lub cel. + +LedgerTransactionOwnerListEditingSupportPortfolioTransferOwnerNotFound = Nie znaleziono w\u0142a\u015Bciciela transferu konta walor\u00F3w: {0} + +LedgerTransactionOwnerListEditingSupportUnsupportedOwnerEdit = W\u0142a\u015Bciciela tej transakcji zarz\u0105dzanej przez Ledger nie mo\u017Cna tutaj zmieni\u0107: {0} + +LedgerTransactionOwnerListEditingSupportUnsupportedOwnerType = Typ w\u0142a\u015Bciciela {0} nie jest obs\u0142ugiwany dla tej transakcji Ledger. + +LedgerTransactionTypeEditingSupportPolicyBlockedTransition = Regu\u0142y Ledger dla tej transakcji nie pozwalaj\u0105 tutaj zmieni\u0107 typu transakcji. + +LedgerTransactionTypeEditingSupportUnsupportedTransition = Tego typu transakcji zarz\u0105dzanej przez Ledger nie mo\u017Cna zmieni\u0107 t\u0105 akcj\u0105. + +LedgerTransactionsViewerUnsupportedSharesInlineEdit = Ta transakcja jest zarz\u0105dzana przez Ledger. Liczby udzia\u0142\u00F3w nie mo\u017Cna edytowa\u0107 bezpo\u015Brednio w tej tabeli; otw\u00F3rz edytor transakcji. + MarkSecurityPageDescription = Wybrane pozycje zostan\u0105 oznaczone jako indeksy gie\u0142dowe. Indeksy nie maj\u0105 waluty. MarkSecurityPageTitle = Oznacz walory jako indeksy gie\u0142dowe diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_pt.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_pt.properties index 1c9ef31b40..3f29ebd6f7 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_pt.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_pt.properties @@ -1959,6 +1959,108 @@ LabelYellowWhiteBlack = Branco - Amarelo - Preto LabelYes = Sim +LedgerAccountTransactionsPaneUnsupportedSharesInlineEdit = Esta transa\u00E7\u00E3o \u00E9 gerida pelo Ledger. A quantidade n\u00E3o pode ser editada diretamente nesta tabela; abra o editor da transa\u00E7\u00E3o. + +LedgerConvertPortfolioCompositeTypeActionUnsupportedLedgerBackedTransition = Esta transa\u00E7\u00E3o da conta de t\u00EDtulos \u00E9 gerida pelo Ledger. O seu tipo n\u00E3o pode ser alterado por esta a\u00E7\u00E3o. + +LedgerConvertTransferToDepositRemovalActionCannotConvertLedgerBackedTransfer = Esta transfer\u00EAncia entre contas \u00E9 gerida pelo Ledger. N\u00E3o pode ser convertida aqui em dep\u00F3sito e levantamento separados. + +LedgerConvertTransferToDepositRemovalActionUnsupportedTransferEntry = A transa\u00E7\u00E3o {0} n\u00E3o \u00E9 uma transfer\u00EAncia de conta suportada e n\u00E3o pode ser convertida. + +LedgerExDateEditingSupportDividendOwnerNotFound = O dividendo Ledger precisa de um propriet\u00E1rio de proje\u00E7\u00E3o de conta, mas nenhum foi encontrado. + +LedgerExDateEditingSupportNoSafeEditorLedgerBacked = A data ex-dividendo desta transa\u00E7\u00E3o de conta gerida pelo Ledger n\u00E3o pode ser editada aqui com seguran\u00E7a. Abra o editor completo da transa\u00E7\u00E3o. + +LedgerExDateEditingSupportNoSafeEditorPolicyBlocked = A edi\u00E7\u00E3o da data ex-dividendo est\u00E1 desativada para este tipo de transa\u00E7\u00E3o de conta gerida pelo Ledger, para manter a entrada consistente. + +LedgerNativeComponentInspectorCardinality = Cardinalidade + +LedgerNativeComponentInspectorCode = C\u00F3digo + +LedgerNativeComponentInspectorDateTime = Data/hora + +LedgerNativeComponentInspectorDialogTitle = Inspecionar detalhes da entrada Ledger + +LedgerNativeComponentInspectorDomain = Dom\u00EDnio + +LedgerNativeComponentInspectorEntryParameters = Par\u00E2metros da entrada + +LedgerNativeComponentInspectorEntryType = Tipo de entrada + +LedgerNativeComponentInspectorEntryUUID = UUID da entrada + +LedgerNativeComponentInspectorField = Campo + +LedgerNativeComponentInspectorFunctionalLegs = Componentes funcionais + +LedgerNativeComponentInspectorGroupExpected = Grupo esperado + +LedgerNativeComponentInspectorHeader = Entrada Ledger + +LedgerNativeComponentInspectorLegRole = Fun\u00E7\u00E3o do componente + +LedgerNativeComponentInspectorMenu = Inspecionar entrada Ledger... + +LedgerNativeComponentInspectorNativeTargeted = Nativo direcionado + +LedgerNativeComponentInspectorNoNativeDefinition = Nenhuma defini\u00E7\u00E3o Ledger nativa est\u00E1 configurada para este tipo de entrada. + +LedgerNativeComponentInspectorOwner = Propriet\u00E1rio + +LedgerNativeComponentInspectorParameter = Par\u00E2metro + +LedgerNativeComponentInspectorPostingGroupUUID = UUID do grupo de lan\u00E7amento + +LedgerNativeComponentInspectorPostingParameters = Par\u00E2metros do lan\u00E7amento + +LedgerNativeComponentInspectorPostingType = Tipo de lan\u00E7amento + +LedgerNativeComponentInspectorPostingUUID = UUID do lan\u00E7amento + +LedgerNativeComponentInspectorPostings = Lan\u00E7amentos + +LedgerNativeComponentInspectorPrimaryExpected = Refer\u00EAncia principal esperada + +LedgerNativeComponentInspectorPrimaryPostingUUID = UUID do lan\u00E7amento principal + +LedgerNativeComponentInspectorProjectionRefs = Refer\u00EAncias de proje\u00E7\u00E3o + +LedgerNativeComponentInspectorProjectionRole = Fun\u00E7\u00E3o da proje\u00E7\u00E3o + +LedgerNativeComponentInspectorProjectionUUID = UUID da proje\u00E7\u00E3o + +LedgerNativeComponentInspectorSelectedPostingGroupUUID = UUID do grupo de lan\u00E7amento selecionado + +LedgerNativeComponentInspectorSelectedPrimaryPostingUUID = UUID do lan\u00E7amento principal selecionado + +LedgerNativeComponentInspectorSelectedProjectionRole = Fun\u00E7\u00E3o da proje\u00E7\u00E3o selecionada + +LedgerNativeComponentInspectorSelectedProjectionUUID = UUID da proje\u00E7\u00E3o selecionada + +LedgerNativeComponentInspectorShape = Estrutura + +LedgerNativeComponentInspectorValue = Valor + +LedgerNativeComponentInspectorValueKind = Tipo de valor + +LedgerPropertyEditingSupportUnsupportedInlineEdit = Este valor \u00E9 gerido pelo Ledger e n\u00E3o pode ser editado diretamente para {0}. Use um editor Ledger suportado. + +LedgerTransactionOwnerListEditingSupportAccountTransferOwnerNotFound = O propriet\u00E1rio da transfer\u00EAncia entre contas n\u00E3o foi encontrado: {0} + +LedgerTransactionOwnerListEditingSupportDistinctOwnersRequired = Uma transfer\u00EAncia deve manter dois propriet\u00E1rios diferentes. Escolha outra origem ou outro destino. + +LedgerTransactionOwnerListEditingSupportPortfolioTransferOwnerNotFound = O propriet\u00E1rio da transfer\u00EAncia da conta de t\u00EDtulos n\u00E3o foi encontrado: {0} + +LedgerTransactionOwnerListEditingSupportUnsupportedOwnerEdit = O propriet\u00E1rio desta transa\u00E7\u00E3o gerida pelo Ledger n\u00E3o pode ser alterado aqui: {0} + +LedgerTransactionOwnerListEditingSupportUnsupportedOwnerType = O tipo de propriet\u00E1rio {0} n\u00E3o \u00E9 suportado para esta transa\u00E7\u00E3o Ledger. + +LedgerTransactionTypeEditingSupportPolicyBlockedTransition = As regras Ledger desta transa\u00E7\u00E3o n\u00E3o permitem alterar aqui o tipo de transa\u00E7\u00E3o. + +LedgerTransactionTypeEditingSupportUnsupportedTransition = Este tipo de transa\u00E7\u00E3o gerida pelo Ledger n\u00E3o pode ser alterado por esta a\u00E7\u00E3o. + +LedgerTransactionsViewerUnsupportedSharesInlineEdit = Esta transa\u00E7\u00E3o \u00E9 gerida pelo Ledger. A quantidade n\u00E3o pode ser editada diretamente nesta tabela; abra o editor da transa\u00E7\u00E3o. + MarkSecurityPageDescription = Os itens selecionados ser\u00E3o marcados como \u00EDndices de a\u00E7\u00F5es. Os \u00EDndices n\u00E3o t\u00EAm moeda. MarkSecurityPageTitle = Marcar t\u00EDtulos como \u00EDndices de a\u00E7\u00F5es diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_pt_BR.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_pt_BR.properties index 4999d8206c..0bc8db261f 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_pt_BR.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_pt_BR.properties @@ -1959,6 +1959,108 @@ LabelYellowWhiteBlack = Amarelo - Branco - Preto LabelYes = Sim +LedgerAccountTransactionsPaneUnsupportedSharesInlineEdit = Esta transa\u00E7\u00E3o \u00E9 gerenciada pelo Ledger. A quantidade n\u00E3o pode ser editada diretamente nesta tabela; abra o editor da transa\u00E7\u00E3o. + +LedgerConvertPortfolioCompositeTypeActionUnsupportedLedgerBackedTransition = Esta transa\u00E7\u00E3o da conta de ativos \u00E9 gerenciada pelo Ledger. O tipo dela n\u00E3o pode ser alterado por esta a\u00E7\u00E3o. + +LedgerConvertTransferToDepositRemovalActionCannotConvertLedgerBackedTransfer = Esta transfer\u00EAncia entre contas \u00E9 gerenciada pelo Ledger. Ela n\u00E3o pode ser convertida aqui em dep\u00F3sito e retirada separados. + +LedgerConvertTransferToDepositRemovalActionUnsupportedTransferEntry = A transa\u00E7\u00E3o {0} n\u00E3o \u00E9 uma transfer\u00EAncia de conta suportada e n\u00E3o pode ser convertida. + +LedgerExDateEditingSupportDividendOwnerNotFound = O dividendo Ledger precisa de um propriet\u00E1rio de proje\u00E7\u00E3o de conta, mas nenhum foi encontrado. + +LedgerExDateEditingSupportNoSafeEditorLedgerBacked = A data ex-dividendo desta transa\u00E7\u00E3o de conta gerenciada pelo Ledger n\u00E3o pode ser editada aqui com seguran\u00E7a. Abra o editor completo da transa\u00E7\u00E3o. + +LedgerExDateEditingSupportNoSafeEditorPolicyBlocked = A edi\u00E7\u00E3o da data ex-dividendo est\u00E1 desativada para este tipo de transa\u00E7\u00E3o de conta gerenciada pelo Ledger, para manter a entrada consistente. + +LedgerNativeComponentInspectorCardinality = Cardinalidade + +LedgerNativeComponentInspectorCode = C\u00F3digo + +LedgerNativeComponentInspectorDateTime = Data/hora + +LedgerNativeComponentInspectorDialogTitle = Inspecionar detalhes da entrada Ledger + +LedgerNativeComponentInspectorDomain = Dom\u00EDnio + +LedgerNativeComponentInspectorEntryParameters = Par\u00E2metros da entrada + +LedgerNativeComponentInspectorEntryType = Tipo de entrada + +LedgerNativeComponentInspectorEntryUUID = UUID da entrada + +LedgerNativeComponentInspectorField = Campo + +LedgerNativeComponentInspectorFunctionalLegs = Componentes funcionais + +LedgerNativeComponentInspectorGroupExpected = Grupo esperado + +LedgerNativeComponentInspectorHeader = Entrada Ledger + +LedgerNativeComponentInspectorLegRole = Fun\u00E7\u00E3o do componente + +LedgerNativeComponentInspectorMenu = Inspecionar entrada Ledger... + +LedgerNativeComponentInspectorNativeTargeted = Nativo direcionado + +LedgerNativeComponentInspectorNoNativeDefinition = Nenhuma defini\u00E7\u00E3o Ledger nativa est\u00E1 configurada para este tipo de entrada. + +LedgerNativeComponentInspectorOwner = Propriet\u00E1rio + +LedgerNativeComponentInspectorParameter = Par\u00E2metro + +LedgerNativeComponentInspectorPostingGroupUUID = UUID do grupo de lan\u00E7amento + +LedgerNativeComponentInspectorPostingParameters = Par\u00E2metros do lan\u00E7amento + +LedgerNativeComponentInspectorPostingType = Tipo de lan\u00E7amento + +LedgerNativeComponentInspectorPostingUUID = UUID do lan\u00E7amento + +LedgerNativeComponentInspectorPostings = Lan\u00E7amentos + +LedgerNativeComponentInspectorPrimaryExpected = Refer\u00EAncia principal esperada + +LedgerNativeComponentInspectorPrimaryPostingUUID = UUID do lan\u00E7amento principal + +LedgerNativeComponentInspectorProjectionRefs = Refer\u00EAncias de proje\u00E7\u00E3o + +LedgerNativeComponentInspectorProjectionRole = Fun\u00E7\u00E3o da proje\u00E7\u00E3o + +LedgerNativeComponentInspectorProjectionUUID = UUID da proje\u00E7\u00E3o + +LedgerNativeComponentInspectorSelectedPostingGroupUUID = UUID do grupo de lan\u00E7amento selecionado + +LedgerNativeComponentInspectorSelectedPrimaryPostingUUID = UUID do lan\u00E7amento principal selecionado + +LedgerNativeComponentInspectorSelectedProjectionRole = Fun\u00E7\u00E3o da proje\u00E7\u00E3o selecionada + +LedgerNativeComponentInspectorSelectedProjectionUUID = UUID da proje\u00E7\u00E3o selecionada + +LedgerNativeComponentInspectorShape = Estrutura + +LedgerNativeComponentInspectorValue = Valor + +LedgerNativeComponentInspectorValueKind = Tipo de valor + +LedgerPropertyEditingSupportUnsupportedInlineEdit = Este valor \u00E9 gerenciado pelo Ledger e n\u00E3o pode ser editado diretamente para {0}. Use um editor Ledger suportado. + +LedgerTransactionOwnerListEditingSupportAccountTransferOwnerNotFound = O propriet\u00E1rio da transfer\u00EAncia entre contas n\u00E3o foi encontrado: {0} + +LedgerTransactionOwnerListEditingSupportDistinctOwnersRequired = Uma transfer\u00EAncia deve manter dois propriet\u00E1rios diferentes. Escolha outra origem ou outro destino. + +LedgerTransactionOwnerListEditingSupportPortfolioTransferOwnerNotFound = O propriet\u00E1rio da transfer\u00EAncia da conta de ativos n\u00E3o foi encontrado: {0} + +LedgerTransactionOwnerListEditingSupportUnsupportedOwnerEdit = O propriet\u00E1rio desta transa\u00E7\u00E3o gerenciada pelo Ledger n\u00E3o pode ser alterado aqui: {0} + +LedgerTransactionOwnerListEditingSupportUnsupportedOwnerType = O tipo de propriet\u00E1rio {0} n\u00E3o \u00E9 suportado para esta transa\u00E7\u00E3o Ledger. + +LedgerTransactionTypeEditingSupportPolicyBlockedTransition = As regras Ledger desta transa\u00E7\u00E3o n\u00E3o permitem alterar aqui o tipo de transa\u00E7\u00E3o. + +LedgerTransactionTypeEditingSupportUnsupportedTransition = Este tipo de transa\u00E7\u00E3o gerenciada pelo Ledger n\u00E3o pode ser alterado por esta a\u00E7\u00E3o. + +LedgerTransactionsViewerUnsupportedSharesInlineEdit = Esta transa\u00E7\u00E3o \u00E9 gerenciada pelo Ledger. A quantidade n\u00E3o pode ser editada diretamente nesta tabela; abra o editor da transa\u00E7\u00E3o. + MarkSecurityPageDescription = Os itens selecionados ser\u00E3o marcados como \u00EDndices de a\u00E7\u00F5es. Os \u00EDndices n\u00E3o t\u00EAm moeda. MarkSecurityPageTitle = Marcar t\u00EDtulos como \u00EDndices de a\u00E7\u00F5es diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_ru.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_ru.properties index 8167a8941b..08aa8dbf0a 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_ru.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_ru.properties @@ -1957,6 +1957,108 @@ LabelYellowWhiteBlack = \u0416\u0435\u043B\u0442\u044B\u0439 - \u0411\u0435\u043 LabelYes = \u0414\u0430 +LedgerAccountTransactionsPaneUnsupportedSharesInlineEdit = \u042D\u0442\u0430 \u043E\u043F\u0435\u0440\u0430\u0446\u0438\u044F \u0443\u043F\u0440\u0430\u0432\u043B\u044F\u0435\u0442\u0441\u044F Ledger. \u041A\u043E\u043B\u0438\u0447\u0435\u0441\u0442\u0432\u043E \u0446\u0435\u043D\u043D\u044B\u0445 \u0431\u0443\u043C\u0430\u0433 \u043D\u0435\u043B\u044C\u0437\u044F \u0438\u0437\u043C\u0435\u043D\u0438\u0442\u044C \u043F\u0440\u044F\u043C\u043E \u0432 \u044D\u0442\u043E\u0439 \u0442\u0430\u0431\u043B\u0438\u0446\u0435; \u043E\u0442\u043A\u0440\u043E\u0439\u0442\u0435 \u0440\u0435\u0434\u0430\u043A\u0442\u043E\u0440 \u043E\u043F\u0435\u0440\u0430\u0446\u0438\u0438. + +LedgerConvertPortfolioCompositeTypeActionUnsupportedLedgerBackedTransition = \u042D\u0442\u0430 \u043E\u043F\u0435\u0440\u0430\u0446\u0438\u044F \u043F\u043E \u0441\u0447\u0435\u0442\u0443 \u0446\u0435\u043D\u043D\u044B\u0445 \u0431\u0443\u043C\u0430\u0433 \u0443\u043F\u0440\u0430\u0432\u043B\u044F\u0435\u0442\u0441\u044F Ledger. \u0415\u0435 \u0442\u0438\u043F \u043D\u0435\u043B\u044C\u0437\u044F \u0438\u0437\u043C\u0435\u043D\u0438\u0442\u044C \u044D\u0442\u0438\u043C \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0435\u043C. + +LedgerConvertTransferToDepositRemovalActionCannotConvertLedgerBackedTransfer = \u042D\u0442\u043E\u0442 \u043F\u0435\u0440\u0435\u0432\u043E\u0434 \u043C\u0435\u0436\u0434\u0443 \u0441\u0447\u0435\u0442\u0430\u043C\u0438 \u0443\u043F\u0440\u0430\u0432\u043B\u044F\u0435\u0442\u0441\u044F Ledger. \u0417\u0434\u0435\u0441\u044C \u0435\u0433\u043E \u043D\u0435\u043B\u044C\u0437\u044F \u043F\u0440\u0435\u043E\u0431\u0440\u0430\u0437\u043E\u0432\u0430\u0442\u044C \u0432 \u043E\u0442\u0434\u0435\u043B\u044C\u043D\u044B\u0435 \u043E\u043F\u0435\u0440\u0430\u0446\u0438\u0438 \u0432\u043D\u0435\u0441\u0435\u043D\u0438\u044F \u0438 \u0441\u043D\u044F\u0442\u0438\u044F. + +LedgerConvertTransferToDepositRemovalActionUnsupportedTransferEntry = \u041E\u043F\u0435\u0440\u0430\u0446\u0438\u044F {0} \u043D\u0435 \u044F\u0432\u043B\u044F\u0435\u0442\u0441\u044F \u043F\u043E\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u043C\u044B\u043C \u043F\u0435\u0440\u0435\u0432\u043E\u0434\u043E\u043C \u043C\u0435\u0436\u0434\u0443 \u0441\u0447\u0435\u0442\u0430\u043C\u0438 \u0438 \u043D\u0435 \u043C\u043E\u0436\u0435\u0442 \u0431\u044B\u0442\u044C \u043F\u0440\u0435\u043E\u0431\u0440\u0430\u0437\u043E\u0432\u0430\u043D\u0430. + +LedgerExDateEditingSupportDividendOwnerNotFound = \u0414\u043B\u044F \u0434\u0438\u0432\u0438\u0434\u0435\u043D\u0434\u0430 Ledger \u0442\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044F \u0432\u043B\u0430\u0434\u0435\u043B\u0435\u0446 \u043F\u0440\u043E\u0435\u043A\u0446\u0438\u0438 \u0441\u0447\u0435\u0442\u0430, \u043D\u043E \u043E\u043D \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D. + +LedgerExDateEditingSupportNoSafeEditorLedgerBacked = Ex-date \u044D\u0442\u043E\u0439 \u043E\u043F\u0435\u0440\u0430\u0446\u0438\u0438 \u0441\u0447\u0435\u0442\u0430, \u0443\u043F\u0440\u0430\u0432\u043B\u044F\u0435\u043C\u043E\u0439 Ledger, \u043D\u0435\u043B\u044C\u0437\u044F \u0431\u0435\u0437\u043E\u043F\u0430\u0441\u043D\u043E \u0438\u0437\u043C\u0435\u043D\u0438\u0442\u044C \u0437\u0434\u0435\u0441\u044C. \u041E\u0442\u043A\u0440\u043E\u0439\u0442\u0435 \u043F\u043E\u043B\u043D\u044B\u0439 \u0440\u0435\u0434\u0430\u043A\u0442\u043E\u0440 \u043E\u043F\u0435\u0440\u0430\u0446\u0438\u0438. + +LedgerExDateEditingSupportNoSafeEditorPolicyBlocked = \u0418\u0437\u043C\u0435\u043D\u0435\u043D\u0438\u0435 ex-date \u043E\u0442\u043A\u043B\u044E\u0447\u0435\u043D\u043E \u0434\u043B\u044F \u044D\u0442\u043E\u0433\u043E \u0442\u0438\u043F\u0430 \u043E\u043F\u0435\u0440\u0430\u0446\u0438\u0438 \u0441\u0447\u0435\u0442\u0430, \u0443\u043F\u0440\u0430\u0432\u043B\u044F\u0435\u043C\u043E\u0439 Ledger, \u0447\u0442\u043E\u0431\u044B \u0437\u0430\u043F\u0438\u0441\u044C Ledger \u043E\u0441\u0442\u0430\u0432\u0430\u043B\u0430\u0441\u044C \u0441\u043E\u0433\u043B\u0430\u0441\u043E\u0432\u0430\u043D\u043D\u043E\u0439. + +LedgerNativeComponentInspectorCardinality = \u041A\u0430\u0440\u0434\u0438\u043D\u0430\u043B\u044C\u043D\u043E\u0441\u0442\u044C + +LedgerNativeComponentInspectorCode = \u041A\u043E\u0434 + +LedgerNativeComponentInspectorDateTime = \u0414\u0430\u0442\u0430/\u0432\u0440\u0435\u043C\u044F + +LedgerNativeComponentInspectorDialogTitle = \u041F\u0440\u043E\u0432\u0435\u0440\u0438\u0442\u044C \u0434\u0435\u0442\u0430\u043B\u0438 \u0437\u0430\u043F\u0438\u0441\u0438 Ledger + +LedgerNativeComponentInspectorDomain = \u0414\u043E\u043C\u0435\u043D + +LedgerNativeComponentInspectorEntryParameters = \u041F\u0430\u0440\u0430\u043C\u0435\u0442\u0440\u044B \u0437\u0430\u043F\u0438\u0441\u0438 + +LedgerNativeComponentInspectorEntryType = \u0422\u0438\u043F \u0437\u0430\u043F\u0438\u0441\u0438 + +LedgerNativeComponentInspectorEntryUUID = UUID \u0437\u0430\u043F\u0438\u0441\u0438 + +LedgerNativeComponentInspectorField = \u041F\u043E\u043B\u0435 + +LedgerNativeComponentInspectorFunctionalLegs = \u0424\u0443\u043D\u043A\u0446\u0438\u043E\u043D\u0430\u043B\u044C\u043D\u044B\u0435 \u0447\u0430\u0441\u0442\u0438 + +LedgerNativeComponentInspectorGroupExpected = \u041E\u0436\u0438\u0434\u0430\u0435\u0442\u0441\u044F \u0433\u0440\u0443\u043F\u043F\u0430 + +LedgerNativeComponentInspectorHeader = \u0417\u0430\u043F\u0438\u0441\u044C Ledger + +LedgerNativeComponentInspectorLegRole = \u0420\u043E\u043B\u044C \u0447\u0430\u0441\u0442\u0438 + +LedgerNativeComponentInspectorMenu = \u041F\u0440\u043E\u0432\u0435\u0440\u0438\u0442\u044C \u0437\u0430\u043F\u0438\u0441\u044C Ledger... + +LedgerNativeComponentInspectorNativeTargeted = \u041D\u0430\u0442\u0438\u0432\u043D\u0430\u044F \u0446\u0435\u043B\u0435\u0432\u0430\u044F + +LedgerNativeComponentInspectorNoNativeDefinition = \u0414\u043B\u044F \u044D\u0442\u043E\u0433\u043E \u0442\u0438\u043F\u0430 \u0437\u0430\u043F\u0438\u0441\u0438 \u043D\u0435 \u043D\u0430\u0441\u0442\u0440\u043E\u0435\u043D\u043E \u043D\u0430\u0442\u0438\u0432\u043D\u043E\u0435 \u043E\u043F\u0440\u0435\u0434\u0435\u043B\u0435\u043D\u0438\u0435 Ledger. + +LedgerNativeComponentInspectorOwner = \u0412\u043B\u0430\u0434\u0435\u043B\u0435\u0446 + +LedgerNativeComponentInspectorParameter = \u041F\u0430\u0440\u0430\u043C\u0435\u0442\u0440 + +LedgerNativeComponentInspectorPostingGroupUUID = UUID \u0433\u0440\u0443\u043F\u043F\u044B \u043F\u0440\u043E\u0432\u043E\u0434\u043E\u043A + +LedgerNativeComponentInspectorPostingParameters = \u041F\u0430\u0440\u0430\u043C\u0435\u0442\u0440\u044B \u043F\u0440\u043E\u0432\u043E\u0434\u043A\u0438 + +LedgerNativeComponentInspectorPostingType = \u0422\u0438\u043F \u043F\u0440\u043E\u0432\u043E\u0434\u043A\u0438 + +LedgerNativeComponentInspectorPostingUUID = UUID \u043F\u0440\u043E\u0432\u043E\u0434\u043A\u0438 + +LedgerNativeComponentInspectorPostings = \u041F\u0440\u043E\u0432\u043E\u0434\u043A\u0438 + +LedgerNativeComponentInspectorPrimaryExpected = \u041E\u0436\u0438\u0434\u0430\u0435\u0442\u0441\u044F \u043E\u0441\u043D\u043E\u0432\u043D\u0430\u044F \u0441\u0441\u044B\u043B\u043A\u0430 + +LedgerNativeComponentInspectorPrimaryPostingUUID = UUID \u043E\u0441\u043D\u043E\u0432\u043D\u043E\u0439 \u043F\u0440\u043E\u0432\u043E\u0434\u043A\u0438 + +LedgerNativeComponentInspectorProjectionRefs = \u0421\u0441\u044B\u043B\u043A\u0438 \u043F\u0440\u043E\u0435\u043A\u0446\u0438\u0439 + +LedgerNativeComponentInspectorProjectionRole = \u0420\u043E\u043B\u044C \u043F\u0440\u043E\u0435\u043A\u0446\u0438\u0438 + +LedgerNativeComponentInspectorProjectionUUID = UUID \u043F\u0440\u043E\u0435\u043A\u0446\u0438\u0438 + +LedgerNativeComponentInspectorSelectedPostingGroupUUID = UUID \u0432\u044B\u0431\u0440\u0430\u043D\u043D\u043E\u0439 \u0433\u0440\u0443\u043F\u043F\u044B \u043F\u0440\u043E\u0432\u043E\u0434\u043E\u043A + +LedgerNativeComponentInspectorSelectedPrimaryPostingUUID = UUID \u0432\u044B\u0431\u0440\u0430\u043D\u043D\u043E\u0439 \u043E\u0441\u043D\u043E\u0432\u043D\u043E\u0439 \u043F\u0440\u043E\u0432\u043E\u0434\u043A\u0438 + +LedgerNativeComponentInspectorSelectedProjectionRole = \u0412\u044B\u0431\u0440\u0430\u043D\u043D\u0430\u044F \u0440\u043E\u043B\u044C \u043F\u0440\u043E\u0435\u043A\u0446\u0438\u0438 + +LedgerNativeComponentInspectorSelectedProjectionUUID = UUID \u0432\u044B\u0431\u0440\u0430\u043D\u043D\u043E\u0439 \u043F\u0440\u043E\u0435\u043A\u0446\u0438\u0438 + +LedgerNativeComponentInspectorShape = \u0421\u0442\u0440\u0443\u043A\u0442\u0443\u0440\u0430 + +LedgerNativeComponentInspectorValue = \u0417\u043D\u0430\u0447\u0435\u043D\u0438\u0435 + +LedgerNativeComponentInspectorValueKind = \u0422\u0438\u043F \u0437\u043D\u0430\u0447\u0435\u043D\u0438\u044F + +LedgerPropertyEditingSupportUnsupportedInlineEdit = \u042D\u0442\u043E \u0437\u043D\u0430\u0447\u0435\u043D\u0438\u0435 \u0443\u043F\u0440\u0430\u0432\u043B\u044F\u0435\u0442\u0441\u044F Ledger \u0438 \u043D\u0435 \u043C\u043E\u0436\u0435\u0442 \u0431\u044B\u0442\u044C \u0438\u0437\u043C\u0435\u043D\u0435\u043D\u043E \u043D\u0430\u043F\u0440\u044F\u043C\u0443\u044E \u0434\u043B\u044F {0}. \u0418\u0441\u043F\u043E\u043B\u044C\u0437\u0443\u0439\u0442\u0435 \u043F\u043E\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u043C\u044B\u0439 \u0440\u0435\u0434\u0430\u043A\u0442\u043E\u0440 Ledger. + +LedgerTransactionOwnerListEditingSupportAccountTransferOwnerNotFound = \u0412\u043B\u0430\u0434\u0435\u043B\u0435\u0446 \u043F\u0435\u0440\u0435\u0432\u043E\u0434\u0430 \u043C\u0435\u0436\u0434\u0443 \u0441\u0447\u0435\u0442\u0430\u043C\u0438 \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D: {0} + +LedgerTransactionOwnerListEditingSupportDistinctOwnersRequired = \u0423 \u043F\u0435\u0440\u0435\u0432\u043E\u0434\u0430 \u0434\u043E\u043B\u0436\u043D\u044B \u0431\u044B\u0442\u044C \u0434\u0432\u0430 \u0440\u0430\u0437\u043D\u044B\u0445 \u0432\u043B\u0430\u0434\u0435\u043B\u044C\u0446\u0430. \u0412\u044B\u0431\u0435\u0440\u0438\u0442\u0435 \u0434\u0440\u0443\u0433\u043E\u0439 \u0438\u0441\u0442\u043E\u0447\u043D\u0438\u043A \u0438\u043B\u0438 \u043F\u043E\u043B\u0443\u0447\u0430\u0442\u0435\u043B\u044C. + +LedgerTransactionOwnerListEditingSupportPortfolioTransferOwnerNotFound = \u0412\u043B\u0430\u0434\u0435\u043B\u0435\u0446 \u043F\u0435\u0440\u0435\u0432\u043E\u0434\u0430 \u043F\u043E \u0441\u0447\u0435\u0442\u0443 \u0446\u0435\u043D\u043D\u044B\u0445 \u0431\u0443\u043C\u0430\u0433 \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D: {0} + +LedgerTransactionOwnerListEditingSupportUnsupportedOwnerEdit = \u0412\u043B\u0430\u0434\u0435\u043B\u044C\u0446\u0430 \u044D\u0442\u043E\u0439 \u043E\u043F\u0435\u0440\u0430\u0446\u0438\u0438, \u0443\u043F\u0440\u0430\u0432\u043B\u044F\u0435\u043C\u043E\u0439 Ledger, \u043D\u0435\u043B\u044C\u0437\u044F \u0438\u0437\u043C\u0435\u043D\u0438\u0442\u044C \u0437\u0434\u0435\u0441\u044C: {0} + +LedgerTransactionOwnerListEditingSupportUnsupportedOwnerType = \u0422\u0438\u043F \u0432\u043B\u0430\u0434\u0435\u043B\u044C\u0446\u0430 {0} \u043D\u0435 \u043F\u043E\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044F \u0434\u043B\u044F \u044D\u0442\u043E\u0439 \u043E\u043F\u0435\u0440\u0430\u0446\u0438\u0438 Ledger. + +LedgerTransactionTypeEditingSupportPolicyBlockedTransition = \u041F\u0440\u0430\u0432\u0438\u043B\u0430 Ledger \u0434\u043B\u044F \u044D\u0442\u043E\u0439 \u043E\u043F\u0435\u0440\u0430\u0446\u0438\u0438 \u043D\u0435 \u043F\u043E\u0437\u0432\u043E\u043B\u044F\u044E\u0442 \u0438\u0437\u043C\u0435\u043D\u0438\u0442\u044C \u0437\u0434\u0435\u0441\u044C \u0442\u0438\u043F \u043E\u043F\u0435\u0440\u0430\u0446\u0438\u0438. + +LedgerTransactionTypeEditingSupportUnsupportedTransition = \u042D\u0442\u043E\u0442 \u0442\u0438\u043F \u043E\u043F\u0435\u0440\u0430\u0446\u0438\u0438, \u0443\u043F\u0440\u0430\u0432\u043B\u044F\u0435\u043C\u043E\u0439 Ledger, \u043D\u0435\u043B\u044C\u0437\u044F \u0438\u0437\u043C\u0435\u043D\u0438\u0442\u044C \u044D\u0442\u0438\u043C \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0435\u043C. + +LedgerTransactionsViewerUnsupportedSharesInlineEdit = \u042D\u0442\u0430 \u043E\u043F\u0435\u0440\u0430\u0446\u0438\u044F \u0443\u043F\u0440\u0430\u0432\u043B\u044F\u0435\u0442\u0441\u044F Ledger. \u041A\u043E\u043B\u0438\u0447\u0435\u0441\u0442\u0432\u043E \u0446\u0435\u043D\u043D\u044B\u0445 \u0431\u0443\u043C\u0430\u0433 \u043D\u0435\u043B\u044C\u0437\u044F \u0438\u0437\u043C\u0435\u043D\u0438\u0442\u044C \u043F\u0440\u044F\u043C\u043E \u0432 \u044D\u0442\u043E\u0439 \u0442\u0430\u0431\u043B\u0438\u0446\u0435; \u043E\u0442\u043A\u0440\u043E\u0439\u0442\u0435 \u0440\u0435\u0434\u0430\u043A\u0442\u043E\u0440 \u043E\u043F\u0435\u0440\u0430\u0446\u0438\u0438. + MarkSecurityPageDescription = \u0412\u044B\u0431\u0440\u0430\u043D\u043D\u044B\u0435 \u043F\u043E\u0437\u0438\u0446\u0438\u0438 \u0431\u0443\u0434\u0443\u0442 \u043E\u0442\u043C\u0435\u0447\u0435\u043D\u044B \u043A\u0430\u043A \u0444\u043E\u043D\u0434\u043E\u0432\u044B\u0435 \u0438\u043D\u0434\u0435\u043A\u0441\u044B. \u0423 \u0438\u043D\u0434\u0435\u043A\u0441\u043E\u0432 \u043D\u0435\u0442 \u0432\u0430\u043B\u044E\u0442\u044B. MarkSecurityPageTitle = \u041E\u0442\u043C\u0435\u0442\u0438\u0442\u044C \u0430\u043A\u0442\u0438\u0432\u044B \u043A\u0430\u043A \u0444\u043E\u043D\u0434\u043E\u0432\u044B\u0435 \u0438\u043D\u0434\u0435\u043A\u0441\u044B diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_sk.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_sk.properties index 00e17a4e06..ff53a2fef7 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_sk.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_sk.properties @@ -1961,6 +1961,108 @@ LabelYellowWhiteBlack = \u017Dlt\u00E1 - Biela - \u010Cierna LabelYes = \u00C1no +LedgerAccountTransactionsPaneUnsupportedSharesInlineEdit = T\u00FAto transakciu spravuje Ledger. Po\u010Det akci\u00ED nemo\u017Eno upravi\u0165 priamo v tejto tabu\u013Eke; otvorte editor transakcie. + +LedgerConvertPortfolioCompositeTypeActionUnsupportedLedgerBackedTransition = T\u00FAto transakciu \u00FA\u010Dtu cenn\u00FDch papierov spravuje Ledger. Jej typ nemo\u017Eno zmeni\u0165 touto akciou. + +LedgerConvertTransferToDepositRemovalActionCannotConvertLedgerBackedTransfer = Tento prevod medzi \u00FA\u010Dtami spravuje Ledger. Tu ho nemo\u017Eno previes\u0165 na samostatn\u00FD vklad a v\u00FDber. + +LedgerConvertTransferToDepositRemovalActionUnsupportedTransferEntry = Transakcia {0} nie je podporovan\u00FD prevod \u00FA\u010Dtu a nemo\u017Eno ju previes\u0165. + +LedgerExDateEditingSupportDividendOwnerNotFound = Dividenda Ledger vy\u017Eaduje vlastn\u00EDka projekcie \u00FA\u010Dtu, ale \u017Eiadny sa nena\u0161iel. + +LedgerExDateEditingSupportNoSafeEditorLedgerBacked = Ex-date tejto \u00FA\u010Dtovnej transakcie spravovanej Ledgerom nemo\u017Eno bezpe\u010Dne upravi\u0165 tu. Otvorte \u00FApln\u00FD editor transakcie. + +LedgerExDateEditingSupportNoSafeEditorPolicyBlocked = \u00DAprava ex-date je pre tento typ \u00FA\u010Dtovnej transakcie spravovanej Ledgerom vypnut\u00E1, aby z\u00E1znam Ledger zostal konzistentn\u00FD. + +LedgerNativeComponentInspectorCardinality = Kardinalita + +LedgerNativeComponentInspectorCode = K\u00F3d + +LedgerNativeComponentInspectorDateTime = D\u00E1tum/\u010Das + +LedgerNativeComponentInspectorDialogTitle = Skontrolova\u0165 detaily z\u00E1znamu Ledger + +LedgerNativeComponentInspectorDomain = Dom\u00E9na + +LedgerNativeComponentInspectorEntryParameters = Parametre z\u00E1znamu + +LedgerNativeComponentInspectorEntryType = Typ z\u00E1znamu + +LedgerNativeComponentInspectorEntryUUID = UUID z\u00E1znamu + +LedgerNativeComponentInspectorField = Pole + +LedgerNativeComponentInspectorFunctionalLegs = Funk\u010Dn\u00E9 \u010Dasti + +LedgerNativeComponentInspectorGroupExpected = O\u010Dak\u00E1va sa skupina + +LedgerNativeComponentInspectorHeader = Z\u00E1znam Ledger + +LedgerNativeComponentInspectorLegRole = Rola \u010Dasti + +LedgerNativeComponentInspectorMenu = Skontrolova\u0165 z\u00E1znam Ledger... + +LedgerNativeComponentInspectorNativeTargeted = Nat\u00EDvne cielen\u00E9 + +LedgerNativeComponentInspectorNoNativeDefinition = Pre tento typ z\u00E1znamu nie je nastaven\u00E1 \u017Eiadna nat\u00EDvna defin\u00EDcia Ledger. + +LedgerNativeComponentInspectorOwner = Vlastn\u00EDk + +LedgerNativeComponentInspectorParameter = Parameter + +LedgerNativeComponentInspectorPostingGroupUUID = UUID skupiny polo\u017Eiek + +LedgerNativeComponentInspectorPostingParameters = Parametre polo\u017Eky + +LedgerNativeComponentInspectorPostingType = Typ polo\u017Eky + +LedgerNativeComponentInspectorPostingUUID = UUID polo\u017Eky + +LedgerNativeComponentInspectorPostings = Polo\u017Eky + +LedgerNativeComponentInspectorPrimaryExpected = O\u010Dak\u00E1va sa prim\u00E1rny odkaz + +LedgerNativeComponentInspectorPrimaryPostingUUID = UUID prim\u00E1rnej polo\u017Eky + +LedgerNativeComponentInspectorProjectionRefs = Odkazy projekci\u00ED + +LedgerNativeComponentInspectorProjectionRole = Rola projekcie + +LedgerNativeComponentInspectorProjectionUUID = UUID projekcie + +LedgerNativeComponentInspectorSelectedPostingGroupUUID = Vybran\u00E9 UUID skupiny polo\u017Eiek + +LedgerNativeComponentInspectorSelectedPrimaryPostingUUID = Vybran\u00E9 UUID prim\u00E1rnej polo\u017Eky + +LedgerNativeComponentInspectorSelectedProjectionRole = Vybran\u00E1 rola projekcie + +LedgerNativeComponentInspectorSelectedProjectionUUID = Vybran\u00E9 UUID projekcie + +LedgerNativeComponentInspectorShape = \u0160trukt\u00FAra + +LedgerNativeComponentInspectorValue = Hodnota + +LedgerNativeComponentInspectorValueKind = Druh hodnoty + +LedgerPropertyEditingSupportUnsupportedInlineEdit = T\u00FAto hodnotu spravuje Ledger a pre {0} ju nemo\u017Eno upravi\u0165 priamo. Pou\u017Eite podporovan\u00FD editor Ledger. + +LedgerTransactionOwnerListEditingSupportAccountTransferOwnerNotFound = Vlastn\u00EDk prevodu medzi \u00FA\u010Dtami sa nena\u0161iel: {0} + +LedgerTransactionOwnerListEditingSupportDistinctOwnersRequired = Prevod mus\u00ED ma\u0165 dvoch r\u00F4znych vlastn\u00EDkov. Vyberte in\u00FD zdroj alebo cie\u013E. + +LedgerTransactionOwnerListEditingSupportPortfolioTransferOwnerNotFound = Vlastn\u00EDk prevodu \u00FA\u010Dtu cenn\u00FDch papierov sa nena\u0161iel: {0} + +LedgerTransactionOwnerListEditingSupportUnsupportedOwnerEdit = Vlastn\u00EDka tejto transakcie spravovanej Ledgerom nemo\u017Eno zmeni\u0165 tu: {0} + +LedgerTransactionOwnerListEditingSupportUnsupportedOwnerType = Typ vlastn\u00EDka {0} nie je podporovan\u00FD pre t\u00FAto transakciu Ledger. + +LedgerTransactionTypeEditingSupportPolicyBlockedTransition = Pravidl\u00E1 Ledger pre t\u00FAto transakciu tu neumo\u017E\u0148uj\u00FA zmeni\u0165 typ transakcie. + +LedgerTransactionTypeEditingSupportUnsupportedTransition = Tento typ transakcie spravovanej Ledgerom nemo\u017Eno zmeni\u0165 touto akciou. + +LedgerTransactionsViewerUnsupportedSharesInlineEdit = T\u00FAto transakciu spravuje Ledger. Po\u010Det akci\u00ED nemo\u017Eno upravi\u0165 priamo v tejto tabu\u013Eke; otvorte editor transakcie. + MarkSecurityPageDescription = Vybran\u00E9 polo\u017Eky bud\u00FA ozna\u010Den\u00E9 ako akciov\u00E9 indexy. Indexy nemaj\u00FA \u017Eiadnu menu. MarkSecurityPageTitle = Ozna\u010Denie cenn\u00FDch papierov ako akciov\u00FDch indexov diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_tr.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_tr.properties index bac55000a3..73e1f4c30e 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_tr.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_tr.properties @@ -1961,6 +1961,108 @@ LabelYellowWhiteBlack = Sar\u0131 - Beyaz - Siyah LabelYes = Evet +LedgerAccountTransactionsPaneUnsupportedSharesInlineEdit = Bu i\u015Flem Ledger taraf\u0131ndan y\u00F6netiliyor. Adet bu tabloda do\u011Frudan d\u00FCzenlenemez; i\u015Flem d\u00FCzenleyicisini a\u00E7\u0131n. + +LedgerConvertPortfolioCompositeTypeActionUnsupportedLedgerBackedTransition = Bu menkul k\u0131ymet hesab\u0131 i\u015Flemi Ledger taraf\u0131ndan y\u00F6netiliyor. T\u00FCr\u00FC bu i\u015Flemle de\u011Fi\u015Ftirilemez. + +LedgerConvertTransferToDepositRemovalActionCannotConvertLedgerBackedTransfer = Bu hesap transferi Ledger taraf\u0131ndan y\u00F6netiliyor. Burada ayr\u0131 para yat\u0131rma ve \u00E7ekme i\u015Flemlerine d\u00F6n\u00FC\u015Ft\u00FCr\u00FClemez. + +LedgerConvertTransferToDepositRemovalActionUnsupportedTransferEntry = {0} i\u015Flemi desteklenen bir hesap transferi de\u011Fildir ve d\u00F6n\u00FC\u015Ft\u00FCr\u00FClemez. + +LedgerExDateEditingSupportDividendOwnerNotFound = Ledger temett\u00FCs\u00FC i\u00E7in bir hesap projeksiyonu sahibi gerekir, ancak bulunamad\u0131. + +LedgerExDateEditingSupportNoSafeEditorLedgerBacked = Ledger taraf\u0131ndan y\u00F6netilen bu hesap i\u015Fleminin ex-date de\u011Feri burada g\u00FCvenli \u015Fekilde d\u00FCzenlenemez. Tam i\u015Flem d\u00FCzenleyicisini a\u00E7\u0131n. + +LedgerExDateEditingSupportNoSafeEditorPolicyBlocked = Ledger kayd\u0131n\u0131n tutarl\u0131 kalmas\u0131 i\u00E7in bu Ledger y\u00F6netimli hesap i\u015Flem t\u00FCr\u00FCnde ex-date d\u00FCzenleme kapal\u0131d\u0131r. + +LedgerNativeComponentInspectorCardinality = Kardinalite + +LedgerNativeComponentInspectorCode = Kod + +LedgerNativeComponentInspectorDateTime = Tarih/saat + +LedgerNativeComponentInspectorDialogTitle = Ledger kayd\u0131 ayr\u0131nt\u0131lar\u0131n\u0131 incele + +LedgerNativeComponentInspectorDomain = Alan + +LedgerNativeComponentInspectorEntryParameters = Kay\u0131t parametreleri + +LedgerNativeComponentInspectorEntryType = Kay\u0131t t\u00FCr\u00FC + +LedgerNativeComponentInspectorEntryUUID = Kay\u0131t UUID + +LedgerNativeComponentInspectorField = Alan + +LedgerNativeComponentInspectorFunctionalLegs = \u0130\u015Flevsel par\u00E7alar + +LedgerNativeComponentInspectorGroupExpected = Beklenen grup + +LedgerNativeComponentInspectorHeader = Ledger kayd\u0131 + +LedgerNativeComponentInspectorLegRole = Par\u00E7a rol\u00FC + +LedgerNativeComponentInspectorMenu = Ledger kayd\u0131n\u0131 incele... + +LedgerNativeComponentInspectorNativeTargeted = Yerel hedefli + +LedgerNativeComponentInspectorNoNativeDefinition = Bu kay\u0131t t\u00FCr\u00FC i\u00E7in yerel Ledger tan\u0131m\u0131 yap\u0131land\u0131r\u0131lmam\u0131\u015F. + +LedgerNativeComponentInspectorOwner = Sahip + +LedgerNativeComponentInspectorParameter = Parametre + +LedgerNativeComponentInspectorPostingGroupUUID = Kay\u0131t grubu UUID + +LedgerNativeComponentInspectorPostingParameters = Kay\u0131t parametreleri + +LedgerNativeComponentInspectorPostingType = Kay\u0131t t\u00FCr\u00FC + +LedgerNativeComponentInspectorPostingUUID = Kay\u0131t UUID + +LedgerNativeComponentInspectorPostings = Kay\u0131tlar + +LedgerNativeComponentInspectorPrimaryExpected = Birincil referans bekleniyor + +LedgerNativeComponentInspectorPrimaryPostingUUID = Birincil kay\u0131t UUID + +LedgerNativeComponentInspectorProjectionRefs = Projeksiyon referanslar\u0131 + +LedgerNativeComponentInspectorProjectionRole = Projeksiyon rol\u00FC + +LedgerNativeComponentInspectorProjectionUUID = Projeksiyon UUID + +LedgerNativeComponentInspectorSelectedPostingGroupUUID = Se\u00E7ili kay\u0131t grubu UUID + +LedgerNativeComponentInspectorSelectedPrimaryPostingUUID = Se\u00E7ili birincil kay\u0131t UUID + +LedgerNativeComponentInspectorSelectedProjectionRole = Se\u00E7ili projeksiyon rol\u00FC + +LedgerNativeComponentInspectorSelectedProjectionUUID = Se\u00E7ili projeksiyon UUID + +LedgerNativeComponentInspectorShape = Yap\u0131 + +LedgerNativeComponentInspectorValue = De\u011Fer + +LedgerNativeComponentInspectorValueKind = De\u011Fer t\u00FCr\u00FC + +LedgerPropertyEditingSupportUnsupportedInlineEdit = Bu de\u011Fer Ledger taraf\u0131ndan y\u00F6netiliyor ve {0} i\u00E7in do\u011Frudan d\u00FCzenlenemez. Desteklenen bir Ledger d\u00FCzenleyicisi kullan\u0131n. + +LedgerTransactionOwnerListEditingSupportAccountTransferOwnerNotFound = Hesap transferi sahibi bulunamad\u0131: {0} + +LedgerTransactionOwnerListEditingSupportDistinctOwnersRequired = Bir transfer iki farkl\u0131 sahibi korumal\u0131d\u0131r. Farkl\u0131 bir kaynak veya hedef se\u00E7in. + +LedgerTransactionOwnerListEditingSupportPortfolioTransferOwnerNotFound = Menkul k\u0131ymet hesab\u0131 transferi sahibi bulunamad\u0131: {0} + +LedgerTransactionOwnerListEditingSupportUnsupportedOwnerEdit = Ledger taraf\u0131ndan y\u00F6netilen bu i\u015Flemin sahibi burada de\u011Fi\u015Ftirilemez: {0} + +LedgerTransactionOwnerListEditingSupportUnsupportedOwnerType = Sahip t\u00FCr\u00FC {0} bu Ledger i\u015Flemi i\u00E7in desteklenmiyor. + +LedgerTransactionTypeEditingSupportPolicyBlockedTransition = Bu i\u015Flemin Ledger kurallar\u0131 i\u015Flem t\u00FCr\u00FCn\u00FCn burada de\u011Fi\u015Ftirilmesine izin vermiyor. + +LedgerTransactionTypeEditingSupportUnsupportedTransition = Ledger taraf\u0131ndan y\u00F6netilen bu i\u015Flem t\u00FCr\u00FC bu eylemle de\u011Fi\u015Ftirilemez. + +LedgerTransactionsViewerUnsupportedSharesInlineEdit = Bu i\u015Flem Ledger taraf\u0131ndan y\u00F6netiliyor. Adet bu tabloda do\u011Frudan d\u00FCzenlenemez; i\u015Flem d\u00FCzenleyicisini a\u00E7\u0131n. + MarkSecurityPageDescription = Se\u00E7ilen \u00F6\u011Feler hisse senedi endeksleri olarak i\u015Faretlenecektir. Endekslerin para birimi yoktur. MarkSecurityPageTitle = Menkul k\u0131ymetleri hisse senedi endeksleri olarak i\u015Faretleyin diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_vi.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_vi.properties index 6286e06979..60a11daa2e 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_vi.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_vi.properties @@ -1961,6 +1961,108 @@ LabelYellowWhiteBlack = V\u00E0ng - Tr\u1EAFng - \u0110en LabelYes = C\u00F3 +LedgerAccountTransactionsPaneUnsupportedSharesInlineEdit = Giao d\u1ECBch n\u00E0y do Ledger qu\u1EA3n l\u00FD. Kh\u00F4ng th\u1EC3 s\u1EEDa s\u1ED1 l\u01B0\u1EE3ng tr\u1EF1c ti\u1EBFp trong b\u1EA3ng n\u00E0y; h\u00E3y m\u1EDF tr\u00ECnh s\u1EEDa giao d\u1ECBch. + +LedgerConvertPortfolioCompositeTypeActionUnsupportedLedgerBackedTransition = Giao d\u1ECBch t\u00E0i kho\u1EA3n ch\u1EE9ng kho\u00E1n n\u00E0y do Ledger qu\u1EA3n l\u00FD. Kh\u00F4ng th\u1EC3 \u0111\u1ED5i lo\u1EA1i b\u1EB1ng thao t\u00E1c n\u00E0y. + +LedgerConvertTransferToDepositRemovalActionCannotConvertLedgerBackedTransfer = Kho\u1EA3n chuy\u1EC3n gi\u1EEFa c\u00E1c t\u00E0i kho\u1EA3n n\u00E0y do Ledger qu\u1EA3n l\u00FD. Kh\u00F4ng th\u1EC3 chuy\u1EC3n n\u00F3 th\u00E0nh giao d\u1ECBch n\u1ED9p v\u00E0 r\u00FAt ri\u00EAng t\u1EA1i \u0111\u00E2y. + +LedgerConvertTransferToDepositRemovalActionUnsupportedTransferEntry = Giao d\u1ECBch {0} kh\u00F4ng ph\u1EA3i l\u00E0 giao d\u1ECBch chuy\u1EC3n t\u00E0i kho\u1EA3n \u0111\u01B0\u1EE3c h\u1ED7 tr\u1EE3 v\u00E0 kh\u00F4ng th\u1EC3 chuy\u1EC3n \u0111\u1ED5i. + +LedgerExDateEditingSupportDividendOwnerNotFound = C\u1ED5 t\u1EE9c Ledger c\u1EA7n ch\u1EE7 s\u1EDF h\u1EEFu ph\u00E9p chi\u1EBFu t\u00E0i kho\u1EA3n, nh\u01B0ng kh\u00F4ng t\u00ECm th\u1EA5y. + +LedgerExDateEditingSupportNoSafeEditorLedgerBacked = Kh\u00F4ng th\u1EC3 s\u1EEDa an to\u00E0n ng\u00E0y giao d\u1ECBch kh\u00F4ng h\u01B0\u1EDFng quy\u1EC1n c\u1EE7a giao d\u1ECBch t\u00E0i kho\u1EA3n do Ledger qu\u1EA3n l\u00FD t\u1EA1i \u0111\u00E2y. H\u00E3y m\u1EDF tr\u00ECnh s\u1EEDa giao d\u1ECBch \u0111\u1EA7y \u0111\u1EE7. + +LedgerExDateEditingSupportNoSafeEditorPolicyBlocked = Vi\u1EC7c s\u1EEDa ng\u00E0y giao d\u1ECBch kh\u00F4ng h\u01B0\u1EDFng quy\u1EC1n b\u1ECB t\u1EAFt cho lo\u1EA1i giao d\u1ECBch t\u00E0i kho\u1EA3n do Ledger qu\u1EA3n l\u00FD n\u00E0y \u0111\u1EC3 gi\u1EEF b\u1EA3n ghi Ledger nh\u1EA5t qu\u00E1n. + +LedgerNativeComponentInspectorCardinality = S\u1ED1 l\u01B0\u1EE3ng quan h\u1EC7 + +LedgerNativeComponentInspectorCode = M\u00E3 + +LedgerNativeComponentInspectorDateTime = Ng\u00E0y/gi\u1EDD + +LedgerNativeComponentInspectorDialogTitle = Ki\u1EC3m tra chi ti\u1EBFt b\u1EA3n ghi Ledger + +LedgerNativeComponentInspectorDomain = Mi\u1EC1n + +LedgerNativeComponentInspectorEntryParameters = Tham s\u1ED1 b\u1EA3n ghi + +LedgerNativeComponentInspectorEntryType = Lo\u1EA1i b\u1EA3n ghi + +LedgerNativeComponentInspectorEntryUUID = UUID b\u1EA3n ghi + +LedgerNativeComponentInspectorField = Tr\u01B0\u1EDDng + +LedgerNativeComponentInspectorFunctionalLegs = Th\u00E0nh ph\u1EA7n ch\u1EE9c n\u0103ng + +LedgerNativeComponentInspectorGroupExpected = Nh\u00F3m \u0111\u01B0\u1EE3c mong \u0111\u1EE3i + +LedgerNativeComponentInspectorHeader = B\u1EA3n ghi Ledger + +LedgerNativeComponentInspectorLegRole = Vai tr\u00F2 th\u00E0nh ph\u1EA7n + +LedgerNativeComponentInspectorMenu = Ki\u1EC3m tra b\u1EA3n ghi Ledger... + +LedgerNativeComponentInspectorNativeTargeted = G\u1ED1c c\u00F3 \u0111\u00EDch + +LedgerNativeComponentInspectorNoNativeDefinition = Ch\u01B0a c\u1EA5u h\u00ECnh \u0111\u1ECBnh ngh\u0129a Ledger g\u1ED1c cho lo\u1EA1i b\u1EA3n ghi n\u00E0y. + +LedgerNativeComponentInspectorOwner = Ch\u1EE7 s\u1EDF h\u1EEFu + +LedgerNativeComponentInspectorParameter = Tham s\u1ED1 + +LedgerNativeComponentInspectorPostingGroupUUID = UUID nh\u00F3m b\u00FAt to\u00E1n + +LedgerNativeComponentInspectorPostingParameters = Tham s\u1ED1 b\u00FAt to\u00E1n + +LedgerNativeComponentInspectorPostingType = Lo\u1EA1i b\u00FAt to\u00E1n + +LedgerNativeComponentInspectorPostingUUID = UUID b\u00FAt to\u00E1n + +LedgerNativeComponentInspectorPostings = B\u00FAt to\u00E1n + +LedgerNativeComponentInspectorPrimaryExpected = C\u1EA7n tham chi\u1EBFu ch\u00EDnh + +LedgerNativeComponentInspectorPrimaryPostingUUID = UUID b\u00FAt to\u00E1n ch\u00EDnh + +LedgerNativeComponentInspectorProjectionRefs = Tham chi\u1EBFu ph\u00E9p chi\u1EBFu + +LedgerNativeComponentInspectorProjectionRole = Vai tr\u00F2 ph\u00E9p chi\u1EBFu + +LedgerNativeComponentInspectorProjectionUUID = UUID ph\u00E9p chi\u1EBFu + +LedgerNativeComponentInspectorSelectedPostingGroupUUID = UUID nh\u00F3m b\u00FAt to\u00E1n \u0111\u00E3 ch\u1ECDn + +LedgerNativeComponentInspectorSelectedPrimaryPostingUUID = UUID b\u00FAt to\u00E1n ch\u00EDnh \u0111\u00E3 ch\u1ECDn + +LedgerNativeComponentInspectorSelectedProjectionRole = Vai tr\u00F2 ph\u00E9p chi\u1EBFu \u0111\u00E3 ch\u1ECDn + +LedgerNativeComponentInspectorSelectedProjectionUUID = UUID ph\u00E9p chi\u1EBFu \u0111\u00E3 ch\u1ECDn + +LedgerNativeComponentInspectorShape = C\u1EA5u tr\u00FAc + +LedgerNativeComponentInspectorValue = Gi\u00E1 tr\u1ECB + +LedgerNativeComponentInspectorValueKind = Ki\u1EC3u gi\u00E1 tr\u1ECB + +LedgerPropertyEditingSupportUnsupportedInlineEdit = Gi\u00E1 tr\u1ECB n\u00E0y do Ledger qu\u1EA3n l\u00FD v\u00E0 kh\u00F4ng th\u1EC3 s\u1EEDa tr\u1EF1c ti\u1EBFp cho {0}. H\u00E3y d\u00F9ng tr\u00ECnh s\u1EEDa Ledger \u0111\u01B0\u1EE3c h\u1ED7 tr\u1EE3. + +LedgerTransactionOwnerListEditingSupportAccountTransferOwnerNotFound = Kh\u00F4ng t\u00ECm th\u1EA5y ch\u1EE7 s\u1EDF h\u1EEFu c\u1EE7a giao d\u1ECBch chuy\u1EC3n t\u00E0i kho\u1EA3n: {0} + +LedgerTransactionOwnerListEditingSupportDistinctOwnersRequired = M\u1ED9t giao d\u1ECBch chuy\u1EC3n ph\u1EA3i gi\u1EEF hai ch\u1EE7 s\u1EDF h\u1EEFu kh\u00E1c nhau. H\u00E3y ch\u1ECDn ngu\u1ED3n ho\u1EB7c \u0111\u00EDch kh\u00E1c. + +LedgerTransactionOwnerListEditingSupportPortfolioTransferOwnerNotFound = Kh\u00F4ng t\u00ECm th\u1EA5y ch\u1EE7 s\u1EDF h\u1EEFu c\u1EE7a giao d\u1ECBch chuy\u1EC3n t\u00E0i kho\u1EA3n ch\u1EE9ng kho\u00E1n: {0} + +LedgerTransactionOwnerListEditingSupportUnsupportedOwnerEdit = Kh\u00F4ng th\u1EC3 thay \u0111\u1ED5i ch\u1EE7 s\u1EDF h\u1EEFu c\u1EE7a giao d\u1ECBch do Ledger qu\u1EA3n l\u00FD n\u00E0y t\u1EA1i \u0111\u00E2y: {0} + +LedgerTransactionOwnerListEditingSupportUnsupportedOwnerType = Lo\u1EA1i ch\u1EE7 s\u1EDF h\u1EEFu {0} kh\u00F4ng \u0111\u01B0\u1EE3c h\u1ED7 tr\u1EE3 cho giao d\u1ECBch Ledger n\u00E0y. + +LedgerTransactionTypeEditingSupportPolicyBlockedTransition = Quy t\u1EAFc Ledger c\u1EE7a giao d\u1ECBch n\u00E0y kh\u00F4ng cho ph\u00E9p \u0111\u1ED5i lo\u1EA1i giao d\u1ECBch t\u1EA1i \u0111\u00E2y. + +LedgerTransactionTypeEditingSupportUnsupportedTransition = Kh\u00F4ng th\u1EC3 \u0111\u1ED5i lo\u1EA1i giao d\u1ECBch do Ledger qu\u1EA3n l\u00FD n\u00E0y b\u1EB1ng thao t\u00E1c n\u00E0y. + +LedgerTransactionsViewerUnsupportedSharesInlineEdit = Giao d\u1ECBch n\u00E0y do Ledger qu\u1EA3n l\u00FD. Kh\u00F4ng th\u1EC3 s\u1EEDa s\u1ED1 l\u01B0\u1EE3ng tr\u1EF1c ti\u1EBFp trong b\u1EA3ng n\u00E0y; h\u00E3y m\u1EDF tr\u00ECnh s\u1EEDa giao d\u1ECBch. + MarkSecurityPageDescription = C\u00E1c m\u1EE5c \u0111\u00E3 ch\u1ECDn s\u1EBD \u0111\u01B0\u1EE3c \u0111\u00E1nh d\u1EA5u l\u00E0 ch\u1EC9 s\u1ED1 c\u1ED5 phi\u1EBFu. C\u00E1c ch\u1EC9 s\u1ED1 kh\u00F4ng c\u00F3 ti\u1EC1n t\u1EC7. MarkSecurityPageTitle = \u0110\u00E1nh d\u1EA5u ch\u1EE9ng kho\u00E1n l\u00E0 ch\u1EC9 s\u1ED1 c\u1ED5 phi\u1EBFu diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_zh.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_zh.properties index 1da1ba8c79..5a65981948 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_zh.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_zh.properties @@ -1961,6 +1961,108 @@ LabelYellowWhiteBlack = \u9ED1\u8272 - \u9EC4\u8272 - \u767D\u8272 LabelYes = \u662F +LedgerAccountTransactionsPaneUnsupportedSharesInlineEdit = \u6B64\u4EA4\u6613\u7531 Ledger \u7BA1\u7406\u3002\u4E0D\u80FD\u5728\u6B64\u8868\u683C\u4E2D\u76F4\u63A5\u7F16\u8F91\u4EFD\u989D\uFF1B\u8BF7\u6253\u5F00\u4EA4\u6613\u7F16\u8F91\u5668\u3002 + +LedgerConvertPortfolioCompositeTypeActionUnsupportedLedgerBackedTransition = \u6B64\u8BC1\u5238\u8D26\u6237\u4EA4\u6613\u7531 Ledger \u7BA1\u7406\u3002\u4E0D\u80FD\u901A\u8FC7\u6B64\u64CD\u4F5C\u66F4\u6539\u5176\u7C7B\u578B\u3002 + +LedgerConvertTransferToDepositRemovalActionCannotConvertLedgerBackedTransfer = \u6B64\u8D26\u6237\u8F6C\u8D26\u7531 Ledger \u7BA1\u7406\u3002\u4E0D\u80FD\u5728\u6B64\u5904\u8F6C\u6362\u4E3A\u5355\u72EC\u7684\u5B58\u5165\u548C\u53D6\u51FA\u4EA4\u6613\u3002 + +LedgerConvertTransferToDepositRemovalActionUnsupportedTransferEntry = \u4EA4\u6613 {0} \u4E0D\u662F\u53D7\u652F\u6301\u7684\u8D26\u6237\u8F6C\u8D26\uFF0C\u4E0D\u80FD\u8F6C\u6362\u3002 + +LedgerExDateEditingSupportDividendOwnerNotFound = Ledger \u80A1\u606F\u9700\u8981\u8D26\u6237\u6295\u5F71\u6240\u6709\u8005\uFF0C\u4F46\u672A\u627E\u5230\u3002 + +LedgerExDateEditingSupportNoSafeEditorLedgerBacked = \u65E0\u6CD5\u5728\u6B64\u5B89\u5168\u7F16\u8F91\u6B64 Ledger \u7BA1\u7406\u8D26\u6237\u4EA4\u6613\u7684\u9664\u606F\u65E5\u3002\u8BF7\u6253\u5F00\u5B8C\u6574\u7684\u4EA4\u6613\u7F16\u8F91\u5668\u3002 + +LedgerExDateEditingSupportNoSafeEditorPolicyBlocked = \u4E3A\u4FDD\u6301 Ledger \u6761\u76EE\u4E00\u81F4\uFF0C\u6B64 Ledger \u7BA1\u7406\u8D26\u6237\u4EA4\u6613\u7C7B\u578B\u5DF2\u7981\u7528\u9664\u606F\u65E5\u7F16\u8F91\u3002 + +LedgerNativeComponentInspectorCardinality = \u57FA\u6570 + +LedgerNativeComponentInspectorCode = \u4EE3\u7801 + +LedgerNativeComponentInspectorDateTime = \u65E5\u671F/\u65F6\u95F4 + +LedgerNativeComponentInspectorDialogTitle = \u68C0\u67E5 Ledger \u6761\u76EE\u8BE6\u7EC6\u4FE1\u606F + +LedgerNativeComponentInspectorDomain = \u57DF + +LedgerNativeComponentInspectorEntryParameters = \u6761\u76EE\u53C2\u6570 + +LedgerNativeComponentInspectorEntryType = \u6761\u76EE\u7C7B\u578B + +LedgerNativeComponentInspectorEntryUUID = \u6761\u76EE UUID + +LedgerNativeComponentInspectorField = \u5B57\u6BB5 + +LedgerNativeComponentInspectorFunctionalLegs = \u529F\u80FD\u7EC4\u6210\u90E8\u5206 + +LedgerNativeComponentInspectorGroupExpected = \u9884\u671F\u7EC4 + +LedgerNativeComponentInspectorHeader = Ledger \u6761\u76EE + +LedgerNativeComponentInspectorLegRole = \u7EC4\u6210\u90E8\u5206\u89D2\u8272 + +LedgerNativeComponentInspectorMenu = \u68C0\u67E5 Ledger \u6761\u76EE... + +LedgerNativeComponentInspectorNativeTargeted = \u539F\u751F\u5B9A\u5411 + +LedgerNativeComponentInspectorNoNativeDefinition = \u672A\u4E3A\u6B64\u6761\u76EE\u7C7B\u578B\u914D\u7F6E\u539F\u751F Ledger \u5B9A\u4E49\u3002 + +LedgerNativeComponentInspectorOwner = \u6240\u6709\u8005 + +LedgerNativeComponentInspectorParameter = \u53C2\u6570 + +LedgerNativeComponentInspectorPostingGroupUUID = \u8FC7\u8D26\u7EC4 UUID + +LedgerNativeComponentInspectorPostingParameters = \u8FC7\u8D26\u53C2\u6570 + +LedgerNativeComponentInspectorPostingType = \u8FC7\u8D26\u7C7B\u578B + +LedgerNativeComponentInspectorPostingUUID = \u8FC7\u8D26 UUID + +LedgerNativeComponentInspectorPostings = \u8FC7\u8D26 + +LedgerNativeComponentInspectorPrimaryExpected = \u9884\u671F\u4E3B\u5F15\u7528 + +LedgerNativeComponentInspectorPrimaryPostingUUID = \u4E3B\u8FC7\u8D26 UUID + +LedgerNativeComponentInspectorProjectionRefs = \u6295\u5F71\u5F15\u7528 + +LedgerNativeComponentInspectorProjectionRole = \u6295\u5F71\u89D2\u8272 + +LedgerNativeComponentInspectorProjectionUUID = \u6295\u5F71 UUID + +LedgerNativeComponentInspectorSelectedPostingGroupUUID = \u6240\u9009\u8FC7\u8D26\u7EC4 UUID + +LedgerNativeComponentInspectorSelectedPrimaryPostingUUID = \u6240\u9009\u4E3B\u8FC7\u8D26 UUID + +LedgerNativeComponentInspectorSelectedProjectionRole = \u6240\u9009\u6295\u5F71\u89D2\u8272 + +LedgerNativeComponentInspectorSelectedProjectionUUID = \u6240\u9009\u6295\u5F71 UUID + +LedgerNativeComponentInspectorShape = \u7ED3\u6784 + +LedgerNativeComponentInspectorValue = \u503C + +LedgerNativeComponentInspectorValueKind = \u503C\u7C7B\u578B + +LedgerPropertyEditingSupportUnsupportedInlineEdit = \u6B64\u503C\u7531 Ledger \u7BA1\u7406\uFF0C\u4E0D\u80FD\u76F4\u63A5\u4E3A {0} \u7F16\u8F91\u3002\u8BF7\u4F7F\u7528\u53D7\u652F\u6301\u7684 Ledger \u7F16\u8F91\u5668\u3002 + +LedgerTransactionOwnerListEditingSupportAccountTransferOwnerNotFound = \u672A\u627E\u5230\u8D26\u6237\u8F6C\u8D26\u6240\u6709\u8005\uFF1A{0} + +LedgerTransactionOwnerListEditingSupportDistinctOwnersRequired = \u8F6C\u8D26\u5FC5\u987B\u4FDD\u7559\u4E24\u4E2A\u4E0D\u540C\u7684\u6240\u6709\u8005\u3002\u8BF7\u9009\u62E9\u4E0D\u540C\u7684\u6765\u6E90\u6216\u76EE\u6807\u3002 + +LedgerTransactionOwnerListEditingSupportPortfolioTransferOwnerNotFound = \u672A\u627E\u5230\u8BC1\u5238\u8D26\u6237\u8F6C\u8D26\u6240\u6709\u8005\uFF1A{0} + +LedgerTransactionOwnerListEditingSupportUnsupportedOwnerEdit = \u65E0\u6CD5\u5728\u6B64\u66F4\u6539\u6B64 Ledger \u7BA1\u7406\u4EA4\u6613\u7684\u6240\u6709\u8005\uFF1A{0} + +LedgerTransactionOwnerListEditingSupportUnsupportedOwnerType = \u6B64 Ledger \u4EA4\u6613\u4E0D\u652F\u6301\u6240\u6709\u8005\u7C7B\u578B {0}\u3002 + +LedgerTransactionTypeEditingSupportPolicyBlockedTransition = \u6B64\u4EA4\u6613\u7684 Ledger \u89C4\u5219\u4E0D\u5141\u8BB8\u5728\u6B64\u66F4\u6539\u4EA4\u6613\u7C7B\u578B\u3002 + +LedgerTransactionTypeEditingSupportUnsupportedTransition = \u4E0D\u80FD\u901A\u8FC7\u6B64\u64CD\u4F5C\u66F4\u6539\u6B64 Ledger \u7BA1\u7406\u7684\u4EA4\u6613\u7C7B\u578B\u3002 + +LedgerTransactionsViewerUnsupportedSharesInlineEdit = \u6B64\u4EA4\u6613\u7531 Ledger \u7BA1\u7406\u3002\u4E0D\u80FD\u5728\u6B64\u8868\u683C\u4E2D\u76F4\u63A5\u7F16\u8F91\u4EFD\u989D\uFF1B\u8BF7\u6253\u5F00\u4EA4\u6613\u7F16\u8F91\u5668\u3002 + MarkSecurityPageDescription = \u5DF2\u9009\u4E2D\u9879\u76EE\u5C06\u6807\u8BB0\u4E3A\u80A1\u4EF7\u6307\u6570\u3002\u6307\u6570\u4E0D\u542B\u8D27\u5E01\u3002 MarkSecurityPageTitle = \u5C06\u8BC1\u5238\u6807\u8BB0\u4E3A\u80A1\u4EF7\u6307\u6570 diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_zh_TW.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_zh_TW.properties index 3765a74164..186b9a7787 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_zh_TW.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_zh_TW.properties @@ -1961,6 +1961,108 @@ LabelYellowWhiteBlack = \u9ED1\u8272 - \u9EC3\u8272 - \u767D\u8272 LabelYes = \u662F +LedgerAccountTransactionsPaneUnsupportedSharesInlineEdit = \u6B64\u4EA4\u6613\u7531 Ledger \u7BA1\u7406\u3002\u7121\u6CD5\u5728\u6B64\u8868\u683C\u4E2D\u76F4\u63A5\u7DE8\u8F2F\u4EFD\u989D\uFF1B\u8ACB\u958B\u555F\u4EA4\u6613\u7DE8\u8F2F\u5668\u3002 + +LedgerConvertPortfolioCompositeTypeActionUnsupportedLedgerBackedTransition = \u6B64\u8B49\u5238\u5E33\u6236\u4EA4\u6613\u7531 Ledger \u7BA1\u7406\u3002\u7121\u6CD5\u901A\u8FC7\u6B64\u64CD\u4F5C\u8B8A\u66F4\u5176\u985E\u578B\u3002 + +LedgerConvertTransferToDepositRemovalActionCannotConvertLedgerBackedTransfer = \u6B64\u5E33\u6236\u8F49\u5E33\u7531 Ledger \u7BA1\u7406\u3002\u7121\u6CD5\u5728\u6B64\u5904\u8F49\u63DB\u4E3A\u5355\u72EC\u7684\u5B58\u5165\u548C\u63D0\u9818\u4EA4\u6613\u3002 + +LedgerConvertTransferToDepositRemovalActionUnsupportedTransferEntry = \u4EA4\u6613 {0} \u4E0D\u662F\u53D7\u652F\u63F4\u7684\u5E33\u6236\u8F49\u5E33\uFF0C\u7121\u6CD5\u8F49\u63DB\u3002 + +LedgerExDateEditingSupportDividendOwnerNotFound = Ledger \u80A1\u5229\u9700\u8981\u5E33\u6236\u6295\u5F71\u64C1\u6709\u8005\uFF0C\u4F46\u627E\u4E0D\u5230\u3002 + +LedgerExDateEditingSupportNoSafeEditorLedgerBacked = \u65E0\u6CD5\u5728\u6B64\u5B89\u5168\u7DE8\u8F2F\u6B64 Ledger \u7BA1\u7406\u5E33\u6236\u4EA4\u6613\u7684\u9664\u606F\u65E5\u3002\u8ACB\u958B\u555F\u5B8C\u6574\u7684\u4EA4\u6613\u7DE8\u8F2F\u5668\u3002 + +LedgerExDateEditingSupportNoSafeEditorPolicyBlocked = \u4E3A\u4FDD\u6301 Ledger \u689D\u76EE\u4E00\u81F4\uFF0C\u6B64 Ledger \u7BA1\u7406\u5E33\u6236\u4EA4\u6613\u985E\u578B\u5DF2\u7981\u7528\u9664\u606F\u65E5\u7DE8\u8F2F\u3002 + +LedgerNativeComponentInspectorCardinality = \u57FA\u6570 + +LedgerNativeComponentInspectorCode = \u4EE3\u78BC + +LedgerNativeComponentInspectorDateTime = \u65E5\u671F/\u65F6\u95F4 + +LedgerNativeComponentInspectorDialogTitle = \u68C0\u67E5 Ledger \u689D\u76EE\u8BE6\u7EC6\u4FE1\u606F + +LedgerNativeComponentInspectorDomain = \u57DF + +LedgerNativeComponentInspectorEntryParameters = \u689D\u76EE\u53C3\u6578 + +LedgerNativeComponentInspectorEntryType = \u689D\u76EE\u985E\u578B + +LedgerNativeComponentInspectorEntryUUID = \u689D\u76EE UUID + +LedgerNativeComponentInspectorField = \u6B04\u4F4D + +LedgerNativeComponentInspectorFunctionalLegs = \u529F\u80FD\u7FA4\u7D44\u6210\u90E8\u5206 + +LedgerNativeComponentInspectorGroupExpected = \u9884\u671F\u7FA4\u7D44 + +LedgerNativeComponentInspectorHeader = Ledger \u689D\u76EE + +LedgerNativeComponentInspectorLegRole = \u7FA4\u7D44\u6210\u90E8\u5206\u89D2\u8272 + +LedgerNativeComponentInspectorMenu = \u68C0\u67E5 Ledger \u689D\u76EE... + +LedgerNativeComponentInspectorNativeTargeted = \u539F\u751F\u5B9A\u5411 + +LedgerNativeComponentInspectorNoNativeDefinition = \u672A\u4E3A\u6B64\u689D\u76EE\u985E\u578B\u914D\u7F6E\u539F\u751F Ledger \u5B9A\u4E49\u3002 + +LedgerNativeComponentInspectorOwner = \u64C1\u6709\u8005 + +LedgerNativeComponentInspectorParameter = \u53C3\u6578 + +LedgerNativeComponentInspectorPostingGroupUUID = \u904E\u5E33\u7FA4\u7D44 UUID + +LedgerNativeComponentInspectorPostingParameters = \u904E\u5E33\u53C3\u6578 + +LedgerNativeComponentInspectorPostingType = \u904E\u5E33\u985E\u578B + +LedgerNativeComponentInspectorPostingUUID = \u904E\u5E33 UUID + +LedgerNativeComponentInspectorPostings = \u904E\u5E33 + +LedgerNativeComponentInspectorPrimaryExpected = \u9884\u671F\u4E3B\u53C3\u7167 + +LedgerNativeComponentInspectorPrimaryPostingUUID = \u4E3B\u904E\u5E33 UUID + +LedgerNativeComponentInspectorProjectionRefs = \u6295\u5F71\u53C3\u7167 + +LedgerNativeComponentInspectorProjectionRole = \u6295\u5F71\u89D2\u8272 + +LedgerNativeComponentInspectorProjectionUUID = \u6295\u5F71 UUID + +LedgerNativeComponentInspectorSelectedPostingGroupUUID = \u6240\u9009\u904E\u5E33\u7FA4\u7D44 UUID + +LedgerNativeComponentInspectorSelectedPrimaryPostingUUID = \u6240\u9009\u4E3B\u904E\u5E33 UUID + +LedgerNativeComponentInspectorSelectedProjectionRole = \u6240\u9009\u6295\u5F71\u89D2\u8272 + +LedgerNativeComponentInspectorSelectedProjectionUUID = \u6240\u9009\u6295\u5F71 UUID + +LedgerNativeComponentInspectorShape = \u7D50\u69CB + +LedgerNativeComponentInspectorValue = \u503C + +LedgerNativeComponentInspectorValueKind = \u503C\u985E\u578B + +LedgerPropertyEditingSupportUnsupportedInlineEdit = \u6B64\u503C\u7531 Ledger \u7BA1\u7406\uFF0C\u7121\u6CD5\u76F4\u63A5\u4E3A {0} \u7DE8\u8F2F\u3002\u8ACB\u4F7F\u7528\u53D7\u652F\u63F4\u7684 Ledger \u7DE8\u8F2F\u5668\u3002 + +LedgerTransactionOwnerListEditingSupportAccountTransferOwnerNotFound = \u627E\u4E0D\u5230\u5E33\u6236\u8F49\u5E33\u64C1\u6709\u8005\uFF1A{0} + +LedgerTransactionOwnerListEditingSupportDistinctOwnersRequired = \u8F49\u5E33\u5FC5\u987B\u4FDD\u7559\u4E24\u4E2A\u4E0D\u540C\u7684\u64C1\u6709\u8005\u3002\u8ACB\u9009\u62E9\u4E0D\u540C\u7684\u4F86\u6E90\u6216\u76EE\u6A19\u3002 + +LedgerTransactionOwnerListEditingSupportPortfolioTransferOwnerNotFound = \u627E\u4E0D\u5230\u8B49\u5238\u5E33\u6236\u8F49\u5E33\u64C1\u6709\u8005\uFF1A{0} + +LedgerTransactionOwnerListEditingSupportUnsupportedOwnerEdit = \u65E0\u6CD5\u5728\u6B64\u8B8A\u66F4\u6B64 Ledger \u7BA1\u7406\u4EA4\u6613\u7684\u64C1\u6709\u8005\uFF1A{0} + +LedgerTransactionOwnerListEditingSupportUnsupportedOwnerType = \u6B64 Ledger \u4EA4\u6613\u4E0D\u652F\u63F4\u64C1\u6709\u8005\u985E\u578B {0}\u3002 + +LedgerTransactionTypeEditingSupportPolicyBlockedTransition = \u6B64\u4EA4\u6613\u7684 Ledger \u898F\u5247\u4E0D\u5141\u8BB8\u5728\u6B64\u8B8A\u66F4\u4EA4\u6613\u985E\u578B\u3002 + +LedgerTransactionTypeEditingSupportUnsupportedTransition = \u7121\u6CD5\u901A\u8FC7\u6B64\u64CD\u4F5C\u8B8A\u66F4\u6B64 Ledger \u7BA1\u7406\u7684\u4EA4\u6613\u985E\u578B\u3002 + +LedgerTransactionsViewerUnsupportedSharesInlineEdit = \u6B64\u4EA4\u6613\u7531 Ledger \u7BA1\u7406\u3002\u7121\u6CD5\u5728\u6B64\u8868\u683C\u4E2D\u76F4\u63A5\u7DE8\u8F2F\u4EFD\u989D\uFF1B\u8ACB\u958B\u555F\u4EA4\u6613\u7DE8\u8F2F\u5668\u3002 + MarkSecurityPageDescription = \u9078\u5B9A\u9805\u76EE\u5C07\u88AB\u6A19\u8A18\u70BA\u80A1\u7968\u6307\u6578\u3002\u6307\u6578\u5C07\u6C92\u6709\u5E63\u503C\u3002 MarkSecurityPageTitle = \u5C07\u8B49\u5238\u6A19\u8A18\u70BA\u80A1\u7968\u6307\u6578 diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/Messages.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/Messages.java index f3587dfbd3..0a3c2d1edb 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/Messages.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/Messages.java @@ -242,6 +242,77 @@ 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 LedgerInsertActionGeneratedBuyMissingBuySellEntry; + public static String LedgerInsertActionGeneratedBuyTypeMismatch; + public static String LedgerInsertActionGeneratedDeliveryOwnerNotFound; + public static String LedgerInsertActionGeneratedDeliveryTypeMismatch; + public static String LedgerInsertActionInvestmentPlanLegacySettersNotSupported; + public static String LedgerInsertActionUnsupportedInvestmentPlanTransactionUpdateType; + public static String LedgerParameterMissingAttribute; + public static String LedgerParameterMissingType; + public static String LedgerParameterMissingValueKind; + public static String LedgerParameterReferenceValueMissingValueNode; + public static String LedgerParameterUnsupportedValueKind; + public static String LedgerParameterValueKindRequiresStructuredValue; + public static String LedgerProtobufBooleanParameterMissingBooleanValue; + public static String LedgerProtobufInvalidLedgerPersistenceState; + public static String LedgerProtobufLocalDateParameterMissingLocalDateValue; + public static String LedgerRuntimeProjectionRestorerInvalidLedger; + public static String LedgerRuntimeProjectionRestorerPartiallyRestored; + public static String LedgerRuntimeProjectionRestorerRestored; + public static String LedgerStructuralValidatorAccountProjectionPortfolioNotAllowed; + public static String LedgerStructuralValidatorDividendSecurityRequired; + public static String LedgerStructuralValidatorDuplicateEntryUuid; + public static String LedgerStructuralValidatorDuplicatePostingUuid; + public static String LedgerStructuralValidatorDuplicateProjectionUuid; + public static String LedgerStructuralValidatorEntryDateTimeRequired; + public static String LedgerStructuralValidatorEntryTypeRequired; + public static String LedgerStructuralValidatorEntryUuidRequired; + 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 LedgerStructuralValidatorPortfolioProjectionAccountNotAllowed; + public static String LedgerStructuralValidatorPostingCurrencyRequired; + public static String LedgerStructuralValidatorPostingExchangeRatePositive; + public static String LedgerStructuralValidatorPostingGroupRefNotFound; + public static String LedgerStructuralValidatorPostingSecurityRequired; + public static String LedgerStructuralValidatorPostingTypeRequired; + public static String LedgerStructuralValidatorPostingUuidRequired; + public static String LedgerStructuralValidatorPrimaryPostingRefNotFound; + public static String LedgerStructuralValidatorProjectionAccountRequired; + public static String LedgerStructuralValidatorProjectionGroupTargetConflict; + public static String LedgerStructuralValidatorProjectionMembershipRefNotFound; + public static String LedgerStructuralValidatorProjectionPortfolioRequired; + public static String LedgerStructuralValidatorProjectionPrimaryTargetConflict; + public static String LedgerStructuralValidatorProjectionRoleNotAllowed; + public static String LedgerStructuralValidatorProjectionRoleRequired; + public static String LedgerStructuralValidatorProjectionRoleRequiredForType; + public static String LedgerStructuralValidatorProjectionUuidRequired; + public static String LedgerStructuralValidatorSignedFactsNotAllowed; + public static String LedgerStructuralValidatorTargetingRefRequired; + public static String LedgerXmlInvalidLedgerStructure; 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..b548b45d6b 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages.properties @@ -472,6 +472,148 @@ 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 + +LedgerInsertActionGeneratedBuyMissingBuySellEntry = The generated BUY transaction is managed by the Ledger, but its linked buy/sell entry could not be found. + +LedgerInsertActionGeneratedBuyTypeMismatch = This generated Ledger BUY transaction can only be updated from an imported BUY transaction. + +LedgerInsertActionGeneratedDeliveryOwnerNotFound = The owner of the generated Ledger delivery transaction could not be found. + +LedgerInsertActionGeneratedDeliveryTypeMismatch = This generated Ledger delivery transaction can only be updated from an imported BUY transaction. + +LedgerInsertActionInvestmentPlanLegacySettersNotSupported = Ledger-managed investment-plan transactions cannot be updated with legacy import setters. Use the Ledger update path. + +LedgerInsertActionUnsupportedInvestmentPlanTransactionUpdateType = Investment-plan update type {0} is not supported for Ledger-managed transactions. + +LedgerParameterMissingAttribute = Ledger parameter {0} is missing a required attribute. + +LedgerParameterMissingType = A Ledger parameter must have a type. + +LedgerParameterMissingValueKind = A Ledger parameter must declare a value kind. + +LedgerParameterReferenceValueMissingValueNode = A Ledger parameter reference value must contain a value node. + +LedgerParameterUnsupportedValueKind = Ledger parameter value kind {0} is not supported. + +LedgerParameterValueKindRequiresStructuredValue = Ledger parameter value kind {0} requires a structured value. + +LedgerProtobufBooleanParameterMissingBooleanValue = A BOOLEAN Ledger parameter must contain a booleanValue. + +LedgerProtobufInvalidLedgerPersistenceState = The persisted Ledger protobuf data is inconsistent:\n{0} + +LedgerProtobufLocalDateParameterMissingLocalDateValue = A LOCAL_DATE Ledger parameter must contain a localDateValue. + +LedgerRuntimeProjectionRestorerInvalidLedger = Runtime projection restore was skipped because the persisted Ledger truth is invalid. The Ledger data was not changed. Diagnostics:\n{0} + +LedgerRuntimeProjectionRestorerPartiallyRestored = Runtime projections were restored only for valid Ledger entries. Removed {0} old runtime projections and recreated {1} valid projections. Skipped invalid Ledger entries: {2}. Affected owners: {3}. Missing projection UUIDs: {4}. Duplicate projection UUIDs: {5}. Stale projection UUIDs: {6}. Persisted Ledger data was not changed. Diagnostics:\n{7} + +LedgerRuntimeProjectionRestorerRestored = Runtime projections were restored from valid Ledger data. Removed {0} old runtime projections and recreated {1} projections. Affected owners: {2}. Missing projection UUIDs: {3}. Duplicate projection UUIDs: {4}. Stale projection UUIDs: {5}. Persisted Ledger data was not changed. + +LedgerStructuralValidatorAccountProjectionPortfolioNotAllowed = An account projection must reference an account only; remove the portfolio reference: {0} + +LedgerStructuralValidatorDividendSecurityRequired = Dividend entries must reference a security posting: {0} + +LedgerStructuralValidatorDuplicateEntryUuid = Every Ledger entry must have a unique UUID. Duplicate: {0} + +LedgerStructuralValidatorDuplicatePostingUuid = Every Ledger posting must have a unique UUID. Duplicate: {0} + +LedgerStructuralValidatorDuplicateProjectionUuid = Every Ledger projection must have a unique UUID. Duplicate: {0} + +LedgerStructuralValidatorEntryDateTimeRequired = Ledger entry {0} must have a date and time. + +LedgerStructuralValidatorEntryTypeRequired = Ledger entry {0} must have an entry type. + +LedgerStructuralValidatorEntryUuidRequired = Every Ledger entry must have a UUID. + +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. + +LedgerStructuralValidatorPortfolioProjectionAccountNotAllowed = A portfolio projection must reference a portfolio only; remove the account reference: {0} + +LedgerStructuralValidatorPostingCurrencyRequired = Ledger posting {0} must have a currency. + +LedgerStructuralValidatorPostingExchangeRatePositive = Ledger posting {0} must have an exchange rate greater than zero. + +LedgerStructuralValidatorPostingGroupRefNotFound = Posting group reference {0} must point to a posting in the same entry. + +LedgerStructuralValidatorPostingSecurityRequired = Security posting {0} must reference a security. + +LedgerStructuralValidatorPostingTypeRequired = Ledger posting {0} must have a posting type. + +LedgerStructuralValidatorPostingUuidRequired = Ledger entry {0} contains a posting without a UUID. + +LedgerStructuralValidatorPrimaryPostingRefNotFound = Primary posting reference {0} must point to a posting in the same entry. + +LedgerStructuralValidatorProjectionAccountRequired = Ledger projection {0} must reference an account. + +LedgerStructuralValidatorProjectionGroupTargetConflict = A projection must use either membership references or a single posting-group reference, not both. + +LedgerStructuralValidatorProjectionMembershipRefNotFound = Projection membership reference {0} must point to a posting in the same entry. + +LedgerStructuralValidatorProjectionPortfolioRequired = Ledger projection {0} must reference a portfolio. + +LedgerStructuralValidatorProjectionPrimaryTargetConflict = A projection must use either membership references or a single primary posting reference, not both. + +LedgerStructuralValidatorProjectionRoleNotAllowed = Projection role {0} is not valid for entry type {1}. + +LedgerStructuralValidatorProjectionRoleRequired = Ledger projection {0} must have a projection role. + +LedgerStructuralValidatorProjectionRoleRequiredForType = Entry type {0} must have exactly one projection with role {1}. + +LedgerStructuralValidatorProjectionUuidRequired = Ledger entry {0} contains a projection without a UUID. + +LedgerStructuralValidatorSignedFactsNotAllowed = Entry type {0} only accepts positive posting values. Use positive amounts and shares. + +LedgerStructuralValidatorTargetingRefRequired = Targeted Ledger projections must point to a primary posting. + +LedgerXmlInvalidLedgerStructure = The Ledger structure is invalid:\n{0} + 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/messages_ca.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_ca.properties index a49093f2d9..8605da2ecc 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_ca.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_ca.properties @@ -471,6 +471,148 @@ LabelYahooFinance = Yahoo Finance LabelYahooFinanceAdjustedClose = Yahoo Finance (ajustat al tancament) +LedgerDiagnosticMessageFormatterAccount = Compte + +LedgerDiagnosticMessageFormatterAmount = Quantitat + +LedgerDiagnosticMessageFormatterContextUnavailable = no disponible + +LedgerDiagnosticMessageFormatterDate = Data + +LedgerDiagnosticMessageFormatterIsin = ISIN + +LedgerDiagnosticMessageFormatterNote = Nota + +LedgerDiagnosticMessageFormatterPortfolio = Compte de valors + +LedgerDiagnosticMessageFormatterSecurity = Actiu + +LedgerDiagnosticMessageFormatterShares = T\u00EDtols + +LedgerDiagnosticMessageFormatterSource = Font + +LedgerDiagnosticMessageFormatterTicker = Ticker + +LedgerDiagnosticMessageFormatterTransactionContext = Detalls de l\u2019operaci\u00F3 + +LedgerDiagnosticMessageFormatterType = Tipus + +LedgerDiagnosticMessageFormatterUnnamedAccount = compte sense nom + +LedgerDiagnosticMessageFormatterUnnamedPortfolio = compte de valors sense nom + +LedgerDiagnosticMessageFormatterUnnamedSecurity = actiu sense nom + +LedgerDiagnosticMessageFormatterWkn = WKN + +LedgerInsertActionGeneratedBuyMissingBuySellEntry = L\u2019operaci\u00F3 de compra generada la gestiona el Ledger, per\u00F2 no s\u2019ha trobat l\u2019entrada de compra/venda vinculada. + +LedgerInsertActionGeneratedBuyTypeMismatch = Aquesta compra Ledger generada nom\u00E9s es pot actualitzar des d\u2019una operaci\u00F3 de compra importada. + +LedgerInsertActionGeneratedDeliveryOwnerNotFound = No s\u2019ha trobat el propietari del lliurament Ledger generat. + +LedgerInsertActionGeneratedDeliveryTypeMismatch = Aquest lliurament Ledger generat nom\u00E9s es pot actualitzar des d\u2019una operaci\u00F3 de compra importada. + +LedgerInsertActionInvestmentPlanLegacySettersNotSupported = Les operacions de plans d\u2019inversi\u00F3 gestionades pel Ledger no es poden actualitzar amb setters d\u2019importaci\u00F3 antics. Useu el cam\u00ED d\u2019actualitzaci\u00F3 Ledger. + +LedgerInsertActionUnsupportedInvestmentPlanTransactionUpdateType = El tipus d\u2019actualitzaci\u00F3 de pla d\u2019inversi\u00F3 {0} no \u00E9s adm\u00E8s per a operacions gestionades pel Ledger. + +LedgerParameterMissingAttribute = Al par\u00E0metre Ledger {0} li falta un atribut obligatori. + +LedgerParameterMissingType = Un par\u00E0metre Ledger ha de tenir un tipus. + +LedgerParameterMissingValueKind = Un par\u00E0metre Ledger ha de declarar un tipus de valor. + +LedgerParameterReferenceValueMissingValueNode = Un valor de refer\u00E8ncia d\u2019un par\u00E0metre Ledger ha de contenir un node de valor. + +LedgerParameterUnsupportedValueKind = El tipus de valor de par\u00E0metre Ledger {0} no \u00E9s adm\u00E8s. + +LedgerParameterValueKindRequiresStructuredValue = El tipus de valor de par\u00E0metre Ledger {0} requereix un valor estructurat. + +LedgerProtobufBooleanParameterMissingBooleanValue = Un par\u00E0metre Ledger BOOLEAN ha de contenir booleanValue. + +LedgerProtobufInvalidLedgerPersistenceState = Les dades Protobuf Ledger desades s\u00F3n incoherents:\n{0} + +LedgerProtobufLocalDateParameterMissingLocalDateValue = Un par\u00E0metre Ledger LOCAL_DATE ha de contenir localDateValue. + +LedgerRuntimeProjectionRestorerInvalidLedger = S\u2019ha om\u00E8s la restauraci\u00F3 de projeccions en temps d\u2019execuci\u00F3 perqu\u00E8 les dades Ledger desades no s\u00F3n v\u00E0lides. Les dades Ledger no s\u2019han modificat. Diagn\u00F2stic:\n{0} + +LedgerRuntimeProjectionRestorerPartiallyRestored = Les projeccions en temps d\u2019execuci\u00F3 s\u2019han restaurat nom\u00E9s per a entrades Ledger v\u00E0lides. S\u2019han eliminat {0} projeccions antigues i s\u2019han recreat {1} projeccions v\u00E0lides. Entrades Ledger no v\u00E0lides omeses: {2}. Propietaris afectats: {3}. UUIDs de projecci\u00F3 que falten: {4}. UUIDs de projecci\u00F3 duplicats: {5}. UUIDs de projecci\u00F3 obsolets: {6}. Les dades Ledger desades no s\u2019han modificat. Diagn\u00F2stic:\n{7} + +LedgerRuntimeProjectionRestorerRestored = Les projeccions en temps d\u2019execuci\u00F3 s\u2019han restaurat a partir de dades Ledger v\u00E0lides. S\u2019han eliminat {0} projeccions antigues i s\u2019han recreat {1} projeccions. Propietaris afectats: {2}. UUIDs de projecci\u00F3 que falten: {3}. UUIDs de projecci\u00F3 duplicats: {4}. UUIDs de projecci\u00F3 obsolets: {5}. Les dades Ledger desades no s\u2019han modificat. + +LedgerStructuralValidatorAccountProjectionPortfolioNotAllowed = Una projecci\u00F3 de compte nom\u00E9s ha de referenciar un compte; elimineu la refer\u00E8ncia al compte de valors: {0} + +LedgerStructuralValidatorDividendSecurityRequired = Les entrades de dividend han de referenciar un assentament d\u2019actiu: {0} + +LedgerStructuralValidatorDuplicateEntryUuid = Cada entrada Ledger ha de tenir un UUID \u00FAnic. Duplicat: {0} + +LedgerStructuralValidatorDuplicatePostingUuid = Cada assentament Ledger ha de tenir un UUID \u00FAnic. Duplicat: {0} + +LedgerStructuralValidatorDuplicateProjectionUuid = Cada projecci\u00F3 Ledger ha de tenir un UUID \u00FAnic. Duplicat: {0} + +LedgerStructuralValidatorEntryDateTimeRequired = L\u2019entrada Ledger {0} ha de tenir data i hora. + +LedgerStructuralValidatorEntryTypeRequired = L\u2019entrada Ledger {0} ha de tenir un tipus d\u2019entrada. + +LedgerStructuralValidatorEntryUuidRequired = Cada entrada Ledger ha de tenir un UUID. + +LedgerStructuralValidatorExDateSecurityRequired = EX_DATE nom\u00E9s es pot usar en assentaments que referencien un actiu: {0} + +LedgerStructuralValidatorLedgerRequired = Cal un objecte Ledger. + +LedgerStructuralValidatorParameterCodeNotAllowed = El par\u00E0metre Ledger per a {0} ha d\u2019usar un dels codis configurats. + +LedgerStructuralValidatorParameterMustUseValueKind = {0} ha d\u2019usar el tipus de valor {1}. + +LedgerStructuralValidatorParameterTypeRequired = El par\u00E0metre Ledger {0} ha de tenir un tipus de par\u00E0metre. + +LedgerStructuralValidatorParameterValueKindMismatch = El valor del par\u00E0metre Ledger ha de coincidir amb el tipus de valor {0}. + +LedgerStructuralValidatorParameterValueKindRequired = El par\u00E0metre Ledger {0} ha de declarar un tipus de valor. + +LedgerStructuralValidatorParameterValueRequired = El par\u00E0metre Ledger {0} ha de contenir un valor. + +LedgerStructuralValidatorPortfolioProjectionAccountNotAllowed = Una projecci\u00F3 de compte de valors nom\u00E9s ha de referenciar un compte de valors; elimineu la refer\u00E8ncia al compte: {0} + +LedgerStructuralValidatorPostingCurrencyRequired = L\u2019assentament Ledger {0} ha de tenir una moneda. + +LedgerStructuralValidatorPostingExchangeRatePositive = L\u2019assentament Ledger {0} ha de tenir un tipus de canvi m\u00E9s gran que zero. + +LedgerStructuralValidatorPostingGroupRefNotFound = La refer\u00E8ncia de grup d\u2019assentaments {0} ha d\u2019apuntar a un assentament de la mateixa entrada. + +LedgerStructuralValidatorPostingSecurityRequired = L\u2019assentament d\u2019actiu {0} ha de referenciar un actiu. + +LedgerStructuralValidatorPostingTypeRequired = L\u2019assentament Ledger {0} ha de tenir un tipus d\u2019assentament. + +LedgerStructuralValidatorPostingUuidRequired = L\u2019entrada Ledger {0} cont\u00E9 un assentament sense UUID. + +LedgerStructuralValidatorPrimaryPostingRefNotFound = La refer\u00E8ncia d\u2019assentament principal {0} ha d\u2019apuntar a un assentament de la mateixa entrada. + +LedgerStructuralValidatorProjectionAccountRequired = La projecci\u00F3 Ledger {0} ha de referenciar un compte. + +LedgerStructuralValidatorProjectionGroupTargetConflict = Una projecci\u00F3 ha d\u2019usar refer\u00E8ncies de pertinen\u00E7a o una sola refer\u00E8ncia de grup d\u2019assentaments, per\u00F2 no totes dues. + +LedgerStructuralValidatorProjectionMembershipRefNotFound = La refer\u00E8ncia de pertinen\u00E7a de projecci\u00F3 {0} ha d\u2019apuntar a un assentament de la mateixa entrada. + +LedgerStructuralValidatorProjectionPortfolioRequired = La projecci\u00F3 Ledger {0} ha de referenciar un compte de valors. + +LedgerStructuralValidatorProjectionPrimaryTargetConflict = Una projecci\u00F3 ha d\u2019usar refer\u00E8ncies de pertinen\u00E7a o una sola refer\u00E8ncia d\u2019assentament principal, per\u00F2 no totes dues. + +LedgerStructuralValidatorProjectionRoleNotAllowed = El rol de projecci\u00F3 {0} no \u00E9s v\u00E0lid per al tipus d\u2019entrada {1}. + +LedgerStructuralValidatorProjectionRoleRequired = La projecci\u00F3 Ledger {0} ha de tenir un rol de projecci\u00F3. + +LedgerStructuralValidatorProjectionRoleRequiredForType = El tipus d\u2019entrada {0} ha de tenir exactament una projecci\u00F3 amb el rol {1}. + +LedgerStructuralValidatorProjectionUuidRequired = L\u2019entrada Ledger {0} cont\u00E9 una projecci\u00F3 sense UUID. + +LedgerStructuralValidatorSignedFactsNotAllowed = El tipus d\u2019entrada {0} nom\u00E9s accepta valors d\u2019assentament positius. Useu imports i t\u00EDtols positius. + +LedgerStructuralValidatorTargetingRefRequired = Les projeccions Ledger dirigides han d\u2019apuntar a un assentament principal. + +LedgerXmlInvalidLedgerStructure = L\u2019estructura Ledger no \u00E9s v\u00E0lida:\n{0} + MsgAlphaVantageAPIKeyMissing = Falta la clau API d'Alpha Vantage. Configura-la a les prefer\u00E8ncies. MsgCheckConfiguredAndCalculatedGrossValueDoNotMatch = El valor brut importat ({0}) i el valor brut calculat ({1}) no coincideixen diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_cs.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_cs.properties index bcf038dba2..f0457ea896 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_cs.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_cs.properties @@ -470,6 +470,148 @@ LabelYahooFinance = Yahoo Finance LabelYahooFinanceAdjustedClose = Yahoo Finance (Upraven\u00E1 uzav\u00EDrac\u00ED cena) +LedgerDiagnosticMessageFormatterAccount = \u00DA\u010Det + +LedgerDiagnosticMessageFormatterAmount = \u010C\u00E1stka + +LedgerDiagnosticMessageFormatterContextUnavailable = nen\u00ED k dispozici + +LedgerDiagnosticMessageFormatterDate = Datum + +LedgerDiagnosticMessageFormatterIsin = ISIN + +LedgerDiagnosticMessageFormatterNote = Pozn\u00E1mka + +LedgerDiagnosticMessageFormatterPortfolio = \u00DA\u010Det cenn\u00FDch pap\u00EDr\u016F + +LedgerDiagnosticMessageFormatterSecurity = Cenn\u00FD pap\u00EDr + +LedgerDiagnosticMessageFormatterShares = Akcie + +LedgerDiagnosticMessageFormatterSource = Zdroj + +LedgerDiagnosticMessageFormatterTicker = Ticker + +LedgerDiagnosticMessageFormatterTransactionContext = Detaily transakce + +LedgerDiagnosticMessageFormatterType = Typ + +LedgerDiagnosticMessageFormatterUnnamedAccount = nepojmenovan\u00FD \u00FA\u010Det + +LedgerDiagnosticMessageFormatterUnnamedPortfolio = nepojmenovan\u00FD \u00FA\u010Det cenn\u00FDch pap\u00EDr\u016F + +LedgerDiagnosticMessageFormatterUnnamedSecurity = nepojmenovan\u00FD cenn\u00FD pap\u00EDr + +LedgerDiagnosticMessageFormatterWkn = WKN + +LedgerInsertActionGeneratedBuyMissingBuySellEntry = Vygenerovanou n\u00E1kupn\u00ED transakci spravuje Ledger, ale propojen\u00FD z\u00E1znam n\u00E1kup/prodej nebyl nalezen. + +LedgerInsertActionGeneratedBuyTypeMismatch = Tento vygenerovan\u00FD n\u00E1kup Ledger lze aktualizovat pouze z importovan\u00E9 n\u00E1kupn\u00ED transakce. + +LedgerInsertActionGeneratedDeliveryOwnerNotFound = Vlastn\u00EDk vygenerovan\u00E9ho dod\u00E1n\u00ED Ledger nebyl nalezen. + +LedgerInsertActionGeneratedDeliveryTypeMismatch = Toto vygenerovan\u00E9 dod\u00E1n\u00ED Ledger lze aktualizovat pouze z importovan\u00E9 n\u00E1kupn\u00ED transakce. + +LedgerInsertActionInvestmentPlanLegacySettersNotSupported = Transakce investi\u010Dn\u00EDho pl\u00E1nu spravovan\u00E9 Ledgerem nelze aktualizovat star\u00FDmi importn\u00EDmi settery. Pou\u017Eijte aktualiza\u010Dn\u00ED cestu Ledger. + +LedgerInsertActionUnsupportedInvestmentPlanTransactionUpdateType = Typ aktualizace investi\u010Dn\u00EDho pl\u00E1nu {0} nen\u00ED pro transakce spravovan\u00E9 Ledgerem podporov\u00E1n. + +LedgerParameterMissingAttribute = Parametru Ledger {0} chyb\u00ED povinn\u00FD atribut. + +LedgerParameterMissingType = Parametr Ledger mus\u00ED m\u00EDt typ. + +LedgerParameterMissingValueKind = Parametr Ledger mus\u00ED deklarovat druh hodnoty. + +LedgerParameterReferenceValueMissingValueNode = Referen\u010Dn\u00ED hodnota parametru Ledger mus\u00ED obsahovat uzel hodnoty. + +LedgerParameterUnsupportedValueKind = Druh hodnoty parametru Ledger {0} nen\u00ED podporov\u00E1n. + +LedgerParameterValueKindRequiresStructuredValue = Druh hodnoty parametru Ledger {0} vy\u017Eaduje strukturovanou hodnotu. + +LedgerProtobufBooleanParameterMissingBooleanValue = Parametr Ledger BOOLEAN mus\u00ED obsahovat booleanValue. + +LedgerProtobufInvalidLedgerPersistenceState = Ulo\u017Een\u00E1 data Ledger Protobuf jsou nekonzistentn\u00ED:\n{0} + +LedgerProtobufLocalDateParameterMissingLocalDateValue = Parametr Ledger LOCAL_DATE mus\u00ED obsahovat localDateValue. + +LedgerRuntimeProjectionRestorerInvalidLedger = Obnova b\u011Bhov\u00FDch projekc\u00ED byla p\u0159esko\u010Dena, proto\u017Ee ulo\u017Een\u00E1 data Ledger nejsou platn\u00E1. Data Ledger nebyla zm\u011Bn\u011Bna. Diagnostika:\n{0} + +LedgerRuntimeProjectionRestorerPartiallyRestored = B\u011Bhov\u00E9 projekce byly obnoveny pouze pro platn\u00E9 z\u00E1znamy Ledger. Odebr\u00E1no {0} star\u00FDch projekc\u00ED a znovu vytvo\u0159eno {1} platn\u00FDch projekc\u00ED. P\u0159esko\u010Den\u00E9 neplatn\u00E9 z\u00E1znamy Ledger: {2}. Dot\u010Den\u00ED vlastn\u00EDci: {3}. Chyb\u011Bj\u00EDc\u00ED UUID projekc\u00ED: {4}. Duplicitn\u00ED UUID projekc\u00ED: {5}. Zastaral\u00E9 UUID projekc\u00ED: {6}. Ulo\u017Een\u00E1 data Ledger nebyla zm\u011Bn\u011Bna. Diagnostika:\n{7} + +LedgerRuntimeProjectionRestorerRestored = B\u011Bhov\u00E9 projekce byly obnoveny z platn\u00FDch dat Ledger. Odebr\u00E1no {0} star\u00FDch projekc\u00ED a znovu vytvo\u0159eno {1} projekc\u00ED. Dot\u010Den\u00ED vlastn\u00EDci: {2}. Chyb\u011Bj\u00EDc\u00ED UUID projekc\u00ED: {3}. Duplicitn\u00ED UUID projekc\u00ED: {4}. Zastaral\u00E9 UUID projekc\u00ED: {5}. Ulo\u017Een\u00E1 data Ledger nebyla zm\u011Bn\u011Bna. + +LedgerStructuralValidatorAccountProjectionPortfolioNotAllowed = Projekce \u00FA\u010Dtu sm\u00ED odkazovat pouze na \u00FA\u010Det; odeberte odkaz na \u00FA\u010Det cenn\u00FDch pap\u00EDr\u016F: {0} + +LedgerStructuralValidatorDividendSecurityRequired = Dividendov\u00E9 z\u00E1znamy mus\u00ED odkazovat na polo\u017Eku cenn\u00E9ho pap\u00EDru: {0} + +LedgerStructuralValidatorDuplicateEntryUuid = Ka\u017Ed\u00FD z\u00E1znam Ledger mus\u00ED m\u00EDt jedine\u010Dn\u00E9 UUID. Duplicita: {0} + +LedgerStructuralValidatorDuplicatePostingUuid = Ka\u017Ed\u00E1 polo\u017Eka Ledger mus\u00ED m\u00EDt jedine\u010Dn\u00E9 UUID. Duplicita: {0} + +LedgerStructuralValidatorDuplicateProjectionUuid = Ka\u017Ed\u00E1 projekce Ledger mus\u00ED m\u00EDt jedine\u010Dn\u00E9 UUID. Duplicita: {0} + +LedgerStructuralValidatorEntryDateTimeRequired = Z\u00E1znam Ledger {0} mus\u00ED m\u00EDt datum a \u010Das. + +LedgerStructuralValidatorEntryTypeRequired = Z\u00E1znam Ledger {0} mus\u00ED m\u00EDt typ z\u00E1znamu. + +LedgerStructuralValidatorEntryUuidRequired = Ka\u017Ed\u00FD z\u00E1znam Ledger mus\u00ED m\u00EDt UUID. + +LedgerStructuralValidatorExDateSecurityRequired = EX_DATE lze pou\u017E\u00EDt pouze u polo\u017Eek, kter\u00E9 odkazuj\u00ED na cenn\u00FD pap\u00EDr: {0} + +LedgerStructuralValidatorLedgerRequired = Je vy\u017Eadov\u00E1n objekt Ledger. + +LedgerStructuralValidatorParameterCodeNotAllowed = Parametr Ledger pro {0} mus\u00ED pou\u017E\u00EDt jeden z konfigurovan\u00FDch k\u00F3d\u016F. + +LedgerStructuralValidatorParameterMustUseValueKind = {0} mus\u00ED pou\u017E\u00EDt druh hodnoty {1}. + +LedgerStructuralValidatorParameterTypeRequired = Parametr Ledger {0} mus\u00ED m\u00EDt typ parametru. + +LedgerStructuralValidatorParameterValueKindMismatch = Hodnota parametru Ledger mus\u00ED odpov\u00EDdat druhu hodnoty {0}. + +LedgerStructuralValidatorParameterValueKindRequired = Parametr Ledger {0} mus\u00ED deklarovat druh hodnoty. + +LedgerStructuralValidatorParameterValueRequired = Parametr Ledger {0} mus\u00ED obsahovat hodnotu. + +LedgerStructuralValidatorPortfolioProjectionAccountNotAllowed = Projekce \u00FA\u010Dtu cenn\u00FDch pap\u00EDr\u016F sm\u00ED odkazovat pouze na \u00FA\u010Det cenn\u00FDch pap\u00EDr\u016F; odeberte odkaz na \u00FA\u010Det: {0} + +LedgerStructuralValidatorPostingCurrencyRequired = Polo\u017Eka Ledger {0} mus\u00ED m\u00EDt m\u011Bnu. + +LedgerStructuralValidatorPostingExchangeRatePositive = Polo\u017Eka Ledger {0} mus\u00ED m\u00EDt sm\u011Bnn\u00FD kurz v\u011Bt\u0161\u00ED ne\u017E nula. + +LedgerStructuralValidatorPostingGroupRefNotFound = Odkaz na skupinu polo\u017Eek {0} mus\u00ED ukazovat na polo\u017Eku ve stejn\u00E9m z\u00E1znamu. + +LedgerStructuralValidatorPostingSecurityRequired = Polo\u017Eka cenn\u00E9ho pap\u00EDru {0} mus\u00ED odkazovat na cenn\u00FD pap\u00EDr. + +LedgerStructuralValidatorPostingTypeRequired = Polo\u017Eka Ledger {0} mus\u00ED m\u00EDt typ polo\u017Eky. + +LedgerStructuralValidatorPostingUuidRequired = Z\u00E1znam Ledger {0} obsahuje polo\u017Eku bez UUID. + +LedgerStructuralValidatorPrimaryPostingRefNotFound = Odkaz na prim\u00E1rn\u00ED polo\u017Eku {0} mus\u00ED ukazovat na polo\u017Eku ve stejn\u00E9m z\u00E1znamu. + +LedgerStructuralValidatorProjectionAccountRequired = Projekce Ledger {0} mus\u00ED odkazovat na \u00FA\u010Det. + +LedgerStructuralValidatorProjectionGroupTargetConflict = Projekce mus\u00ED pou\u017E\u00EDt bu\u010F \u010Dlensk\u00E9 odkazy, nebo jeden odkaz na skupinu polo\u017Eek, ne oboj\u00ED. + +LedgerStructuralValidatorProjectionMembershipRefNotFound = \u010Clensk\u00FD odkaz projekce {0} mus\u00ED ukazovat na polo\u017Eku ve stejn\u00E9m z\u00E1znamu. + +LedgerStructuralValidatorProjectionPortfolioRequired = Projekce Ledger {0} mus\u00ED odkazovat na \u00FA\u010Det cenn\u00FDch pap\u00EDr\u016F. + +LedgerStructuralValidatorProjectionPrimaryTargetConflict = Projekce mus\u00ED pou\u017E\u00EDt bu\u010F \u010Dlensk\u00E9 odkazy, nebo jeden odkaz na prim\u00E1rn\u00ED polo\u017Eku, ne oboj\u00ED. + +LedgerStructuralValidatorProjectionRoleNotAllowed = Role projekce {0} nen\u00ED platn\u00E1 pro typ z\u00E1znamu {1}. + +LedgerStructuralValidatorProjectionRoleRequired = Projekce Ledger {0} mus\u00ED m\u00EDt roli projekce. + +LedgerStructuralValidatorProjectionRoleRequiredForType = Typ z\u00E1znamu {0} mus\u00ED m\u00EDt pr\u00E1v\u011B jednu projekci s rol\u00ED {1}. + +LedgerStructuralValidatorProjectionUuidRequired = Z\u00E1znam Ledger {0} obsahuje projekci bez UUID. + +LedgerStructuralValidatorSignedFactsNotAllowed = Typ z\u00E1znamu {0} p\u0159ij\u00EDm\u00E1 pouze kladn\u00E9 hodnoty polo\u017Eek. Pou\u017Eijte kladn\u00E9 \u010D\u00E1stky a po\u010Dty akci\u00ED. + +LedgerStructuralValidatorTargetingRefRequired = C\u00EDlen\u00E9 projekce Ledger mus\u00ED ukazovat na prim\u00E1rn\u00ED polo\u017Eku. + +LedgerXmlInvalidLedgerStructure = Struktura Ledger nen\u00ED platn\u00E1:\n{0} + MsgAlphaVantageAPIKeyMissing = Alpha Vantage API key is missing. Nastavte v p\u0159edvolb\u00E1ch. MsgCheckConfiguredAndCalculatedGrossValueDoNotMatch = Importovan\u00E1 hrub\u00E1 hodnota ({0}) a vypo\u010Dten\u00E1 hrub\u00E1 hodnota ({1}) se neshoduj\u00ED diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_da.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_da.properties index bc40be723a..c7758f4ea5 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_da.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_da.properties @@ -472,6 +472,148 @@ LabelYahooFinance = Yahoo Finance LabelYahooFinanceAdjustedClose = Yahoo Finance (Justeret Luk) +LedgerDiagnosticMessageFormatterAccount = Konto + +LedgerDiagnosticMessageFormatterAmount = Bel\u00F8b + +LedgerDiagnosticMessageFormatterContextUnavailable = ikke tilg\u00E6ngelig + +LedgerDiagnosticMessageFormatterDate = Dato + +LedgerDiagnosticMessageFormatterIsin = ISIN + +LedgerDiagnosticMessageFormatterNote = Note + +LedgerDiagnosticMessageFormatterPortfolio = V\u00E6rdipapirkonto + +LedgerDiagnosticMessageFormatterSecurity = V\u00E6rdipapir + +LedgerDiagnosticMessageFormatterShares = Aktier + +LedgerDiagnosticMessageFormatterSource = Kilde + +LedgerDiagnosticMessageFormatterTicker = Ticker + +LedgerDiagnosticMessageFormatterTransactionContext = Transaktionsdetaljer + +LedgerDiagnosticMessageFormatterType = Type + +LedgerDiagnosticMessageFormatterUnnamedAccount = unavngiven konto + +LedgerDiagnosticMessageFormatterUnnamedPortfolio = unavngiven v\u00E6rdipapirkonto + +LedgerDiagnosticMessageFormatterUnnamedSecurity = unavngivet v\u00E6rdipapir + +LedgerDiagnosticMessageFormatterWkn = WKN + +LedgerInsertActionGeneratedBuyMissingBuySellEntry = Den genererede k\u00F8bstransaktion administreres af Ledger, men den tilknyttede k\u00F8b/salg-post blev ikke fundet. + +LedgerInsertActionGeneratedBuyTypeMismatch = Dette genererede Ledger-k\u00F8b kan kun opdateres fra en importeret k\u00F8bstransaktion. + +LedgerInsertActionGeneratedDeliveryOwnerNotFound = Ejeren af den genererede Ledger-levering blev ikke fundet. + +LedgerInsertActionGeneratedDeliveryTypeMismatch = Denne genererede Ledger-levering kan kun opdateres fra en importeret k\u00F8bstransaktion. + +LedgerInsertActionInvestmentPlanLegacySettersNotSupported = Ledger-administrerede investeringsplantransaktioner kan ikke opdateres med gamle import-settere. Brug Ledger-opdateringsvejen. + +LedgerInsertActionUnsupportedInvestmentPlanTransactionUpdateType = Opdateringstypen {0} for investeringsplaner underst\u00F8ttes ikke for Ledger-administrerede transaktioner. + +LedgerParameterMissingAttribute = Ledger-parameteren {0} mangler en p\u00E5kr\u00E6vet attribut. + +LedgerParameterMissingType = En Ledger-parameter skal have en type. + +LedgerParameterMissingValueKind = En Ledger-parameter skal angive en v\u00E6rditype. + +LedgerParameterReferenceValueMissingValueNode = En referencev\u00E6rdi for en Ledger-parameter skal indeholde en v\u00E6rdinode. + +LedgerParameterUnsupportedValueKind = Ledger-parameterens v\u00E6rditype {0} underst\u00F8ttes ikke. + +LedgerParameterValueKindRequiresStructuredValue = Ledger-parameterens v\u00E6rditype {0} kr\u00E6ver en struktureret v\u00E6rdi. + +LedgerProtobufBooleanParameterMissingBooleanValue = En BOOLEAN Ledger-parameter skal indeholde booleanValue. + +LedgerProtobufInvalidLedgerPersistenceState = De gemte Ledger Protobuf-data er inkonsistente:\n{0} + +LedgerProtobufLocalDateParameterMissingLocalDateValue = En LOCAL_DATE Ledger-parameter skal indeholde localDateValue. + +LedgerRuntimeProjectionRestorerInvalidLedger = Gendannelse af runtimeprojektioner blev sprunget over, fordi de gemte Ledger-data er ugyldige. Ledger-data blev ikke \u00E6ndret. Diagnose:\n{0} + +LedgerRuntimeProjectionRestorerPartiallyRestored = Runtimeprojektioner blev kun gendannet for gyldige Ledger-poster. {0} gamle projektioner blev fjernet, og {1} gyldige projektioner blev oprettet igen. Ugyldige Ledger-poster sprunget over: {2}. Ber\u00F8rte ejere: {3}. Manglende projektions-UUIDer: {4}. Dublette projektions-UUIDer: {5}. For\u00E6ldede projektions-UUIDer: {6}. De gemte Ledger-data blev ikke \u00E6ndret. Diagnose:\n{7} + +LedgerRuntimeProjectionRestorerRestored = Runtimeprojektioner blev gendannet fra gyldige Ledger-data. {0} gamle projektioner blev fjernet, og {1} projektioner blev oprettet igen. Ber\u00F8rte ejere: {2}. Manglende projektions-UUIDer: {3}. Dublette projektions-UUIDer: {4}. For\u00E6ldede projektions-UUIDer: {5}. De gemte Ledger-data blev ikke \u00E6ndret. + +LedgerStructuralValidatorAccountProjectionPortfolioNotAllowed = En kontoprojektion m\u00E5 kun referere til en konto; fjern referencen til v\u00E6rdipapirkontoen: {0} + +LedgerStructuralValidatorDividendSecurityRequired = Udbytteposter skal referere til en v\u00E6rdipapirposting: {0} + +LedgerStructuralValidatorDuplicateEntryUuid = Hver Ledger-post skal have en unik UUID. Dublet: {0} + +LedgerStructuralValidatorDuplicatePostingUuid = Hver Ledger-posting skal have en unik UUID. Dublet: {0} + +LedgerStructuralValidatorDuplicateProjectionUuid = Hver Ledger-projektion skal have en unik UUID. Dublet: {0} + +LedgerStructuralValidatorEntryDateTimeRequired = Ledger-posten {0} skal have dato og tid. + +LedgerStructuralValidatorEntryTypeRequired = Ledger-posten {0} skal have en posttype. + +LedgerStructuralValidatorEntryUuidRequired = Hver Ledger-post skal have en UUID. + +LedgerStructuralValidatorExDateSecurityRequired = EX_DATE kan kun bruges p\u00E5 postinger, der refererer til et v\u00E6rdipapir: {0} + +LedgerStructuralValidatorLedgerRequired = Et Ledger-objekt er p\u00E5kr\u00E6vet. + +LedgerStructuralValidatorParameterCodeNotAllowed = Ledger-parameteren for {0} skal bruge en af de konfigurerede koder. + +LedgerStructuralValidatorParameterMustUseValueKind = {0} skal bruge v\u00E6rditypen {1}. + +LedgerStructuralValidatorParameterTypeRequired = Ledger-parameteren {0} skal have en parametertype. + +LedgerStructuralValidatorParameterValueKindMismatch = Ledger-parameterv\u00E6rdien skal matche v\u00E6rditypen {0}. + +LedgerStructuralValidatorParameterValueKindRequired = Ledger-parameteren {0} skal angive en v\u00E6rditype. + +LedgerStructuralValidatorParameterValueRequired = Ledger-parameteren {0} skal indeholde en v\u00E6rdi. + +LedgerStructuralValidatorPortfolioProjectionAccountNotAllowed = En v\u00E6rdipapirkontoprojektion m\u00E5 kun referere til en v\u00E6rdipapirkonto; fjern kontoreferencen: {0} + +LedgerStructuralValidatorPostingCurrencyRequired = Ledger-postingen {0} skal have en valuta. + +LedgerStructuralValidatorPostingExchangeRatePositive = Ledger-postingen {0} skal have en vekselkurs st\u00F8rre end nul. + +LedgerStructuralValidatorPostingGroupRefNotFound = Postinggruppereferencen {0} skal pege p\u00E5 en posting i samme post. + +LedgerStructuralValidatorPostingSecurityRequired = V\u00E6rdipapirpostingen {0} skal referere til et v\u00E6rdipapir. + +LedgerStructuralValidatorPostingTypeRequired = Ledger-postingen {0} skal have en postingtype. + +LedgerStructuralValidatorPostingUuidRequired = Ledger-posten {0} indeholder en posting uden UUID. + +LedgerStructuralValidatorPrimaryPostingRefNotFound = Referencen til prim\u00E6r posting {0} skal pege p\u00E5 en posting i samme post. + +LedgerStructuralValidatorProjectionAccountRequired = Ledger-projektionen {0} skal referere til en konto. + +LedgerStructuralValidatorProjectionGroupTargetConflict = En projektion skal bruge enten medlemskabsreferencer eller \u00E9n postinggruppereference, ikke begge. + +LedgerStructuralValidatorProjectionMembershipRefNotFound = Projektionsmedlemskabsreferencen {0} skal pege p\u00E5 en posting i samme post. + +LedgerStructuralValidatorProjectionPortfolioRequired = Ledger-projektionen {0} skal referere til en v\u00E6rdipapirkonto. + +LedgerStructuralValidatorProjectionPrimaryTargetConflict = En projektion skal bruge enten medlemskabsreferencer eller \u00E9n prim\u00E6r postingreference, ikke begge. + +LedgerStructuralValidatorProjectionRoleNotAllowed = Projektionsrollen {0} er ikke gyldig for posttypen {1}. + +LedgerStructuralValidatorProjectionRoleRequired = Ledger-projektionen {0} skal have en projektionsrolle. + +LedgerStructuralValidatorProjectionRoleRequiredForType = Posttypen {0} skal have pr\u00E6cis \u00E9n projektion med rollen {1}. + +LedgerStructuralValidatorProjectionUuidRequired = Ledger-posten {0} indeholder en projektion uden UUID. + +LedgerStructuralValidatorSignedFactsNotAllowed = Posttypen {0} accepterer kun positive postingv\u00E6rdier. Brug positive bel\u00F8b og antal. + +LedgerStructuralValidatorTargetingRefRequired = M\u00E5lrettede Ledger-projektioner skal pege p\u00E5 en prim\u00E6r posting. + +LedgerXmlInvalidLedgerStructure = Ledger-strukturen er ugyldig:\n{0} + MsgAlphaVantageAPIKeyMissing = Alpha Vantage API-n\u00F8gle mangler. Konfigurer i pr\u00E6ferencer. MsgCheckConfiguredAndCalculatedGrossValueDoNotMatch = Importeret bruttov\u00E6rdi ({0}) og beregnet bruttov\u00E6rdi ({1}) stemmer ikke overens diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_de.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_de.properties index b68110730d..f979af82b7 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_de.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_de.properties @@ -472,6 +472,148 @@ LabelYahooFinance = Yahoo Finance LabelYahooFinanceAdjustedClose = Yahoo Finance (Adjusted Close) +LedgerDiagnosticMessageFormatterAccount = Konto + +LedgerDiagnosticMessageFormatterAmount = Betrag + +LedgerDiagnosticMessageFormatterContextUnavailable = nicht verf\u00FCgbar + +LedgerDiagnosticMessageFormatterDate = Datum + +LedgerDiagnosticMessageFormatterIsin = ISIN + +LedgerDiagnosticMessageFormatterNote = Notiz + +LedgerDiagnosticMessageFormatterPortfolio = Depot + +LedgerDiagnosticMessageFormatterSecurity = Wertpapier + +LedgerDiagnosticMessageFormatterShares = St\u00FCck + +LedgerDiagnosticMessageFormatterSource = Quelle + +LedgerDiagnosticMessageFormatterTicker = Ticker + +LedgerDiagnosticMessageFormatterTransactionContext = Buchungsdetails + +LedgerDiagnosticMessageFormatterType = Typ + +LedgerDiagnosticMessageFormatterUnnamedAccount = unbenanntes Konto + +LedgerDiagnosticMessageFormatterUnnamedPortfolio = unbenanntes Depot + +LedgerDiagnosticMessageFormatterUnnamedSecurity = unbenanntes Wertpapier + +LedgerDiagnosticMessageFormatterWkn = WKN + +LedgerInsertActionGeneratedBuyMissingBuySellEntry = Die generierte Kaufbuchung wird vom Ledger verwaltet, aber der verkn\u00FCpfte Kauf-/Verkaufseintrag wurde nicht gefunden. + +LedgerInsertActionGeneratedBuyTypeMismatch = Diese generierte Ledger-Kaufbuchung kann nur mit einer importierten Kaufbuchung aktualisiert werden. + +LedgerInsertActionGeneratedDeliveryOwnerNotFound = Der Besitzer der generierten Ledger-Einlieferung wurde nicht gefunden. + +LedgerInsertActionGeneratedDeliveryTypeMismatch = Diese generierte Ledger-Einlieferung kann nur mit einer importierten Kaufbuchung aktualisiert werden. + +LedgerInsertActionInvestmentPlanLegacySettersNotSupported = Vom Ledger verwaltete Sparplanbuchungen k\u00F6nnen nicht \u00FCber alte Import-Setter aktualisiert werden. Verwenden Sie den Ledger-Aktualisierungspfad. + +LedgerInsertActionUnsupportedInvestmentPlanTransactionUpdateType = Der Sparplan-Aktualisierungstyp {0} wird f\u00FCr vom Ledger verwaltete Buchungen nicht unterst\u00FCtzt. + +LedgerParameterMissingAttribute = Dem Ledger-Parameter {0} fehlt ein erforderliches Attribut. + +LedgerParameterMissingType = Ein Ledger-Parameter muss einen Typ haben. + +LedgerParameterMissingValueKind = Ein Ledger-Parameter muss eine Wertart angeben. + +LedgerParameterReferenceValueMissingValueNode = Ein Referenzwert eines Ledger-Parameters muss einen Wertknoten enthalten. + +LedgerParameterUnsupportedValueKind = Die Wertart {0} f\u00FCr Ledger-Parameter wird nicht unterst\u00FCtzt. + +LedgerParameterValueKindRequiresStructuredValue = Die Wertart {0} f\u00FCr Ledger-Parameter ben\u00F6tigt einen strukturierten Wert. + +LedgerProtobufBooleanParameterMissingBooleanValue = Ein BOOLEAN-Ledger-Parameter muss einen booleanValue enthalten. + +LedgerProtobufInvalidLedgerPersistenceState = Die gespeicherten Ledger-Protobuf-Daten sind inkonsistent:\n{0} + +LedgerProtobufLocalDateParameterMissingLocalDateValue = Ein LOCAL_DATE-Ledger-Parameter muss einen localDateValue enthalten. + +LedgerRuntimeProjectionRestorerInvalidLedger = Die Wiederherstellung der Laufzeitprojektionen wurde \u00FCbersprungen, weil die gespeicherten Ledger-Daten ung\u00FCltig sind. Die Ledger-Daten wurden nicht ge\u00E4ndert. Diagnose:\n{0} + +LedgerRuntimeProjectionRestorerPartiallyRestored = Laufzeitprojektionen wurden nur f\u00FCr g\u00FCltige Ledger-Eintr\u00E4ge wiederhergestellt. {0} alte Laufzeitprojektionen wurden entfernt und {1} g\u00FCltige Projektionen neu erstellt. \u00DCbersprungene ung\u00FCltige Ledger-Eintr\u00E4ge: {2}. Betroffene Besitzer: {3}. Fehlende Projektions-UUIDs: {4}. Doppelte Projektions-UUIDs: {5}. Veraltete Projektions-UUIDs: {6}. Die gespeicherten Ledger-Daten wurden nicht ge\u00E4ndert. Diagnose:\n{7} + +LedgerRuntimeProjectionRestorerRestored = Laufzeitprojektionen wurden aus g\u00FCltigen Ledger-Daten wiederhergestellt. {0} alte Laufzeitprojektionen wurden entfernt und {1} Projektionen neu erstellt. Betroffene Besitzer: {2}. Fehlende Projektions-UUIDs: {3}. Doppelte Projektions-UUIDs: {4}. Veraltete Projektions-UUIDs: {5}. Die gespeicherten Ledger-Daten wurden nicht ge\u00E4ndert. + +LedgerStructuralValidatorAccountProjectionPortfolioNotAllowed = Eine Kontoprojektion darf nur auf ein Konto verweisen; entfernen Sie den Depotverweis: {0} + +LedgerStructuralValidatorDividendSecurityRequired = Dividenden-Eintr\u00E4ge m\u00FCssen auf eine Wertpapierbuchung verweisen: {0} + +LedgerStructuralValidatorDuplicateEntryUuid = Jeder Ledger-Eintrag muss eine eindeutige UUID haben. Doppelt: {0} + +LedgerStructuralValidatorDuplicatePostingUuid = Jede Ledger-Buchung muss eine eindeutige UUID haben. Doppelt: {0} + +LedgerStructuralValidatorDuplicateProjectionUuid = Jede Ledger-Projektion muss eine eindeutige UUID haben. Doppelt: {0} + +LedgerStructuralValidatorEntryDateTimeRequired = Der Ledger-Eintrag {0} muss Datum und Uhrzeit haben. + +LedgerStructuralValidatorEntryTypeRequired = Der Ledger-Eintrag {0} muss einen Eintragstyp haben. + +LedgerStructuralValidatorEntryUuidRequired = Jeder Ledger-Eintrag muss eine UUID haben. + +LedgerStructuralValidatorExDateSecurityRequired = EX_DATE kann nur f\u00FCr Buchungen verwendet werden, die auf ein Wertpapier verweisen: {0} + +LedgerStructuralValidatorLedgerRequired = Ein Ledger-Objekt ist erforderlich. + +LedgerStructuralValidatorParameterCodeNotAllowed = Der Ledger-Parameter f\u00FCr {0} muss einen der konfigurierten Codes verwenden. + +LedgerStructuralValidatorParameterMustUseValueKind = {0} muss die Wertart {1} verwenden. + +LedgerStructuralValidatorParameterTypeRequired = Der Ledger-Parameter {0} muss einen Parametertyp haben. + +LedgerStructuralValidatorParameterValueKindMismatch = Der Wert des Ledger-Parameters muss zur Wertart {0} passen. + +LedgerStructuralValidatorParameterValueKindRequired = Der Ledger-Parameter {0} muss eine Wertart angeben. + +LedgerStructuralValidatorParameterValueRequired = Der Ledger-Parameter {0} muss einen Wert enthalten. + +LedgerStructuralValidatorPortfolioProjectionAccountNotAllowed = Eine Depotprojektion darf nur auf ein Depot verweisen; entfernen Sie den Kontoverweis: {0} + +LedgerStructuralValidatorPostingCurrencyRequired = Die Ledger-Buchung {0} muss eine W\u00E4hrung haben. + +LedgerStructuralValidatorPostingExchangeRatePositive = Die Ledger-Buchung {0} muss einen Wechselkurs gr\u00F6\u00DFer als null haben. + +LedgerStructuralValidatorPostingGroupRefNotFound = Die Buchungsgruppenreferenz {0} muss auf eine Buchung im selben Eintrag zeigen. + +LedgerStructuralValidatorPostingSecurityRequired = Die Wertpapierbuchung {0} muss auf ein Wertpapier verweisen. + +LedgerStructuralValidatorPostingTypeRequired = Die Ledger-Buchung {0} muss einen Buchungstyp haben. + +LedgerStructuralValidatorPostingUuidRequired = Der Ledger-Eintrag {0} enth\u00E4lt eine Buchung ohne UUID. + +LedgerStructuralValidatorPrimaryPostingRefNotFound = Die prim\u00E4re Buchungsreferenz {0} muss auf eine Buchung im selben Eintrag zeigen. + +LedgerStructuralValidatorProjectionAccountRequired = Die Ledger-Projektion {0} muss auf ein Konto verweisen. + +LedgerStructuralValidatorProjectionGroupTargetConflict = Eine Projektion muss entweder Mitgliedschaftsreferenzen oder eine einzelne Buchungsgruppenreferenz verwenden, nicht beides. + +LedgerStructuralValidatorProjectionMembershipRefNotFound = Die Projektions-Mitgliedschaftsreferenz {0} muss auf eine Buchung im selben Eintrag zeigen. + +LedgerStructuralValidatorProjectionPortfolioRequired = Die Ledger-Projektion {0} muss auf ein Depot verweisen. + +LedgerStructuralValidatorProjectionPrimaryTargetConflict = Eine Projektion muss entweder Mitgliedschaftsreferenzen oder eine einzelne prim\u00E4re Buchungsreferenz verwenden, nicht beides. + +LedgerStructuralValidatorProjectionRoleNotAllowed = Die Projektionsrolle {0} ist f\u00FCr den Eintragstyp {1} nicht g\u00FCltig. + +LedgerStructuralValidatorProjectionRoleRequired = Die Ledger-Projektion {0} muss eine Projektionsrolle haben. + +LedgerStructuralValidatorProjectionRoleRequiredForType = Der Eintragstyp {0} muss genau eine Projektion mit der Rolle {1} haben. + +LedgerStructuralValidatorProjectionUuidRequired = Der Ledger-Eintrag {0} enth\u00E4lt eine Projektion ohne UUID. + +LedgerStructuralValidatorSignedFactsNotAllowed = Der Eintragstyp {0} akzeptiert nur positive Buchungswerte. Verwenden Sie positive Betr\u00E4ge und St\u00FCckzahlen. + +LedgerStructuralValidatorTargetingRefRequired = Zielgerichtete Ledger-Projektionen m\u00FCssen auf eine prim\u00E4re Buchung zeigen. + +LedgerXmlInvalidLedgerStructure = Die Ledger-Struktur ist ung\u00FCltig:\n{0} + MsgAlphaVantageAPIKeyMissing = Alpha Vantage API-Schl\u00FCssel ist nicht in den Einstellungen hinterlegt. MsgCheckConfiguredAndCalculatedGrossValueDoNotMatch = Importierter Bruttowert ({0}) und berechneter Bruttowert ({1}) stimmen nicht \u00FCberein diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_es.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_es.properties index 9c54a60107..64e533dc65 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_es.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_es.properties @@ -470,6 +470,148 @@ LabelYahooFinance = Yahoo Finance LabelYahooFinanceAdjustedClose = Yahoo Finance (ajustado al cierre) +LedgerDiagnosticMessageFormatterAccount = Cuenta + +LedgerDiagnosticMessageFormatterAmount = Importe + +LedgerDiagnosticMessageFormatterContextUnavailable = no disponible + +LedgerDiagnosticMessageFormatterDate = Fecha + +LedgerDiagnosticMessageFormatterIsin = ISIN + +LedgerDiagnosticMessageFormatterNote = Nota + +LedgerDiagnosticMessageFormatterPortfolio = Cuenta de valores + +LedgerDiagnosticMessageFormatterSecurity = Activo + +LedgerDiagnosticMessageFormatterShares = Participaciones + +LedgerDiagnosticMessageFormatterSource = Fuente + +LedgerDiagnosticMessageFormatterTicker = Ticker + +LedgerDiagnosticMessageFormatterTransactionContext = Detalles de la operaci\u00F3n + +LedgerDiagnosticMessageFormatterType = Tipo + +LedgerDiagnosticMessageFormatterUnnamedAccount = cuenta sin nombre + +LedgerDiagnosticMessageFormatterUnnamedPortfolio = cuenta de valores sin nombre + +LedgerDiagnosticMessageFormatterUnnamedSecurity = activo sin nombre + +LedgerDiagnosticMessageFormatterWkn = WKN + +LedgerInsertActionGeneratedBuyMissingBuySellEntry = La operaci\u00F3n de compra generada est\u00E1 gestionada por el Ledger, pero no se ha encontrado su entrada de compra/venta vinculada. + +LedgerInsertActionGeneratedBuyTypeMismatch = Esta compra Ledger generada solo se puede actualizar desde una operaci\u00F3n de compra importada. + +LedgerInsertActionGeneratedDeliveryOwnerNotFound = No se ha encontrado el propietario de la entrega Ledger generada. + +LedgerInsertActionGeneratedDeliveryTypeMismatch = Esta entrega Ledger generada solo se puede actualizar desde una operaci\u00F3n de compra importada. + +LedgerInsertActionInvestmentPlanLegacySettersNotSupported = Las operaciones de planes de inversi\u00F3n gestionadas por el Ledger no se pueden actualizar con setters de importaci\u00F3n antiguos. Use la ruta de actualizaci\u00F3n Ledger. + +LedgerInsertActionUnsupportedInvestmentPlanTransactionUpdateType = El tipo de actualizaci\u00F3n de plan de inversi\u00F3n {0} no est\u00E1 admitido para operaciones gestionadas por el Ledger. + +LedgerParameterMissingAttribute = Al par\u00E1metro Ledger {0} le falta un atributo obligatorio. + +LedgerParameterMissingType = Un par\u00E1metro Ledger debe tener un tipo. + +LedgerParameterMissingValueKind = Un par\u00E1metro Ledger debe declarar un tipo de valor. + +LedgerParameterReferenceValueMissingValueNode = Un valor de referencia de par\u00E1metro Ledger debe contener un nodo de valor. + +LedgerParameterUnsupportedValueKind = El tipo de valor de par\u00E1metro Ledger {0} no est\u00E1 admitido. + +LedgerParameterValueKindRequiresStructuredValue = El tipo de valor de par\u00E1metro Ledger {0} requiere un valor estructurado. + +LedgerProtobufBooleanParameterMissingBooleanValue = Un par\u00E1metro Ledger BOOLEAN debe contener booleanValue. + +LedgerProtobufInvalidLedgerPersistenceState = Los datos Protobuf Ledger guardados son incoherentes:\n{0} + +LedgerProtobufLocalDateParameterMissingLocalDateValue = Un par\u00E1metro Ledger LOCAL_DATE debe contener localDateValue. + +LedgerRuntimeProjectionRestorerInvalidLedger = Se omiti\u00F3 la restauraci\u00F3n de proyecciones en tiempo de ejecuci\u00F3n porque los datos Ledger guardados no son v\u00E1lidos. Los datos Ledger no se modificaron. Diagn\u00F3stico:\n{0} + +LedgerRuntimeProjectionRestorerPartiallyRestored = Las proyecciones en tiempo de ejecuci\u00F3n se restauraron solo para entradas Ledger v\u00E1lidas. Se eliminaron {0} proyecciones antiguas y se recrearon {1} proyecciones v\u00E1lidas. Entradas Ledger no v\u00E1lidas omitidas: {2}. Propietarios afectados: {3}. UUID de proyecci\u00F3n faltantes: {4}. UUID de proyecci\u00F3n duplicados: {5}. UUID de proyecci\u00F3n obsoletos: {6}. Los datos Ledger guardados no se modificaron. Diagn\u00F3stico:\n{7} + +LedgerRuntimeProjectionRestorerRestored = Las proyecciones en tiempo de ejecuci\u00F3n se restauraron desde datos Ledger v\u00E1lidos. Se eliminaron {0} proyecciones antiguas y se recrearon {1} proyecciones. Propietarios afectados: {2}. UUID de proyecci\u00F3n faltantes: {3}. UUID de proyecci\u00F3n duplicados: {4}. UUID de proyecci\u00F3n obsoletos: {5}. Los datos Ledger guardados no se modificaron. + +LedgerStructuralValidatorAccountProjectionPortfolioNotAllowed = Una proyecci\u00F3n de cuenta solo debe referenciar una cuenta; elimine la referencia a la cuenta de valores: {0} + +LedgerStructuralValidatorDividendSecurityRequired = Las entradas de dividendo deben referenciar un asiento de activo: {0} + +LedgerStructuralValidatorDuplicateEntryUuid = Cada entrada Ledger debe tener un UUID \u00FAnico. Duplicado: {0} + +LedgerStructuralValidatorDuplicatePostingUuid = Cada asiento Ledger debe tener un UUID \u00FAnico. Duplicado: {0} + +LedgerStructuralValidatorDuplicateProjectionUuid = Cada proyecci\u00F3n Ledger debe tener un UUID \u00FAnico. Duplicado: {0} + +LedgerStructuralValidatorEntryDateTimeRequired = La entrada Ledger {0} debe tener fecha y hora. + +LedgerStructuralValidatorEntryTypeRequired = La entrada Ledger {0} debe tener un tipo de entrada. + +LedgerStructuralValidatorEntryUuidRequired = Cada entrada Ledger debe tener un UUID. + +LedgerStructuralValidatorExDateSecurityRequired = EX_DATE solo se puede usar en asientos que referencian un activo: {0} + +LedgerStructuralValidatorLedgerRequired = Se requiere un objeto Ledger. + +LedgerStructuralValidatorParameterCodeNotAllowed = El par\u00E1metro Ledger para {0} debe usar uno de los c\u00F3digos configurados. + +LedgerStructuralValidatorParameterMustUseValueKind = {0} debe usar el tipo de valor {1}. + +LedgerStructuralValidatorParameterTypeRequired = El par\u00E1metro Ledger {0} debe tener un tipo de par\u00E1metro. + +LedgerStructuralValidatorParameterValueKindMismatch = El valor del par\u00E1metro Ledger debe coincidir con el tipo de valor {0}. + +LedgerStructuralValidatorParameterValueKindRequired = El par\u00E1metro Ledger {0} debe declarar un tipo de valor. + +LedgerStructuralValidatorParameterValueRequired = El par\u00E1metro Ledger {0} debe contener un valor. + +LedgerStructuralValidatorPortfolioProjectionAccountNotAllowed = Una proyecci\u00F3n de cuenta de valores solo debe referenciar una cuenta de valores; elimine la referencia a la cuenta: {0} + +LedgerStructuralValidatorPostingCurrencyRequired = El asiento Ledger {0} debe tener una divisa. + +LedgerStructuralValidatorPostingExchangeRatePositive = El asiento Ledger {0} debe tener un tipo de cambio mayor que cero. + +LedgerStructuralValidatorPostingGroupRefNotFound = La referencia de grupo de asientos {0} debe apuntar a un asiento de la misma entrada. + +LedgerStructuralValidatorPostingSecurityRequired = El asiento de activo {0} debe referenciar un activo. + +LedgerStructuralValidatorPostingTypeRequired = El asiento Ledger {0} debe tener un tipo de asiento. + +LedgerStructuralValidatorPostingUuidRequired = La entrada Ledger {0} contiene un asiento sin UUID. + +LedgerStructuralValidatorPrimaryPostingRefNotFound = La referencia de asiento principal {0} debe apuntar a un asiento de la misma entrada. + +LedgerStructuralValidatorProjectionAccountRequired = La proyecci\u00F3n Ledger {0} debe referenciar una cuenta. + +LedgerStructuralValidatorProjectionGroupTargetConflict = Una proyecci\u00F3n debe usar referencias de pertenencia o una sola referencia de grupo de asientos, pero no ambas. + +LedgerStructuralValidatorProjectionMembershipRefNotFound = La referencia de pertenencia de proyecci\u00F3n {0} debe apuntar a un asiento de la misma entrada. + +LedgerStructuralValidatorProjectionPortfolioRequired = La proyecci\u00F3n Ledger {0} debe referenciar una cuenta de valores. + +LedgerStructuralValidatorProjectionPrimaryTargetConflict = Una proyecci\u00F3n debe usar referencias de pertenencia o una sola referencia de asiento principal, pero no ambas. + +LedgerStructuralValidatorProjectionRoleNotAllowed = El rol de proyecci\u00F3n {0} no es v\u00E1lido para el tipo de entrada {1}. + +LedgerStructuralValidatorProjectionRoleRequired = La proyecci\u00F3n Ledger {0} debe tener un rol de proyecci\u00F3n. + +LedgerStructuralValidatorProjectionRoleRequiredForType = El tipo de entrada {0} debe tener exactamente una proyecci\u00F3n con el rol {1}. + +LedgerStructuralValidatorProjectionUuidRequired = La entrada Ledger {0} contiene una proyecci\u00F3n sin UUID. + +LedgerStructuralValidatorSignedFactsNotAllowed = El tipo de entrada {0} solo acepta valores de asiento positivos. Use importes y participaciones positivos. + +LedgerStructuralValidatorTargetingRefRequired = Las proyecciones Ledger dirigidas deben apuntar a un asiento principal. + +LedgerXmlInvalidLedgerStructure = La estructura Ledger no es v\u00E1lida:\n{0} + MsgAlphaVantageAPIKeyMissing = Falta la clave API de Alpha Vantage. Config\u00FArela en las preferencias. MsgCheckConfiguredAndCalculatedGrossValueDoNotMatch = Valor bruto importado ({0}) y el valor bruto calculado ({1}) no coinciden diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_fi.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_fi.properties index d63dec868a..d0c58f1b83 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_fi.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_fi.properties @@ -471,6 +471,148 @@ LabelYahooFinance = Yahoo Finance LabelYahooFinanceAdjustedClose = Yahoo Finance (Adjusted Close) +LedgerDiagnosticMessageFormatterAccount = Tili + +LedgerDiagnosticMessageFormatterAmount = M\u00E4\u00E4r\u00E4 + +LedgerDiagnosticMessageFormatterContextUnavailable = ei saatavilla + +LedgerDiagnosticMessageFormatterDate = P\u00E4iv\u00E4 + +LedgerDiagnosticMessageFormatterIsin = ISIN + +LedgerDiagnosticMessageFormatterNote = Muistiinpano + +LedgerDiagnosticMessageFormatterPortfolio = S\u00E4ilytystili + +LedgerDiagnosticMessageFormatterSecurity = Arvopaperi + +LedgerDiagnosticMessageFormatterShares = Osuudet + +LedgerDiagnosticMessageFormatterSource = L\u00E4hde + +LedgerDiagnosticMessageFormatterTicker = Ticker + +LedgerDiagnosticMessageFormatterTransactionContext = Tapahtuman tiedot + +LedgerDiagnosticMessageFormatterType = Tyyppi + +LedgerDiagnosticMessageFormatterUnnamedAccount = nimet\u00F6n tili + +LedgerDiagnosticMessageFormatterUnnamedPortfolio = nimet\u00F6n s\u00E4ilytystili + +LedgerDiagnosticMessageFormatterUnnamedSecurity = nimet\u00F6n arvopaperi + +LedgerDiagnosticMessageFormatterWkn = WKN + +LedgerInsertActionGeneratedBuyMissingBuySellEntry = Luotua ostotapahtumaa hallitsee Ledger, mutta siihen liitetty\u00E4 osto/myynti-kirjausta ei l\u00F6ytynyt. + +LedgerInsertActionGeneratedBuyTypeMismatch = T\u00E4m\u00E4 luotu Ledger-osto voidaan p\u00E4ivitt\u00E4\u00E4 vain tuodusta ostotapahtumasta. + +LedgerInsertActionGeneratedDeliveryOwnerNotFound = Luodun Ledger-toimituksen omistajaa ei l\u00F6ytynyt. + +LedgerInsertActionGeneratedDeliveryTypeMismatch = T\u00E4m\u00E4 luotu Ledger-toimitus voidaan p\u00E4ivitt\u00E4\u00E4 vain tuodusta ostotapahtumasta. + +LedgerInsertActionInvestmentPlanLegacySettersNotSupported = Ledgerin hallitsemia sijoitussuunnitelman tapahtumia ei voi p\u00E4ivitt\u00E4\u00E4 vanhoilla tuonnin settereill\u00E4. K\u00E4yt\u00E4 Ledger-p\u00E4ivityspolkua. + +LedgerInsertActionUnsupportedInvestmentPlanTransactionUpdateType = Sijoitussuunnitelman p\u00E4ivitystyyppi\u00E4 {0} ei tueta Ledgerin hallitsemille tapahtumille. + +LedgerParameterMissingAttribute = Ledger-parametrilta {0} puuttuu pakollinen attribuutti. + +LedgerParameterMissingType = Ledger-parametrilla on oltava tyyppi. + +LedgerParameterMissingValueKind = Ledger-parametrin on ilmoitettava arvon tyyppi. + +LedgerParameterReferenceValueMissingValueNode = Ledger-parametrin viitearvon on sis\u00E4llett\u00E4v\u00E4 arvosolmu. + +LedgerParameterUnsupportedValueKind = Ledger-parametrin arvon tyyppi\u00E4 {0} ei tueta. + +LedgerParameterValueKindRequiresStructuredValue = Ledger-parametrin arvon tyyppi {0} vaatii rakenteisen arvon. + +LedgerProtobufBooleanParameterMissingBooleanValue = BOOLEAN Ledger -parametrin on sis\u00E4llett\u00E4v\u00E4 booleanValue. + +LedgerProtobufInvalidLedgerPersistenceState = Tallennetut Ledger Protobuf -tiedot ovat ep\u00E4johdonmukaiset:\n{0} + +LedgerProtobufLocalDateParameterMissingLocalDateValue = LOCAL_DATE Ledger -parametrin on sis\u00E4llett\u00E4v\u00E4 localDateValue. + +LedgerRuntimeProjectionRestorerInvalidLedger = Ajonaikaisten projektioiden palautus ohitettiin, koska tallennetut Ledger-tiedot eiv\u00E4t ole kelvollisia. Ledger-tietoja ei muutettu. Diagnostiikka:\n{0} + +LedgerRuntimeProjectionRestorerPartiallyRestored = Ajonaikaiset projektiot palautettiin vain kelvollisille Ledger-kirjauksille. {0} vanhaa projektiota poistettiin ja {1} kelvollista projektiota luotiin uudelleen. Ohitetut virheelliset Ledger-kirjaukset: {2}. Vaikutetut omistajat: {3}. Puuttuvat projektio-UUID:t: {4}. P\u00E4\u00E4llekk\u00E4iset projektio-UUID:t: {5}. Vanhentuneet projektio-UUID:t: {6}. Tallennettuja Ledger-tietoja ei muutettu. Diagnostiikka:\n{7} + +LedgerRuntimeProjectionRestorerRestored = Ajonaikaiset projektiot palautettiin kelvollisista Ledger-tiedoista. {0} vanhaa projektiota poistettiin ja {1} projektiota luotiin uudelleen. Vaikutetut omistajat: {2}. Puuttuvat projektio-UUID:t: {3}. P\u00E4\u00E4llekk\u00E4iset projektio-UUID:t: {4}. Vanhentuneet projektio-UUID:t: {5}. Tallennettuja Ledger-tietoja ei muutettu. + +LedgerStructuralValidatorAccountProjectionPortfolioNotAllowed = Tiliprojektio saa viitata vain tiliin; poista s\u00E4ilytystilin viite: {0} + +LedgerStructuralValidatorDividendSecurityRequired = Osinkokirjausten on viitattava arvopaperikirjausriviin: {0} + +LedgerStructuralValidatorDuplicateEntryUuid = Jokaisella Ledger-kirjauksella on oltava yksil\u00F6llinen UUID. P\u00E4\u00E4llekk\u00E4inen: {0} + +LedgerStructuralValidatorDuplicatePostingUuid = Jokaisella Ledger-kirjausrivill\u00E4 on oltava yksil\u00F6llinen UUID. P\u00E4\u00E4llekk\u00E4inen: {0} + +LedgerStructuralValidatorDuplicateProjectionUuid = Jokaisella Ledger-projektiolla on oltava yksil\u00F6llinen UUID. P\u00E4\u00E4llekk\u00E4inen: {0} + +LedgerStructuralValidatorEntryDateTimeRequired = Ledger-kirjauksella {0} on oltava p\u00E4iv\u00E4 ja aika. + +LedgerStructuralValidatorEntryTypeRequired = Ledger-kirjauksella {0} on oltava kirjaustyyppi. + +LedgerStructuralValidatorEntryUuidRequired = Jokaisella Ledger-kirjauksella on oltava UUID. + +LedgerStructuralValidatorExDateSecurityRequired = EX_DATE voidaan k\u00E4ytt\u00E4\u00E4 vain kirjausriveill\u00E4, jotka viittaavat arvopaperiin: {0} + +LedgerStructuralValidatorLedgerRequired = Ledger-objekti vaaditaan. + +LedgerStructuralValidatorParameterCodeNotAllowed = Ledger-parametrin {0} on k\u00E4ytett\u00E4v\u00E4 jotakin m\u00E4\u00E4ritetyist\u00E4 koodeista. + +LedgerStructuralValidatorParameterMustUseValueKind = {0} on k\u00E4ytett\u00E4v\u00E4 arvon tyyppi\u00E4 {1}. + +LedgerStructuralValidatorParameterTypeRequired = Ledger-parametrilla {0} on oltava parametrityyppi. + +LedgerStructuralValidatorParameterValueKindMismatch = Ledger-parametrin arvon on vastattava arvon tyyppi\u00E4 {0}. + +LedgerStructuralValidatorParameterValueKindRequired = Ledger-parametrin {0} on ilmoitettava arvon tyyppi. + +LedgerStructuralValidatorParameterValueRequired = Ledger-parametrin {0} on sis\u00E4llett\u00E4v\u00E4 arvo. + +LedgerStructuralValidatorPortfolioProjectionAccountNotAllowed = S\u00E4ilytystiliprojektio saa viitata vain s\u00E4ilytystiliin; poista tiliviite: {0} + +LedgerStructuralValidatorPostingCurrencyRequired = Ledger-kirjausrivill\u00E4 {0} on oltava valuutta. + +LedgerStructuralValidatorPostingExchangeRatePositive = Ledger-kirjausrivill\u00E4 {0} on oltava nollaa suurempi vaihtokurssi. + +LedgerStructuralValidatorPostingGroupRefNotFound = Kirjausryhm\u00E4n viitteen {0} on osoitettava saman kirjauksen kirjausriviin. + +LedgerStructuralValidatorPostingSecurityRequired = Arvopaperikirjausrivin {0} on viitattava arvopaperiin. + +LedgerStructuralValidatorPostingTypeRequired = Ledger-kirjausrivill\u00E4 {0} on oltava kirjausrivin tyyppi. + +LedgerStructuralValidatorPostingUuidRequired = Ledger-kirjaus {0} sis\u00E4lt\u00E4\u00E4 kirjausrivin ilman UUID:t\u00E4. + +LedgerStructuralValidatorPrimaryPostingRefNotFound = Ensisijaisen kirjausrivin viitteen {0} on osoitettava saman kirjauksen kirjausriviin. + +LedgerStructuralValidatorProjectionAccountRequired = Ledger-projektion {0} on viitattava tiliin. + +LedgerStructuralValidatorProjectionGroupTargetConflict = Projektion on k\u00E4ytett\u00E4v\u00E4 joko j\u00E4senyysviitteit\u00E4 tai yht\u00E4 kirjausryhm\u00E4viitett\u00E4, ei molempia. + +LedgerStructuralValidatorProjectionMembershipRefNotFound = Projektion j\u00E4senyysviitteen {0} on osoitettava saman kirjauksen kirjausriviin. + +LedgerStructuralValidatorProjectionPortfolioRequired = Ledger-projektion {0} on viitattava s\u00E4ilytystiliin. + +LedgerStructuralValidatorProjectionPrimaryTargetConflict = Projektion on k\u00E4ytett\u00E4v\u00E4 joko j\u00E4senyysviitteit\u00E4 tai yht\u00E4 ensisijaisen kirjausrivin viitett\u00E4, ei molempia. + +LedgerStructuralValidatorProjectionRoleNotAllowed = Projektion rooli {0} ei ole kelvollinen kirjaustyypille {1}. + +LedgerStructuralValidatorProjectionRoleRequired = Ledger-projektiolla {0} on oltava projektion rooli. + +LedgerStructuralValidatorProjectionRoleRequiredForType = Kirjaustyypill\u00E4 {0} on oltava t\u00E4sm\u00E4lleen yksi projektio roolilla {1}. + +LedgerStructuralValidatorProjectionUuidRequired = Ledger-kirjaus {0} sis\u00E4lt\u00E4\u00E4 projektion ilman UUID:t\u00E4. + +LedgerStructuralValidatorSignedFactsNotAllowed = Kirjaustyyppi {0} hyv\u00E4ksyy vain positiiviset kirjausrivien arvot. K\u00E4yt\u00E4 positiivisia m\u00E4\u00E4ri\u00E4 ja osuuksia. + +LedgerStructuralValidatorTargetingRefRequired = Kohdistettujen Ledger-projektioiden on osoitettava ensisijaiseen kirjausriviin. + +LedgerXmlInvalidLedgerStructure = Ledger-rakenne ei ole kelvollinen:\n{0} + MsgAlphaVantageAPIKeyMissing = Alpha Vantagen API-avain puuttuu. M\u00E4\u00E4rit\u00E4 se asetuksisa. MsgCheckConfiguredAndCalculatedGrossValueDoNotMatch = Tuodun bruttoarvon ({0}) ja lasketun bruttoarvon ({1}) arvot eiv\u00E4t vastaa toisiaan diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_fr.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_fr.properties index dabe06edc1..7b186c811d 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_fr.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_fr.properties @@ -472,6 +472,148 @@ LabelYahooFinance = Yahoo Finance LabelYahooFinanceAdjustedClose = Yahoo Finance (cours de cl\u00F4ture ajust\u00E9) +LedgerDiagnosticMessageFormatterAccount = Compte + +LedgerDiagnosticMessageFormatterAmount = Montant + +LedgerDiagnosticMessageFormatterContextUnavailable = non disponible + +LedgerDiagnosticMessageFormatterDate = Date + +LedgerDiagnosticMessageFormatterIsin = ISIN + +LedgerDiagnosticMessageFormatterNote = Note + +LedgerDiagnosticMessageFormatterPortfolio = Compte titres + +LedgerDiagnosticMessageFormatterSecurity = Titre + +LedgerDiagnosticMessageFormatterShares = Parts + +LedgerDiagnosticMessageFormatterSource = Source + +LedgerDiagnosticMessageFormatterTicker = Ticker + +LedgerDiagnosticMessageFormatterTransactionContext = D\u00E9tails de la transaction + +LedgerDiagnosticMessageFormatterType = Type + +LedgerDiagnosticMessageFormatterUnnamedAccount = compte sans nom + +LedgerDiagnosticMessageFormatterUnnamedPortfolio = compte titres sans nom + +LedgerDiagnosticMessageFormatterUnnamedSecurity = titre sans nom + +LedgerDiagnosticMessageFormatterWkn = WKN + +LedgerInsertActionGeneratedBuyMissingBuySellEntry = La transaction d\u2019achat g\u00E9n\u00E9r\u00E9e est g\u00E9r\u00E9e par le Ledger, mais son entr\u00E9e achat/vente li\u00E9e est introuvable. + +LedgerInsertActionGeneratedBuyTypeMismatch = Cette transaction d\u2019achat Ledger g\u00E9n\u00E9r\u00E9e ne peut \u00EAtre mise \u00E0 jour qu\u2019\u00E0 partir d\u2019une transaction d\u2019achat import\u00E9e. + +LedgerInsertActionGeneratedDeliveryOwnerNotFound = Le propri\u00E9taire de la livraison Ledger g\u00E9n\u00E9r\u00E9e est introuvable. + +LedgerInsertActionGeneratedDeliveryTypeMismatch = Cette livraison Ledger g\u00E9n\u00E9r\u00E9e ne peut \u00EAtre mise \u00E0 jour qu\u2019\u00E0 partir d\u2019une transaction d\u2019achat import\u00E9e. + +LedgerInsertActionInvestmentPlanLegacySettersNotSupported = Les transactions de plan d\u2019investissement g\u00E9r\u00E9es par le Ledger ne peuvent pas \u00EAtre mises \u00E0 jour avec les anciens setters d\u2019import. Utilisez le chemin de mise \u00E0 jour Ledger. + +LedgerInsertActionUnsupportedInvestmentPlanTransactionUpdateType = Le type de mise \u00E0 jour de plan d\u2019investissement {0} n\u2019est pas pris en charge pour les transactions g\u00E9r\u00E9es par le Ledger. + +LedgerParameterMissingAttribute = Il manque un attribut requis au param\u00E8tre Ledger {0}. + +LedgerParameterMissingType = Un param\u00E8tre Ledger doit avoir un type. + +LedgerParameterMissingValueKind = Un param\u00E8tre Ledger doit d\u00E9clarer un type de valeur. + +LedgerParameterReferenceValueMissingValueNode = Une valeur de r\u00E9f\u00E9rence de param\u00E8tre Ledger doit contenir un n\u0153ud de valeur. + +LedgerParameterUnsupportedValueKind = Le type de valeur de param\u00E8tre Ledger {0} n\u2019est pas pris en charge. + +LedgerParameterValueKindRequiresStructuredValue = Le type de valeur de param\u00E8tre Ledger {0} n\u00E9cessite une valeur structur\u00E9e. + +LedgerProtobufBooleanParameterMissingBooleanValue = Un param\u00E8tre Ledger BOOLEAN doit contenir booleanValue. + +LedgerProtobufInvalidLedgerPersistenceState = Les donn\u00E9es Protobuf Ledger enregistr\u00E9es sont incoh\u00E9rentes :\n{0} + +LedgerProtobufLocalDateParameterMissingLocalDateValue = Un param\u00E8tre Ledger LOCAL_DATE doit contenir localDateValue. + +LedgerRuntimeProjectionRestorerInvalidLedger = La restauration des projections d\u2019ex\u00E9cution a \u00E9t\u00E9 ignor\u00E9e parce que les donn\u00E9es Ledger enregistr\u00E9es sont invalides. Les donn\u00E9es Ledger n\u2019ont pas \u00E9t\u00E9 modifi\u00E9es. Diagnostic :\n{0} + +LedgerRuntimeProjectionRestorerPartiallyRestored = Les projections d\u2019ex\u00E9cution ont \u00E9t\u00E9 restaur\u00E9es uniquement pour les \u00E9critures Ledger valides. {0} anciennes projections ont \u00E9t\u00E9 supprim\u00E9es et {1} projections valides recr\u00E9\u00E9es. \u00C9critures Ledger invalides ignor\u00E9es : {2}. Propri\u00E9taires concern\u00E9s : {3}. UUID de projection manquants : {4}. UUID de projection dupliqu\u00E9s : {5}. UUID de projection obsol\u00E8tes : {6}. Les donn\u00E9es Ledger enregistr\u00E9es n\u2019ont pas \u00E9t\u00E9 modifi\u00E9es. Diagnostic :\n{7} + +LedgerRuntimeProjectionRestorerRestored = Les projections d\u2019ex\u00E9cution ont \u00E9t\u00E9 restaur\u00E9es \u00E0 partir de donn\u00E9es Ledger valides. {0} anciennes projections ont \u00E9t\u00E9 supprim\u00E9es et {1} projections recr\u00E9\u00E9es. Propri\u00E9taires concern\u00E9s : {2}. UUID de projection manquants : {3}. UUID de projection dupliqu\u00E9s : {4}. UUID de projection obsol\u00E8tes : {5}. Les donn\u00E9es Ledger enregistr\u00E9es n\u2019ont pas \u00E9t\u00E9 modifi\u00E9es. + +LedgerStructuralValidatorAccountProjectionPortfolioNotAllowed = Une projection de compte doit r\u00E9f\u00E9rencer uniquement un compte ; supprimez la r\u00E9f\u00E9rence au compte titres : {0} + +LedgerStructuralValidatorDividendSecurityRequired = Les \u00E9critures de dividende doivent r\u00E9f\u00E9rencer un posting de titre : {0} + +LedgerStructuralValidatorDuplicateEntryUuid = Chaque \u00E9criture Ledger doit avoir un UUID unique. Doublon : {0} + +LedgerStructuralValidatorDuplicatePostingUuid = Chaque posting Ledger doit avoir un UUID unique. Doublon : {0} + +LedgerStructuralValidatorDuplicateProjectionUuid = Chaque projection Ledger doit avoir un UUID unique. Doublon : {0} + +LedgerStructuralValidatorEntryDateTimeRequired = L\u2019\u00E9criture Ledger {0} doit avoir une date et une heure. + +LedgerStructuralValidatorEntryTypeRequired = L\u2019\u00E9criture Ledger {0} doit avoir un type d\u2019\u00E9criture. + +LedgerStructuralValidatorEntryUuidRequired = Chaque \u00E9criture Ledger doit avoir un UUID. + +LedgerStructuralValidatorExDateSecurityRequired = EX_DATE ne peut \u00EAtre utilis\u00E9 que sur des postings qui r\u00E9f\u00E9rencent un titre : {0} + +LedgerStructuralValidatorLedgerRequired = Un objet Ledger est requis. + +LedgerStructuralValidatorParameterCodeNotAllowed = Le param\u00E8tre Ledger pour {0} doit utiliser l\u2019un des codes configur\u00E9s. + +LedgerStructuralValidatorParameterMustUseValueKind = {0} doit utiliser le type de valeur {1}. + +LedgerStructuralValidatorParameterTypeRequired = Le param\u00E8tre Ledger {0} doit avoir un type de param\u00E8tre. + +LedgerStructuralValidatorParameterValueKindMismatch = La valeur du param\u00E8tre Ledger doit correspondre au type de valeur {0}. + +LedgerStructuralValidatorParameterValueKindRequired = Le param\u00E8tre Ledger {0} doit d\u00E9clarer un type de valeur. + +LedgerStructuralValidatorParameterValueRequired = Le param\u00E8tre Ledger {0} doit contenir une valeur. + +LedgerStructuralValidatorPortfolioProjectionAccountNotAllowed = Une projection de compte titres doit r\u00E9f\u00E9rencer uniquement un compte titres ; supprimez la r\u00E9f\u00E9rence au compte : {0} + +LedgerStructuralValidatorPostingCurrencyRequired = Le posting Ledger {0} doit avoir une devise. + +LedgerStructuralValidatorPostingExchangeRatePositive = Le posting Ledger {0} doit avoir un taux de change sup\u00E9rieur \u00E0 z\u00E9ro. + +LedgerStructuralValidatorPostingGroupRefNotFound = La r\u00E9f\u00E9rence de groupe de postings {0} doit pointer vers un posting de la m\u00EAme \u00E9criture. + +LedgerStructuralValidatorPostingSecurityRequired = Le posting de titre {0} doit r\u00E9f\u00E9rencer un titre. + +LedgerStructuralValidatorPostingTypeRequired = Le posting Ledger {0} doit avoir un type de posting. + +LedgerStructuralValidatorPostingUuidRequired = L\u2019\u00E9criture Ledger {0} contient un posting sans UUID. + +LedgerStructuralValidatorPrimaryPostingRefNotFound = La r\u00E9f\u00E9rence de posting principal {0} doit pointer vers un posting de la m\u00EAme \u00E9criture. + +LedgerStructuralValidatorProjectionAccountRequired = La projection Ledger {0} doit r\u00E9f\u00E9rencer un compte. + +LedgerStructuralValidatorProjectionGroupTargetConflict = Une projection doit utiliser soit des r\u00E9f\u00E9rences d\u2019appartenance, soit une seule r\u00E9f\u00E9rence de groupe de postings, mais pas les deux. + +LedgerStructuralValidatorProjectionMembershipRefNotFound = La r\u00E9f\u00E9rence d\u2019appartenance de projection {0} doit pointer vers un posting de la m\u00EAme \u00E9criture. + +LedgerStructuralValidatorProjectionPortfolioRequired = La projection Ledger {0} doit r\u00E9f\u00E9rencer un compte titres. + +LedgerStructuralValidatorProjectionPrimaryTargetConflict = Une projection doit utiliser soit des r\u00E9f\u00E9rences d\u2019appartenance, soit une seule r\u00E9f\u00E9rence de posting principal, mais pas les deux. + +LedgerStructuralValidatorProjectionRoleNotAllowed = Le r\u00F4le de projection {0} n\u2019est pas valide pour le type d\u2019\u00E9criture {1}. + +LedgerStructuralValidatorProjectionRoleRequired = La projection Ledger {0} doit avoir un r\u00F4le de projection. + +LedgerStructuralValidatorProjectionRoleRequiredForType = Le type d\u2019\u00E9criture {0} doit avoir exactement une projection avec le r\u00F4le {1}. + +LedgerStructuralValidatorProjectionUuidRequired = L\u2019\u00E9criture Ledger {0} contient une projection sans UUID. + +LedgerStructuralValidatorSignedFactsNotAllowed = Le type d\u2019\u00E9criture {0} accepte uniquement des valeurs de posting positives. Utilisez des montants et des parts positifs. + +LedgerStructuralValidatorTargetingRefRequired = Les projections Ledger cibl\u00E9es doivent pointer vers un posting principal. + +LedgerXmlInvalidLedgerStructure = La structure Ledger est invalide :\n{0} + MsgAlphaVantageAPIKeyMissing = Clef API Alpha Vantage manquante. \u00C0 configurer dans les pr\u00E9f\u00E9rences. MsgCheckConfiguredAndCalculatedGrossValueDoNotMatch = Valeur brute import\u00E9e ({0}) et valeur brute calcul\u00E9e ({1}) ne correspond pas diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_it.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_it.properties index 9cb5f92b34..289acec6ca 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_it.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_it.properties @@ -472,6 +472,148 @@ LabelYahooFinance = Yahoo Finance LabelYahooFinanceAdjustedClose = Yahoo Finance (Chiusura normalizzata) +LedgerDiagnosticMessageFormatterAccount = Conto + +LedgerDiagnosticMessageFormatterAmount = Importo + +LedgerDiagnosticMessageFormatterContextUnavailable = non disponibile + +LedgerDiagnosticMessageFormatterDate = Data + +LedgerDiagnosticMessageFormatterIsin = ISIN + +LedgerDiagnosticMessageFormatterNote = Nota + +LedgerDiagnosticMessageFormatterPortfolio = Conto titoli + +LedgerDiagnosticMessageFormatterSecurity = Titolo + +LedgerDiagnosticMessageFormatterShares = Azioni + +LedgerDiagnosticMessageFormatterSource = Fonte + +LedgerDiagnosticMessageFormatterTicker = Ticker + +LedgerDiagnosticMessageFormatterTransactionContext = Dettagli della transazione + +LedgerDiagnosticMessageFormatterType = Tipo + +LedgerDiagnosticMessageFormatterUnnamedAccount = conto senza nome + +LedgerDiagnosticMessageFormatterUnnamedPortfolio = conto titoli senza nome + +LedgerDiagnosticMessageFormatterUnnamedSecurity = titolo senza nome + +LedgerDiagnosticMessageFormatterWkn = WKN + +LedgerInsertActionGeneratedBuyMissingBuySellEntry = La transazione di acquisto generata \u00E8 gestita dal Ledger, ma la voce acquisto/vendita collegata non \u00E8 stata trovata. + +LedgerInsertActionGeneratedBuyTypeMismatch = Questo acquisto Ledger generato pu\u00F2 essere aggiornato solo da una transazione di acquisto importata. + +LedgerInsertActionGeneratedDeliveryOwnerNotFound = Il proprietario della consegna Ledger generata non \u00E8 stato trovato. + +LedgerInsertActionGeneratedDeliveryTypeMismatch = Questa consegna Ledger generata pu\u00F2 essere aggiornata solo da una transazione di acquisto importata. + +LedgerInsertActionInvestmentPlanLegacySettersNotSupported = Le transazioni dei piani di investimento gestite dal Ledger non possono essere aggiornate con i vecchi setter di importazione. Usare il percorso di aggiornamento Ledger. + +LedgerInsertActionUnsupportedInvestmentPlanTransactionUpdateType = Il tipo di aggiornamento del piano di investimento {0} non \u00E8 supportato per transazioni gestite dal Ledger. + +LedgerParameterMissingAttribute = Al parametro Ledger {0} manca un attributo obbligatorio. + +LedgerParameterMissingType = Un parametro Ledger deve avere un tipo. + +LedgerParameterMissingValueKind = Un parametro Ledger deve dichiarare un tipo di valore. + +LedgerParameterReferenceValueMissingValueNode = Un valore di riferimento di parametro Ledger deve contenere un nodo valore. + +LedgerParameterUnsupportedValueKind = Il tipo di valore del parametro Ledger {0} non \u00E8 supportato. + +LedgerParameterValueKindRequiresStructuredValue = Il tipo di valore del parametro Ledger {0} richiede un valore strutturato. + +LedgerProtobufBooleanParameterMissingBooleanValue = Un parametro Ledger BOOLEAN deve contenere booleanValue. + +LedgerProtobufInvalidLedgerPersistenceState = I dati Protobuf Ledger salvati sono incoerenti:\n{0} + +LedgerProtobufLocalDateParameterMissingLocalDateValue = Un parametro Ledger LOCAL_DATE deve contenere localDateValue. + +LedgerRuntimeProjectionRestorerInvalidLedger = Il ripristino delle proiezioni runtime \u00E8 stato saltato perch\u00E9 i dati Ledger salvati non sono validi. I dati Ledger non sono stati modificati. Diagnostica:\n{0} + +LedgerRuntimeProjectionRestorerPartiallyRestored = Le proiezioni runtime sono state ripristinate solo per voci Ledger valide. Rimosse {0} vecchie proiezioni e ricreate {1} proiezioni valide. Voci Ledger non valide saltate: {2}. Proprietari interessati: {3}. UUID di proiezione mancanti: {4}. UUID di proiezione duplicati: {5}. UUID di proiezione obsoleti: {6}. I dati Ledger salvati non sono stati modificati. Diagnostica:\n{7} + +LedgerRuntimeProjectionRestorerRestored = Le proiezioni runtime sono state ripristinate da dati Ledger validi. Rimosse {0} vecchie proiezioni e ricreate {1} proiezioni. Proprietari interessati: {2}. UUID di proiezione mancanti: {3}. UUID di proiezione duplicati: {4}. UUID di proiezione obsoleti: {5}. I dati Ledger salvati non sono stati modificati. + +LedgerStructuralValidatorAccountProjectionPortfolioNotAllowed = Una proiezione conto deve riferirsi solo a un conto; rimuovere il riferimento al conto titoli: {0} + +LedgerStructuralValidatorDividendSecurityRequired = Le voci dividendo devono riferirsi a una registrazione titolo: {0} + +LedgerStructuralValidatorDuplicateEntryUuid = Ogni voce Ledger deve avere un UUID univoco. Duplicato: {0} + +LedgerStructuralValidatorDuplicatePostingUuid = Ogni registrazione Ledger deve avere un UUID univoco. Duplicato: {0} + +LedgerStructuralValidatorDuplicateProjectionUuid = Ogni proiezione Ledger deve avere un UUID univoco. Duplicato: {0} + +LedgerStructuralValidatorEntryDateTimeRequired = La voce Ledger {0} deve avere data e ora. + +LedgerStructuralValidatorEntryTypeRequired = La voce Ledger {0} deve avere un tipo di voce. + +LedgerStructuralValidatorEntryUuidRequired = Ogni voce Ledger deve avere un UUID. + +LedgerStructuralValidatorExDateSecurityRequired = EX_DATE pu\u00F2 essere usato solo su registrazioni che riferiscono un titolo: {0} + +LedgerStructuralValidatorLedgerRequired = \u00C8 richiesto un oggetto Ledger. + +LedgerStructuralValidatorParameterCodeNotAllowed = Il parametro Ledger per {0} deve usare uno dei codici configurati. + +LedgerStructuralValidatorParameterMustUseValueKind = {0} deve usare il tipo di valore {1}. + +LedgerStructuralValidatorParameterTypeRequired = Il parametro Ledger {0} deve avere un tipo di parametro. + +LedgerStructuralValidatorParameterValueKindMismatch = Il valore del parametro Ledger deve corrispondere al tipo di valore {0}. + +LedgerStructuralValidatorParameterValueKindRequired = Il parametro Ledger {0} deve dichiarare un tipo di valore. + +LedgerStructuralValidatorParameterValueRequired = Il parametro Ledger {0} deve contenere un valore. + +LedgerStructuralValidatorPortfolioProjectionAccountNotAllowed = Una proiezione conto titoli deve riferirsi solo a un conto titoli; rimuovere il riferimento al conto: {0} + +LedgerStructuralValidatorPostingCurrencyRequired = La registrazione Ledger {0} deve avere una valuta. + +LedgerStructuralValidatorPostingExchangeRatePositive = La registrazione Ledger {0} deve avere un tasso di cambio maggiore di zero. + +LedgerStructuralValidatorPostingGroupRefNotFound = Il riferimento al gruppo di registrazioni {0} deve puntare a una registrazione nella stessa voce. + +LedgerStructuralValidatorPostingSecurityRequired = La registrazione titolo {0} deve riferirsi a un titolo. + +LedgerStructuralValidatorPostingTypeRequired = La registrazione Ledger {0} deve avere un tipo di registrazione. + +LedgerStructuralValidatorPostingUuidRequired = La voce Ledger {0} contiene una registrazione senza UUID. + +LedgerStructuralValidatorPrimaryPostingRefNotFound = Il riferimento alla registrazione principale {0} deve puntare a una registrazione nella stessa voce. + +LedgerStructuralValidatorProjectionAccountRequired = La proiezione Ledger {0} deve riferirsi a un conto. + +LedgerStructuralValidatorProjectionGroupTargetConflict = Una proiezione deve usare riferimenti di appartenenza oppure un solo riferimento al gruppo di registrazioni, non entrambi. + +LedgerStructuralValidatorProjectionMembershipRefNotFound = Il riferimento di appartenenza della proiezione {0} deve puntare a una registrazione nella stessa voce. + +LedgerStructuralValidatorProjectionPortfolioRequired = La proiezione Ledger {0} deve riferirsi a un conto titoli. + +LedgerStructuralValidatorProjectionPrimaryTargetConflict = Una proiezione deve usare riferimenti di appartenenza oppure un solo riferimento alla registrazione principale, non entrambi. + +LedgerStructuralValidatorProjectionRoleNotAllowed = Il ruolo di proiezione {0} non \u00E8 valido per il tipo di voce {1}. + +LedgerStructuralValidatorProjectionRoleRequired = La proiezione Ledger {0} deve avere un ruolo di proiezione. + +LedgerStructuralValidatorProjectionRoleRequiredForType = Il tipo di voce {0} deve avere esattamente una proiezione con il ruolo {1}. + +LedgerStructuralValidatorProjectionUuidRequired = La voce Ledger {0} contiene una proiezione senza UUID. + +LedgerStructuralValidatorSignedFactsNotAllowed = Il tipo di voce {0} accetta solo valori di registrazione positivi. Usare importi e azioni positivi. + +LedgerStructuralValidatorTargetingRefRequired = Le proiezioni Ledger mirate devono puntare a una registrazione principale. + +LedgerXmlInvalidLedgerStructure = La struttura Ledger non \u00E8 valida:\n{0} + MsgAlphaVantageAPIKeyMissing = Manca la chiave API di Alpha Vantage. Configurala in preferenze. MsgCheckConfiguredAndCalculatedGrossValueDoNotMatch = Valore lordo importato ({0}) e valore lordo calcolato ({1}) non corrispondono diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_nl.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_nl.properties index f3c4ecb167..6266fc8370 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_nl.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_nl.properties @@ -472,6 +472,148 @@ LabelYahooFinance = Yahoo Finance LabelYahooFinanceAdjustedClose = Yahoo Finance (Aangepaste sluitkoers) +LedgerDiagnosticMessageFormatterAccount = Rekening + +LedgerDiagnosticMessageFormatterAmount = Bedrag + +LedgerDiagnosticMessageFormatterContextUnavailable = niet beschikbaar + +LedgerDiagnosticMessageFormatterDate = Datum + +LedgerDiagnosticMessageFormatterIsin = ISIN + +LedgerDiagnosticMessageFormatterNote = Notitie + +LedgerDiagnosticMessageFormatterPortfolio = Effectenrekening + +LedgerDiagnosticMessageFormatterSecurity = Effect + +LedgerDiagnosticMessageFormatterShares = Aantal + +LedgerDiagnosticMessageFormatterSource = Bron + +LedgerDiagnosticMessageFormatterTicker = Ticker + +LedgerDiagnosticMessageFormatterTransactionContext = Transactiedetails + +LedgerDiagnosticMessageFormatterType = Type + +LedgerDiagnosticMessageFormatterUnnamedAccount = naamloze rekening + +LedgerDiagnosticMessageFormatterUnnamedPortfolio = naamloze effectenrekening + +LedgerDiagnosticMessageFormatterUnnamedSecurity = naamloos effect + +LedgerDiagnosticMessageFormatterWkn = WKN + +LedgerInsertActionGeneratedBuyMissingBuySellEntry = De gegenereerde aankooptransactie wordt door de Ledger beheerd, maar de gekoppelde koop/verkoop-invoer is niet gevonden. + +LedgerInsertActionGeneratedBuyTypeMismatch = Deze gegenereerde Ledger-aankoop kan alleen worden bijgewerkt vanuit een ge\u00EFmporteerde aankooptransactie. + +LedgerInsertActionGeneratedDeliveryOwnerNotFound = De eigenaar van de gegenereerde Ledger-levering is niet gevonden. + +LedgerInsertActionGeneratedDeliveryTypeMismatch = Deze gegenereerde Ledger-levering kan alleen worden bijgewerkt vanuit een ge\u00EFmporteerde aankooptransactie. + +LedgerInsertActionInvestmentPlanLegacySettersNotSupported = Door de Ledger beheerde beleggingsplantransacties kunnen niet met oude import-setters worden bijgewerkt. Gebruik het Ledger-updatepad. + +LedgerInsertActionUnsupportedInvestmentPlanTransactionUpdateType = Update-type {0} voor beleggingsplannen wordt niet ondersteund voor door de Ledger beheerde transacties. + +LedgerParameterMissingAttribute = Ledger-parameter {0} mist een verplicht attribuut. + +LedgerParameterMissingType = Een Ledger-parameter moet een type hebben. + +LedgerParameterMissingValueKind = Een Ledger-parameter moet een waardetype declareren. + +LedgerParameterReferenceValueMissingValueNode = Een referentiewaarde van een Ledger-parameter moet een waarde-node bevatten. + +LedgerParameterUnsupportedValueKind = Ledger-parameterwaardetype {0} wordt niet ondersteund. + +LedgerParameterValueKindRequiresStructuredValue = Ledger-parameterwaardetype {0} vereist een gestructureerde waarde. + +LedgerProtobufBooleanParameterMissingBooleanValue = Een BOOLEAN Ledger-parameter moet booleanValue bevatten. + +LedgerProtobufInvalidLedgerPersistenceState = De opgeslagen Ledger-Protobuf-gegevens zijn inconsistent:\n{0} + +LedgerProtobufLocalDateParameterMissingLocalDateValue = Een LOCAL_DATE Ledger-parameter moet localDateValue bevatten. + +LedgerRuntimeProjectionRestorerInvalidLedger = Het herstellen van runtimeprojecties is overgeslagen omdat de opgeslagen Ledger-gegevens ongeldig zijn. De Ledger-gegevens zijn niet gewijzigd. Diagnose:\n{0} + +LedgerRuntimeProjectionRestorerPartiallyRestored = Runtimeprojecties zijn alleen voor geldige Ledger-boekingen hersteld. {0} oude runtimeprojecties verwijderd en {1} geldige projecties opnieuw gemaakt. Ongeldige Ledger-boekingen overgeslagen: {2}. Betrokken eigenaren: {3}. Ontbrekende projectie-UUIDs: {4}. Dubbele projectie-UUIDs: {5}. Verouderde projectie-UUIDs: {6}. De opgeslagen Ledger-gegevens zijn niet gewijzigd. Diagnose:\n{7} + +LedgerRuntimeProjectionRestorerRestored = Runtimeprojecties zijn hersteld uit geldige Ledger-gegevens. {0} oude runtimeprojecties verwijderd en {1} projecties opnieuw gemaakt. Betrokken eigenaren: {2}. Ontbrekende projectie-UUIDs: {3}. Dubbele projectie-UUIDs: {4}. Verouderde projectie-UUIDs: {5}. De opgeslagen Ledger-gegevens zijn niet gewijzigd. + +LedgerStructuralValidatorAccountProjectionPortfolioNotAllowed = Een rekeningprojectie mag alleen naar een rekening verwijzen; verwijder de verwijzing naar de effectenrekening: {0} + +LedgerStructuralValidatorDividendSecurityRequired = Dividendboekingen moeten naar een effectposting verwijzen: {0} + +LedgerStructuralValidatorDuplicateEntryUuid = Elke Ledger-boeking moet een unieke UUID hebben. Duplicaat: {0} + +LedgerStructuralValidatorDuplicatePostingUuid = Elke Ledger-posting moet een unieke UUID hebben. Duplicaat: {0} + +LedgerStructuralValidatorDuplicateProjectionUuid = Elke Ledger-projectie moet een unieke UUID hebben. Duplicaat: {0} + +LedgerStructuralValidatorEntryDateTimeRequired = Ledger-boeking {0} moet een datum en tijd hebben. + +LedgerStructuralValidatorEntryTypeRequired = Ledger-boeking {0} moet een boekingstype hebben. + +LedgerStructuralValidatorEntryUuidRequired = Elke Ledger-boeking moet een UUID hebben. + +LedgerStructuralValidatorExDateSecurityRequired = EX_DATE kan alleen worden gebruikt op postings die naar een effect verwijzen: {0} + +LedgerStructuralValidatorLedgerRequired = Een Ledger-object is vereist. + +LedgerStructuralValidatorParameterCodeNotAllowed = De Ledger-parameter voor {0} moet een van de geconfigureerde codes gebruiken. + +LedgerStructuralValidatorParameterMustUseValueKind = {0} moet waardetype {1} gebruiken. + +LedgerStructuralValidatorParameterTypeRequired = Ledger-parameter {0} moet een parametertype hebben. + +LedgerStructuralValidatorParameterValueKindMismatch = De Ledger-parameterwaarde moet overeenkomen met waardetype {0}. + +LedgerStructuralValidatorParameterValueKindRequired = Ledger-parameter {0} moet een waardetype declareren. + +LedgerStructuralValidatorParameterValueRequired = Ledger-parameter {0} moet een waarde bevatten. + +LedgerStructuralValidatorPortfolioProjectionAccountNotAllowed = Een effectenrekeningprojectie mag alleen naar een effectenrekening verwijzen; verwijder de rekeningverwijzing: {0} + +LedgerStructuralValidatorPostingCurrencyRequired = Ledger-posting {0} moet een valuta hebben. + +LedgerStructuralValidatorPostingExchangeRatePositive = Ledger-posting {0} moet een wisselkoers groter dan nul hebben. + +LedgerStructuralValidatorPostingGroupRefNotFound = Postinggroepverwijzing {0} moet naar een posting in dezelfde boeking wijzen. + +LedgerStructuralValidatorPostingSecurityRequired = Effectposting {0} moet naar een effect verwijzen. + +LedgerStructuralValidatorPostingTypeRequired = Ledger-posting {0} moet een postingtype hebben. + +LedgerStructuralValidatorPostingUuidRequired = Ledger-boeking {0} bevat een posting zonder UUID. + +LedgerStructuralValidatorPrimaryPostingRefNotFound = Primaire postingverwijzing {0} moet naar een posting in dezelfde boeking wijzen. + +LedgerStructuralValidatorProjectionAccountRequired = Ledger-projectie {0} moet naar een rekening verwijzen. + +LedgerStructuralValidatorProjectionGroupTargetConflict = Een projectie moet lidmaatschapsverwijzingen of \u00E9\u00E9n postinggroepverwijzing gebruiken, niet beide. + +LedgerStructuralValidatorProjectionMembershipRefNotFound = Projectielidmaatschapsverwijzing {0} moet naar een posting in dezelfde boeking wijzen. + +LedgerStructuralValidatorProjectionPortfolioRequired = Ledger-projectie {0} moet naar een effectenrekening verwijzen. + +LedgerStructuralValidatorProjectionPrimaryTargetConflict = Een projectie moet lidmaatschapsverwijzingen of \u00E9\u00E9n primaire postingverwijzing gebruiken, niet beide. + +LedgerStructuralValidatorProjectionRoleNotAllowed = Projectierol {0} is niet geldig voor boekingstype {1}. + +LedgerStructuralValidatorProjectionRoleRequired = Ledger-projectie {0} moet een projectierol hebben. + +LedgerStructuralValidatorProjectionRoleRequiredForType = Boekingstype {0} moet exact \u00E9\u00E9n projectie met rol {1} hebben. + +LedgerStructuralValidatorProjectionUuidRequired = Ledger-boeking {0} bevat een projectie zonder UUID. + +LedgerStructuralValidatorSignedFactsNotAllowed = Boekingstype {0} accepteert alleen positieve postingwaarden. Gebruik positieve bedragen en aantallen. + +LedgerStructuralValidatorTargetingRefRequired = Gerichte Ledger-projecties moeten naar een primaire posting wijzen. + +LedgerXmlInvalidLedgerStructure = De Ledger-structuur is ongeldig:\n{0} + MsgAlphaVantageAPIKeyMissing = De Alpha Vantage API-sleutel ontbreekt. Configureer deze in Help/Instellingen/API-sleutels. MsgCheckConfiguredAndCalculatedGrossValueDoNotMatch = Ge\u00EFmporteerde brutowaarde ({0}) en berekende brutowaarde ({1}) komen niet overeen diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_pl.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_pl.properties index ce98763fff..f2f9d9f208 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_pl.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_pl.properties @@ -470,6 +470,148 @@ LabelYahooFinance = Yahoo Finance LabelYahooFinanceAdjustedClose = Yahoo Finance (zamkni\u0119cie po korekcie) +LedgerDiagnosticMessageFormatterAccount = Konto + +LedgerDiagnosticMessageFormatterAmount = Kwota + +LedgerDiagnosticMessageFormatterContextUnavailable = niedost\u0119pne + +LedgerDiagnosticMessageFormatterDate = Data + +LedgerDiagnosticMessageFormatterIsin = ISIN + +LedgerDiagnosticMessageFormatterNote = Notatka + +LedgerDiagnosticMessageFormatterPortfolio = Konto walor\u00F3w + +LedgerDiagnosticMessageFormatterSecurity = Walor + +LedgerDiagnosticMessageFormatterShares = Udzia\u0142y + +LedgerDiagnosticMessageFormatterSource = \u0179r\u00F3d\u0142o + +LedgerDiagnosticMessageFormatterTicker = Ticker + +LedgerDiagnosticMessageFormatterTransactionContext = Szczeg\u00F3\u0142y transakcji + +LedgerDiagnosticMessageFormatterType = Typ + +LedgerDiagnosticMessageFormatterUnnamedAccount = konto bez nazwy + +LedgerDiagnosticMessageFormatterUnnamedPortfolio = konto walor\u00F3w bez nazwy + +LedgerDiagnosticMessageFormatterUnnamedSecurity = walor bez nazwy + +LedgerDiagnosticMessageFormatterWkn = WKN + +LedgerInsertActionGeneratedBuyMissingBuySellEntry = Wygenerowana transakcja zakupu jest zarz\u0105dzana przez Ledger, ale nie znaleziono powi\u0105zanego wpisu kupna/sprzeda\u017Cy. + +LedgerInsertActionGeneratedBuyTypeMismatch = Ten wygenerowany zakup Ledger mo\u017Cna zaktualizowa\u0107 tylko z importowanej transakcji zakupu. + +LedgerInsertActionGeneratedDeliveryOwnerNotFound = Nie znaleziono w\u0142a\u015Bciciela wygenerowanej dostawy Ledger. + +LedgerInsertActionGeneratedDeliveryTypeMismatch = T\u0119 wygenerowan\u0105 dostaw\u0119 Ledger mo\u017Cna zaktualizowa\u0107 tylko z importowanej transakcji zakupu. + +LedgerInsertActionInvestmentPlanLegacySettersNotSupported = Transakcji planu inwestycyjnego zarz\u0105dzanych przez Ledger nie mo\u017Cna aktualizowa\u0107 starymi setterami importu. U\u017Cyj \u015Bcie\u017Cki aktualizacji Ledger. + +LedgerInsertActionUnsupportedInvestmentPlanTransactionUpdateType = Typ aktualizacji planu inwestycyjnego {0} nie jest obs\u0142ugiwany dla transakcji zarz\u0105dzanych przez Ledger. + +LedgerParameterMissingAttribute = Parametrowi Ledger {0} brakuje wymaganego atrybutu. + +LedgerParameterMissingType = Parametr Ledger musi mie\u0107 typ. + +LedgerParameterMissingValueKind = Parametr Ledger musi deklarowa\u0107 rodzaj warto\u015Bci. + +LedgerParameterReferenceValueMissingValueNode = Warto\u015B\u0107 referencyjna parametru Ledger musi zawiera\u0107 w\u0119ze\u0142 warto\u015Bci. + +LedgerParameterUnsupportedValueKind = Rodzaj warto\u015Bci parametru Ledger {0} nie jest obs\u0142ugiwany. + +LedgerParameterValueKindRequiresStructuredValue = Rodzaj warto\u015Bci parametru Ledger {0} wymaga warto\u015Bci strukturalnej. + +LedgerProtobufBooleanParameterMissingBooleanValue = Parametr Ledger BOOLEAN musi zawiera\u0107 booleanValue. + +LedgerProtobufInvalidLedgerPersistenceState = Zapisane dane Protobuf Ledger s\u0105 niesp\u00F3jne:\n{0} + +LedgerProtobufLocalDateParameterMissingLocalDateValue = Parametr Ledger LOCAL_DATE musi zawiera\u0107 localDateValue. + +LedgerRuntimeProjectionRestorerInvalidLedger = Pomini\u0119to odtwarzanie projekcji uruchomieniowych, poniewa\u017C zapisane dane Ledger s\u0105 nieprawid\u0142owe. Dane Ledger nie zosta\u0142y zmienione. Diagnostyka:\n{0} + +LedgerRuntimeProjectionRestorerPartiallyRestored = Projekcje uruchomieniowe odtworzono tylko dla prawid\u0142owych wpis\u00F3w Ledger. Usuni\u0119to {0} starych projekcji i odtworzono {1} prawid\u0142owych projekcji. Pomini\u0119te nieprawid\u0142owe wpisy Ledger: {2}. Dotkni\u0119ci w\u0142a\u015Bciciele: {3}. Brakuj\u0105ce UUID projekcji: {4}. Zduplikowane UUID projekcji: {5}. Nieaktualne UUID projekcji: {6}. Zapisane dane Ledger nie zosta\u0142y zmienione. Diagnostyka:\n{7} + +LedgerRuntimeProjectionRestorerRestored = Projekcje uruchomieniowe odtworzono z prawid\u0142owych danych Ledger. Usuni\u0119to {0} starych projekcji i odtworzono {1} projekcji. Dotkni\u0119ci w\u0142a\u015Bciciele: {2}. Brakuj\u0105ce UUID projekcji: {3}. Zduplikowane UUID projekcji: {4}. Nieaktualne UUID projekcji: {5}. Zapisane dane Ledger nie zosta\u0142y zmienione. + +LedgerStructuralValidatorAccountProjectionPortfolioNotAllowed = Projekcja konta mo\u017Ce odwo\u0142ywa\u0107 si\u0119 tylko do konta; usu\u0144 odniesienie do konta walor\u00F3w: {0} + +LedgerStructuralValidatorDividendSecurityRequired = Wpisy dywidendy musz\u0105 odwo\u0142ywa\u0107 si\u0119 do ksi\u0119gowania waloru: {0} + +LedgerStructuralValidatorDuplicateEntryUuid = Ka\u017Cdy wpis Ledger musi mie\u0107 unikalny UUID. Duplikat: {0} + +LedgerStructuralValidatorDuplicatePostingUuid = Ka\u017Cde ksi\u0119gowanie Ledger musi mie\u0107 unikalny UUID. Duplikat: {0} + +LedgerStructuralValidatorDuplicateProjectionUuid = Ka\u017Cda projekcja Ledger musi mie\u0107 unikalny UUID. Duplikat: {0} + +LedgerStructuralValidatorEntryDateTimeRequired = Wpis Ledger {0} musi mie\u0107 dat\u0119 i czas. + +LedgerStructuralValidatorEntryTypeRequired = Wpis Ledger {0} musi mie\u0107 typ wpisu. + +LedgerStructuralValidatorEntryUuidRequired = Ka\u017Cdy wpis Ledger musi mie\u0107 UUID. + +LedgerStructuralValidatorExDateSecurityRequired = EX_DATE mo\u017Cna u\u017Cywa\u0107 tylko w ksi\u0119gowaniach odwo\u0142uj\u0105cych si\u0119 do waloru: {0} + +LedgerStructuralValidatorLedgerRequired = Wymagany jest obiekt Ledger. + +LedgerStructuralValidatorParameterCodeNotAllowed = Parametr Ledger dla {0} musi u\u017Cywa\u0107 jednego ze skonfigurowanych kod\u00F3w. + +LedgerStructuralValidatorParameterMustUseValueKind = {0} musi u\u017Cywa\u0107 rodzaju warto\u015Bci {1}. + +LedgerStructuralValidatorParameterTypeRequired = Parametr Ledger {0} musi mie\u0107 typ parametru. + +LedgerStructuralValidatorParameterValueKindMismatch = Warto\u015B\u0107 parametru Ledger musi odpowiada\u0107 rodzajowi warto\u015Bci {0}. + +LedgerStructuralValidatorParameterValueKindRequired = Parametr Ledger {0} musi deklarowa\u0107 rodzaj warto\u015Bci. + +LedgerStructuralValidatorParameterValueRequired = Parametr Ledger {0} musi zawiera\u0107 warto\u015B\u0107. + +LedgerStructuralValidatorPortfolioProjectionAccountNotAllowed = Projekcja konta walor\u00F3w mo\u017Ce odwo\u0142ywa\u0107 si\u0119 tylko do konta walor\u00F3w; usu\u0144 odniesienie do konta: {0} + +LedgerStructuralValidatorPostingCurrencyRequired = Ksi\u0119gowanie Ledger {0} musi mie\u0107 walut\u0119. + +LedgerStructuralValidatorPostingExchangeRatePositive = Ksi\u0119gowanie Ledger {0} musi mie\u0107 kurs wymiany wi\u0119kszy od zera. + +LedgerStructuralValidatorPostingGroupRefNotFound = Odniesienie do grupy ksi\u0119gowa\u0144 {0} musi wskazywa\u0107 ksi\u0119gowanie w tym samym wpisie. + +LedgerStructuralValidatorPostingSecurityRequired = Ksi\u0119gowanie waloru {0} musi odwo\u0142ywa\u0107 si\u0119 do waloru. + +LedgerStructuralValidatorPostingTypeRequired = Ksi\u0119gowanie Ledger {0} musi mie\u0107 typ ksi\u0119gowania. + +LedgerStructuralValidatorPostingUuidRequired = Wpis Ledger {0} zawiera ksi\u0119gowanie bez UUID. + +LedgerStructuralValidatorPrimaryPostingRefNotFound = Odniesienie do g\u0142\u00F3wnego ksi\u0119gowania {0} musi wskazywa\u0107 ksi\u0119gowanie w tym samym wpisie. + +LedgerStructuralValidatorProjectionAccountRequired = Projekcja Ledger {0} musi odwo\u0142ywa\u0107 si\u0119 do konta. + +LedgerStructuralValidatorProjectionGroupTargetConflict = Projekcja musi u\u017Cywa\u0107 albo odniesie\u0144 cz\u0142onkostwa, albo jednego odniesienia do grupy ksi\u0119gowa\u0144, nie obu. + +LedgerStructuralValidatorProjectionMembershipRefNotFound = Odniesienie cz\u0142onkostwa projekcji {0} musi wskazywa\u0107 ksi\u0119gowanie w tym samym wpisie. + +LedgerStructuralValidatorProjectionPortfolioRequired = Projekcja Ledger {0} musi odwo\u0142ywa\u0107 si\u0119 do konta walor\u00F3w. + +LedgerStructuralValidatorProjectionPrimaryTargetConflict = Projekcja musi u\u017Cywa\u0107 albo odniesie\u0144 cz\u0142onkostwa, albo jednego odniesienia do g\u0142\u00F3wnego ksi\u0119gowania, nie obu. + +LedgerStructuralValidatorProjectionRoleNotAllowed = Rola projekcji {0} nie jest prawid\u0142owa dla typu wpisu {1}. + +LedgerStructuralValidatorProjectionRoleRequired = Projekcja Ledger {0} musi mie\u0107 rol\u0119 projekcji. + +LedgerStructuralValidatorProjectionRoleRequiredForType = Typ wpisu {0} musi mie\u0107 dok\u0142adnie jedn\u0105 projekcj\u0119 z rol\u0105 {1}. + +LedgerStructuralValidatorProjectionUuidRequired = Wpis Ledger {0} zawiera projekcj\u0119 bez UUID. + +LedgerStructuralValidatorSignedFactsNotAllowed = Typ wpisu {0} akceptuje tylko dodatnie warto\u015Bci ksi\u0119gowania. U\u017Cyj dodatnich kwot i liczby udzia\u0142\u00F3w. + +LedgerStructuralValidatorTargetingRefRequired = Celowane projekcje Ledger musz\u0105 wskazywa\u0107 g\u0142\u00F3wne ksi\u0119gowanie. + +LedgerXmlInvalidLedgerStructure = Struktura Ledger jest nieprawid\u0142owa:\n{0} + MsgAlphaVantageAPIKeyMissing = Brak klucza API Alpha Vantage. Skonfiguruj w preferencjach. MsgCheckConfiguredAndCalculatedGrossValueDoNotMatch = Zaimportowana warto\u015B\u0107 brutto ({0}) i wyliczona warto\u015B\u0107 brutto ({1}) nie zgadzaj\u0105 si\u0119 diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_pt.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_pt.properties index d385cb012a..0aad87f357 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_pt.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_pt.properties @@ -456,6 +456,148 @@ LabelYahooFinance = Yahoo Finance LabelYahooFinanceAdjustedClose = Yahoo Finance (pre\u00E7o de fecho ajustado) +LedgerDiagnosticMessageFormatterAccount = Conta + +LedgerDiagnosticMessageFormatterAmount = Valor + +LedgerDiagnosticMessageFormatterContextUnavailable = n\u00E3o dispon\u00EDvel + +LedgerDiagnosticMessageFormatterDate = Data + +LedgerDiagnosticMessageFormatterIsin = ISIN + +LedgerDiagnosticMessageFormatterNote = Nota + +LedgerDiagnosticMessageFormatterPortfolio = Conta de t\u00EDtulos + +LedgerDiagnosticMessageFormatterSecurity = Ativo + +LedgerDiagnosticMessageFormatterShares = Quantidade + +LedgerDiagnosticMessageFormatterSource = Fonte + +LedgerDiagnosticMessageFormatterTicker = Ticker + +LedgerDiagnosticMessageFormatterTransactionContext = Detalhes da transa\u00E7\u00E3o + +LedgerDiagnosticMessageFormatterType = Tipo + +LedgerDiagnosticMessageFormatterUnnamedAccount = conta sem nome + +LedgerDiagnosticMessageFormatterUnnamedPortfolio = conta de t\u00EDtulos sem nome + +LedgerDiagnosticMessageFormatterUnnamedSecurity = ativo sem nome + +LedgerDiagnosticMessageFormatterWkn = WKN + +LedgerInsertActionGeneratedBuyMissingBuySellEntry = A transa\u00E7\u00E3o de compra gerada \u00E9 gerida pelo Ledger, mas a entrada de compra/venda associada n\u00E3o foi encontrada. + +LedgerInsertActionGeneratedBuyTypeMismatch = Esta compra Ledger gerada s\u00F3 pode ser atualizada a partir de uma transa\u00E7\u00E3o de compra importada. + +LedgerInsertActionGeneratedDeliveryOwnerNotFound = O propriet\u00E1rio da entrega Ledger gerada n\u00E3o foi encontrado. + +LedgerInsertActionGeneratedDeliveryTypeMismatch = Esta entrega Ledger gerada s\u00F3 pode ser atualizada a partir de uma transa\u00E7\u00E3o de compra importada. + +LedgerInsertActionInvestmentPlanLegacySettersNotSupported = As transa\u00E7\u00F5es de planos de investimento geridas pelo Ledger n\u00E3o podem ser atualizadas com setters de importa\u00E7\u00E3o antigos. Use o caminho de atualiza\u00E7\u00E3o Ledger. + +LedgerInsertActionUnsupportedInvestmentPlanTransactionUpdateType = O tipo de atualiza\u00E7\u00E3o de plano de investimento {0} n\u00E3o \u00E9 suportado para transa\u00E7\u00F5es geridas pelo Ledger. + +LedgerParameterMissingAttribute = Falta um atributo obrigat\u00F3rio ao par\u00E2metro Ledger {0}. + +LedgerParameterMissingType = Um par\u00E2metro Ledger deve ter um tipo. + +LedgerParameterMissingValueKind = Um par\u00E2metro Ledger deve declarar um tipo de valor. + +LedgerParameterReferenceValueMissingValueNode = Um valor de refer\u00EAncia de par\u00E2metro Ledger deve conter um n\u00F3 de valor. + +LedgerParameterUnsupportedValueKind = O tipo de valor de par\u00E2metro Ledger {0} n\u00E3o \u00E9 suportado. + +LedgerParameterValueKindRequiresStructuredValue = O tipo de valor de par\u00E2metro Ledger {0} requer um valor estruturado. + +LedgerProtobufBooleanParameterMissingBooleanValue = Um par\u00E2metro Ledger BOOLEAN deve conter booleanValue. + +LedgerProtobufInvalidLedgerPersistenceState = Os dados Protobuf Ledger guardados s\u00E3o inconsistentes:\n{0} + +LedgerProtobufLocalDateParameterMissingLocalDateValue = Um par\u00E2metro Ledger LOCAL_DATE deve conter localDateValue. + +LedgerRuntimeProjectionRestorerInvalidLedger = A restaura\u00E7\u00E3o das proje\u00E7\u00F5es de execu\u00E7\u00E3o foi ignorada porque os dados Ledger guardados s\u00E3o inv\u00E1lidos. Os dados Ledger n\u00E3o foram alterados. Diagn\u00F3stico:\n{0} + +LedgerRuntimeProjectionRestorerPartiallyRestored = As proje\u00E7\u00F5es de execu\u00E7\u00E3o foram restauradas apenas para entradas Ledger v\u00E1lidas. Foram removidas {0} proje\u00E7\u00F5es antigas e recriadas {1} proje\u00E7\u00F5es v\u00E1lidas. Entradas Ledger inv\u00E1lidas ignoradas: {2}. Propriet\u00E1rios afetados: {3}. UUIDs de proje\u00E7\u00E3o em falta: {4}. UUIDs de proje\u00E7\u00E3o duplicados: {5}. UUIDs de proje\u00E7\u00E3o obsoletos: {6}. Os dados Ledger guardados n\u00E3o foram alterados. Diagn\u00F3stico:\n{7} + +LedgerRuntimeProjectionRestorerRestored = As proje\u00E7\u00F5es de execu\u00E7\u00E3o foram restauradas a partir de dados Ledger v\u00E1lidos. Foram removidas {0} proje\u00E7\u00F5es antigas e recriadas {1} proje\u00E7\u00F5es. Propriet\u00E1rios afetados: {2}. UUIDs de proje\u00E7\u00E3o em falta: {3}. UUIDs de proje\u00E7\u00E3o duplicados: {4}. UUIDs de proje\u00E7\u00E3o obsoletos: {5}. Os dados Ledger guardados n\u00E3o foram alterados. + +LedgerStructuralValidatorAccountProjectionPortfolioNotAllowed = Uma proje\u00E7\u00E3o de conta deve referenciar apenas uma conta; remova a refer\u00EAncia \u00E0 conta de t\u00EDtulos: {0} + +LedgerStructuralValidatorDividendSecurityRequired = As entradas de dividendo devem referenciar um lan\u00E7amento de ativo: {0} + +LedgerStructuralValidatorDuplicateEntryUuid = Cada entrada Ledger deve ter um UUID \u00FAnico. Duplicado: {0} + +LedgerStructuralValidatorDuplicatePostingUuid = Cada lan\u00E7amento Ledger deve ter um UUID \u00FAnico. Duplicado: {0} + +LedgerStructuralValidatorDuplicateProjectionUuid = Cada proje\u00E7\u00E3o Ledger deve ter um UUID \u00FAnico. Duplicado: {0} + +LedgerStructuralValidatorEntryDateTimeRequired = A entrada Ledger {0} deve ter data e hora. + +LedgerStructuralValidatorEntryTypeRequired = A entrada Ledger {0} deve ter um tipo de entrada. + +LedgerStructuralValidatorEntryUuidRequired = Cada entrada Ledger deve ter um UUID. + +LedgerStructuralValidatorExDateSecurityRequired = EX_DATE s\u00F3 pode ser usado em lan\u00E7amentos que referenciam um ativo: {0} + +LedgerStructuralValidatorLedgerRequired = \u00C9 necess\u00E1rio um objeto Ledger. + +LedgerStructuralValidatorParameterCodeNotAllowed = O par\u00E2metro Ledger para {0} deve usar um dos c\u00F3digos configurados. + +LedgerStructuralValidatorParameterMustUseValueKind = {0} deve usar o tipo de valor {1}. + +LedgerStructuralValidatorParameterTypeRequired = O par\u00E2metro Ledger {0} deve ter um tipo de par\u00E2metro. + +LedgerStructuralValidatorParameterValueKindMismatch = O valor do par\u00E2metro Ledger deve corresponder ao tipo de valor {0}. + +LedgerStructuralValidatorParameterValueKindRequired = O par\u00E2metro Ledger {0} deve declarar um tipo de valor. + +LedgerStructuralValidatorParameterValueRequired = O par\u00E2metro Ledger {0} deve conter um valor. + +LedgerStructuralValidatorPortfolioProjectionAccountNotAllowed = Uma proje\u00E7\u00E3o de conta de t\u00EDtulos deve referenciar apenas uma conta de t\u00EDtulos; remova a refer\u00EAncia \u00E0 conta: {0} + +LedgerStructuralValidatorPostingCurrencyRequired = O lan\u00E7amento Ledger {0} deve ter uma moeda. + +LedgerStructuralValidatorPostingExchangeRatePositive = O lan\u00E7amento Ledger {0} deve ter uma taxa de c\u00E2mbio maior que zero. + +LedgerStructuralValidatorPostingGroupRefNotFound = A refer\u00EAncia de grupo de lan\u00E7amentos {0} deve apontar para um lan\u00E7amento na mesma entrada. + +LedgerStructuralValidatorPostingSecurityRequired = O lan\u00E7amento de ativo {0} deve referenciar um ativo. + +LedgerStructuralValidatorPostingTypeRequired = O lan\u00E7amento Ledger {0} deve ter um tipo de lan\u00E7amento. + +LedgerStructuralValidatorPostingUuidRequired = A entrada Ledger {0} cont\u00E9m um lan\u00E7amento sem UUID. + +LedgerStructuralValidatorPrimaryPostingRefNotFound = A refer\u00EAncia de lan\u00E7amento principal {0} deve apontar para um lan\u00E7amento na mesma entrada. + +LedgerStructuralValidatorProjectionAccountRequired = A proje\u00E7\u00E3o Ledger {0} deve referenciar uma conta. + +LedgerStructuralValidatorProjectionGroupTargetConflict = Uma proje\u00E7\u00E3o deve usar refer\u00EAncias de perten\u00E7a ou uma \u00FAnica refer\u00EAncia de grupo de lan\u00E7amentos, mas n\u00E3o ambas. + +LedgerStructuralValidatorProjectionMembershipRefNotFound = A refer\u00EAncia de perten\u00E7a da proje\u00E7\u00E3o {0} deve apontar para um lan\u00E7amento na mesma entrada. + +LedgerStructuralValidatorProjectionPortfolioRequired = A proje\u00E7\u00E3o Ledger {0} deve referenciar uma conta de t\u00EDtulos. + +LedgerStructuralValidatorProjectionPrimaryTargetConflict = Uma proje\u00E7\u00E3o deve usar refer\u00EAncias de perten\u00E7a ou uma \u00FAnica refer\u00EAncia de lan\u00E7amento principal, mas n\u00E3o ambas. + +LedgerStructuralValidatorProjectionRoleNotAllowed = A fun\u00E7\u00E3o de proje\u00E7\u00E3o {0} n\u00E3o \u00E9 v\u00E1lida para o tipo de entrada {1}. + +LedgerStructuralValidatorProjectionRoleRequired = A proje\u00E7\u00E3o Ledger {0} deve ter uma fun\u00E7\u00E3o de proje\u00E7\u00E3o. + +LedgerStructuralValidatorProjectionRoleRequiredForType = O tipo de entrada {0} deve ter exatamente uma proje\u00E7\u00E3o com a fun\u00E7\u00E3o {1}. + +LedgerStructuralValidatorProjectionUuidRequired = A entrada Ledger {0} cont\u00E9m uma proje\u00E7\u00E3o sem UUID. + +LedgerStructuralValidatorSignedFactsNotAllowed = O tipo de entrada {0} aceita apenas valores de lan\u00E7amento positivos. Use valores e quantidades positivos. + +LedgerStructuralValidatorTargetingRefRequired = As proje\u00E7\u00F5es Ledger direcionadas devem apontar para um lan\u00E7amento principal. + +LedgerXmlInvalidLedgerStructure = A estrutura Ledger \u00E9 inv\u00E1lida:\n{0} + MsgAlphaVantageAPIKeyMissing = A chave da API Alpha Vantage est\u00E1 ausente. Configure nas prefer\u00EAncias. MsgCheckConfiguredAndCalculatedGrossValueDoNotMatch = Valor bruto importado ({0}) e valor bruto calculado ({1}) n\u00E3o corresponde diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_pt_BR.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_pt_BR.properties index 9682ed7e68..dc76560eec 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_pt_BR.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_pt_BR.properties @@ -470,6 +470,148 @@ LabelYahooFinance = Yahoo Finance LabelYahooFinanceAdjustedClose = Yahoo Finance (pre\u00E7o de fechamento ajustado) +LedgerDiagnosticMessageFormatterAccount = Conta + +LedgerDiagnosticMessageFormatterAmount = Valor + +LedgerDiagnosticMessageFormatterContextUnavailable = n\u00E3o dispon\u00EDvel + +LedgerDiagnosticMessageFormatterDate = Data + +LedgerDiagnosticMessageFormatterIsin = ISIN + +LedgerDiagnosticMessageFormatterNote = Nota + +LedgerDiagnosticMessageFormatterPortfolio = Conta de ativos + +LedgerDiagnosticMessageFormatterSecurity = Ativo + +LedgerDiagnosticMessageFormatterShares = Quantidade + +LedgerDiagnosticMessageFormatterSource = Fonte + +LedgerDiagnosticMessageFormatterTicker = Ticker + +LedgerDiagnosticMessageFormatterTransactionContext = Detalhes da transa\u00E7\u00E3o + +LedgerDiagnosticMessageFormatterType = Tipo + +LedgerDiagnosticMessageFormatterUnnamedAccount = conta sem nome + +LedgerDiagnosticMessageFormatterUnnamedPortfolio = conta de ativos sem nome + +LedgerDiagnosticMessageFormatterUnnamedSecurity = ativo sem nome + +LedgerDiagnosticMessageFormatterWkn = WKN + +LedgerInsertActionGeneratedBuyMissingBuySellEntry = A transa\u00E7\u00E3o de compra gerada \u00E9 gerenciada pelo Ledger, mas a entrada de compra/venda associada n\u00E3o foi encontrada. + +LedgerInsertActionGeneratedBuyTypeMismatch = Esta compra Ledger gerada s\u00F3 pode ser atualizada a partir de uma transa\u00E7\u00E3o de compra importada. + +LedgerInsertActionGeneratedDeliveryOwnerNotFound = O propriet\u00E1rio da entrega Ledger gerada n\u00E3o foi encontrado. + +LedgerInsertActionGeneratedDeliveryTypeMismatch = Esta entrega Ledger gerada s\u00F3 pode ser atualizada a partir de uma transa\u00E7\u00E3o de compra importada. + +LedgerInsertActionInvestmentPlanLegacySettersNotSupported = As transa\u00E7\u00F5es de planos de investimento gerenciadas pelo Ledger n\u00E3o podem ser atualizadas com setters de importa\u00E7\u00E3o antigos. Use o caminho de atualiza\u00E7\u00E3o Ledger. + +LedgerInsertActionUnsupportedInvestmentPlanTransactionUpdateType = O tipo de atualiza\u00E7\u00E3o de plano de investimento {0} n\u00E3o \u00E9 suportado para transa\u00E7\u00F5es gerenciadas pelo Ledger. + +LedgerParameterMissingAttribute = Falta um atributo obrigat\u00F3rio ao par\u00E2metro Ledger {0}. + +LedgerParameterMissingType = Um par\u00E2metro Ledger deve ter um tipo. + +LedgerParameterMissingValueKind = Um par\u00E2metro Ledger deve declarar um tipo de valor. + +LedgerParameterReferenceValueMissingValueNode = Um valor de refer\u00EAncia de par\u00E2metro Ledger deve conter um n\u00F3 de valor. + +LedgerParameterUnsupportedValueKind = O tipo de valor de par\u00E2metro Ledger {0} n\u00E3o \u00E9 suportado. + +LedgerParameterValueKindRequiresStructuredValue = O tipo de valor de par\u00E2metro Ledger {0} requer um valor estruturado. + +LedgerProtobufBooleanParameterMissingBooleanValue = Um par\u00E2metro Ledger BOOLEAN deve conter booleanValue. + +LedgerProtobufInvalidLedgerPersistenceState = Os dados Protobuf Ledger salvos s\u00E3o inconsistentes:\n{0} + +LedgerProtobufLocalDateParameterMissingLocalDateValue = Um par\u00E2metro Ledger LOCAL_DATE deve conter localDateValue. + +LedgerRuntimeProjectionRestorerInvalidLedger = A restaura\u00E7\u00E3o das proje\u00E7\u00F5es de execu\u00E7\u00E3o foi ignorada porque os dados Ledger salvos s\u00E3o inv\u00E1lidos. Os dados Ledger n\u00E3o foram alterados. Diagn\u00F3stico:\n{0} + +LedgerRuntimeProjectionRestorerPartiallyRestored = As proje\u00E7\u00F5es de execu\u00E7\u00E3o foram restauradas apenas para entradas Ledger v\u00E1lidas. Foram removidas {0} proje\u00E7\u00F5es antigas e recriadas {1} proje\u00E7\u00F5es v\u00E1lidas. Entradas Ledger inv\u00E1lidas ignoradas: {2}. Propriet\u00E1rios afetados: {3}. UUIDs de proje\u00E7\u00E3o ausentes: {4}. UUIDs de proje\u00E7\u00E3o duplicados: {5}. UUIDs de proje\u00E7\u00E3o obsoletos: {6}. Os dados Ledger salvos n\u00E3o foram alterados. Diagn\u00F3stico:\n{7} + +LedgerRuntimeProjectionRestorerRestored = As proje\u00E7\u00F5es de execu\u00E7\u00E3o foram restauradas a partir de dados Ledger v\u00E1lidos. Foram removidas {0} proje\u00E7\u00F5es antigas e recriadas {1} proje\u00E7\u00F5es. Propriet\u00E1rios afetados: {2}. UUIDs de proje\u00E7\u00E3o ausentes: {3}. UUIDs de proje\u00E7\u00E3o duplicados: {4}. UUIDs de proje\u00E7\u00E3o obsoletos: {5}. Os dados Ledger salvos n\u00E3o foram alterados. + +LedgerStructuralValidatorAccountProjectionPortfolioNotAllowed = Uma proje\u00E7\u00E3o de conta deve referenciar apenas uma conta; remova a refer\u00EAncia \u00E0 conta de ativos: {0} + +LedgerStructuralValidatorDividendSecurityRequired = As entradas de dividendo devem referenciar um lan\u00E7amento de ativo: {0} + +LedgerStructuralValidatorDuplicateEntryUuid = Cada entrada Ledger deve ter um UUID \u00FAnico. Duplicado: {0} + +LedgerStructuralValidatorDuplicatePostingUuid = Cada lan\u00E7amento Ledger deve ter um UUID \u00FAnico. Duplicado: {0} + +LedgerStructuralValidatorDuplicateProjectionUuid = Cada proje\u00E7\u00E3o Ledger deve ter um UUID \u00FAnico. Duplicado: {0} + +LedgerStructuralValidatorEntryDateTimeRequired = A entrada Ledger {0} deve ter data e hora. + +LedgerStructuralValidatorEntryTypeRequired = A entrada Ledger {0} deve ter um tipo de entrada. + +LedgerStructuralValidatorEntryUuidRequired = Cada entrada Ledger deve ter um UUID. + +LedgerStructuralValidatorExDateSecurityRequired = EX_DATE s\u00F3 pode ser usado em lan\u00E7amentos que referenciam um ativo: {0} + +LedgerStructuralValidatorLedgerRequired = \u00C9 necess\u00E1rio um objeto Ledger. + +LedgerStructuralValidatorParameterCodeNotAllowed = O par\u00E2metro Ledger para {0} deve usar um dos c\u00F3digos configurados. + +LedgerStructuralValidatorParameterMustUseValueKind = {0} deve usar o tipo de valor {1}. + +LedgerStructuralValidatorParameterTypeRequired = O par\u00E2metro Ledger {0} deve ter um tipo de par\u00E2metro. + +LedgerStructuralValidatorParameterValueKindMismatch = O valor do par\u00E2metro Ledger deve corresponder ao tipo de valor {0}. + +LedgerStructuralValidatorParameterValueKindRequired = O par\u00E2metro Ledger {0} deve declarar um tipo de valor. + +LedgerStructuralValidatorParameterValueRequired = O par\u00E2metro Ledger {0} deve conter um valor. + +LedgerStructuralValidatorPortfolioProjectionAccountNotAllowed = Uma proje\u00E7\u00E3o de conta de ativos deve referenciar apenas uma conta de ativos; remova a refer\u00EAncia \u00E0 conta: {0} + +LedgerStructuralValidatorPostingCurrencyRequired = O lan\u00E7amento Ledger {0} deve ter uma moeda. + +LedgerStructuralValidatorPostingExchangeRatePositive = O lan\u00E7amento Ledger {0} deve ter uma taxa de c\u00E2mbio maior que zero. + +LedgerStructuralValidatorPostingGroupRefNotFound = A refer\u00EAncia de grupo de lan\u00E7amentos {0} deve apontar para um lan\u00E7amento na mesma entrada. + +LedgerStructuralValidatorPostingSecurityRequired = O lan\u00E7amento de ativo {0} deve referenciar um ativo. + +LedgerStructuralValidatorPostingTypeRequired = O lan\u00E7amento Ledger {0} deve ter um tipo de lan\u00E7amento. + +LedgerStructuralValidatorPostingUuidRequired = A entrada Ledger {0} cont\u00E9m um lan\u00E7amento sem UUID. + +LedgerStructuralValidatorPrimaryPostingRefNotFound = A refer\u00EAncia de lan\u00E7amento principal {0} deve apontar para um lan\u00E7amento na mesma entrada. + +LedgerStructuralValidatorProjectionAccountRequired = A proje\u00E7\u00E3o Ledger {0} deve referenciar uma conta. + +LedgerStructuralValidatorProjectionGroupTargetConflict = Uma proje\u00E7\u00E3o deve usar refer\u00EAncias de perten\u00E7a ou uma \u00FAnica refer\u00EAncia de grupo de lan\u00E7amentos, mas n\u00E3o ambas. + +LedgerStructuralValidatorProjectionMembershipRefNotFound = A refer\u00EAncia de perten\u00E7a da proje\u00E7\u00E3o {0} deve apontar para um lan\u00E7amento na mesma entrada. + +LedgerStructuralValidatorProjectionPortfolioRequired = A proje\u00E7\u00E3o Ledger {0} deve referenciar uma conta de ativos. + +LedgerStructuralValidatorProjectionPrimaryTargetConflict = Uma proje\u00E7\u00E3o deve usar refer\u00EAncias de perten\u00E7a ou uma \u00FAnica refer\u00EAncia de lan\u00E7amento principal, mas n\u00E3o ambas. + +LedgerStructuralValidatorProjectionRoleNotAllowed = A fun\u00E7\u00E3o de proje\u00E7\u00E3o {0} n\u00E3o \u00E9 v\u00E1lida para o tipo de entrada {1}. + +LedgerStructuralValidatorProjectionRoleRequired = A proje\u00E7\u00E3o Ledger {0} deve ter uma fun\u00E7\u00E3o de proje\u00E7\u00E3o. + +LedgerStructuralValidatorProjectionRoleRequiredForType = O tipo de entrada {0} deve ter exatamente uma proje\u00E7\u00E3o com a fun\u00E7\u00E3o {1}. + +LedgerStructuralValidatorProjectionUuidRequired = A entrada Ledger {0} cont\u00E9m uma proje\u00E7\u00E3o sem UUID. + +LedgerStructuralValidatorSignedFactsNotAllowed = O tipo de entrada {0} aceita apenas valores de lan\u00E7amento positivos. Use valores e quantidades positivos. + +LedgerStructuralValidatorTargetingRefRequired = As proje\u00E7\u00F5es Ledger direcionadas devem apontar para um lan\u00E7amento principal. + +LedgerXmlInvalidLedgerStructure = A estrutura Ledger \u00E9 inv\u00E1lida:\n{0} + MsgAlphaVantageAPIKeyMissing = A chave da API Alpha Vantage est\u00E1 ausente. Configure nas prefer\u00EAncias. MsgCheckConfiguredAndCalculatedGrossValueDoNotMatch = Valor bruto importado ({0}) e valor bruto calculado ({1}) n\u00E3o corresponde diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_ru.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_ru.properties index 33590573b0..0e67f28de9 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_ru.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_ru.properties @@ -470,6 +470,148 @@ LabelYahooFinance = Yahoo Finance LabelYahooFinanceAdjustedClose = Yahoo Finance (\u0421\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u043D\u043D\u043E\u0435 \u0417\u0430\u043A\u0440\u044B\u0442\u0438\u0435) +LedgerDiagnosticMessageFormatterAccount = \u0421\u0447\u0435\u0442 + +LedgerDiagnosticMessageFormatterAmount = \u0421\u0443\u043C\u043C\u0430 + +LedgerDiagnosticMessageFormatterContextUnavailable = \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u043D\u043E + +LedgerDiagnosticMessageFormatterDate = \u0414\u0430\u0442\u0430 + +LedgerDiagnosticMessageFormatterIsin = ISIN + +LedgerDiagnosticMessageFormatterNote = \u041F\u0440\u0438\u043C\u0435\u0447\u0430\u043D\u0438\u0435 + +LedgerDiagnosticMessageFormatterPortfolio = \u0421\u0447\u0435\u0442 \u0446\u0435\u043D\u043D\u044B\u0445 \u0431\u0443\u043C\u0430\u0433 + +LedgerDiagnosticMessageFormatterSecurity = \u0426\u0435\u043D\u043D\u0430\u044F \u0431\u0443\u043C\u0430\u0433\u0430 + +LedgerDiagnosticMessageFormatterShares = \u041A\u043E\u043B\u0438\u0447\u0435\u0441\u0442\u0432\u043E + +LedgerDiagnosticMessageFormatterSource = \u0418\u0441\u0442\u043E\u0447\u043D\u0438\u043A + +LedgerDiagnosticMessageFormatterTicker = \u0422\u0438\u043A\u0435\u0440 + +LedgerDiagnosticMessageFormatterTransactionContext = \u0414\u0435\u0442\u0430\u043B\u0438 \u043E\u043F\u0435\u0440\u0430\u0446\u0438\u0438 + +LedgerDiagnosticMessageFormatterType = \u0422\u0438\u043F + +LedgerDiagnosticMessageFormatterUnnamedAccount = \u0441\u0447\u0435\u0442 \u0431\u0435\u0437 \u0438\u043C\u0435\u043D\u0438 + +LedgerDiagnosticMessageFormatterUnnamedPortfolio = \u0441\u0447\u0435\u0442 \u0446\u0435\u043D\u043D\u044B\u0445 \u0431\u0443\u043C\u0430\u0433 \u0431\u0435\u0437 \u0438\u043C\u0435\u043D\u0438 + +LedgerDiagnosticMessageFormatterUnnamedSecurity = \u0446\u0435\u043D\u043D\u0430\u044F \u0431\u0443\u043C\u0430\u0433\u0430 \u0431\u0435\u0437 \u0438\u043C\u0435\u043D\u0438 + +LedgerDiagnosticMessageFormatterWkn = WKN + +LedgerInsertActionGeneratedBuyMissingBuySellEntry = \u0421\u0433\u0435\u043D\u0435\u0440\u0438\u0440\u043E\u0432\u0430\u043D\u043D\u0430\u044F \u043E\u043F\u0435\u0440\u0430\u0446\u0438\u044F \u043F\u043E\u043A\u0443\u043F\u043A\u0438 \u0443\u043F\u0440\u0430\u0432\u043B\u044F\u0435\u0442\u0441\u044F Ledger, \u043D\u043E \u0441\u0432\u044F\u0437\u0430\u043D\u043D\u0430\u044F \u0437\u0430\u043F\u0438\u0441\u044C \u043F\u043E\u043A\u0443\u043F\u043A\u0438/\u043F\u0440\u043E\u0434\u0430\u0436\u0438 \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u0430. + +LedgerInsertActionGeneratedBuyTypeMismatch = \u042D\u0442\u0430 \u0441\u0433\u0435\u043D\u0435\u0440\u0438\u0440\u043E\u0432\u0430\u043D\u043D\u0430\u044F \u043F\u043E\u043A\u0443\u043F\u043A\u0430 Ledger \u043C\u043E\u0436\u0435\u0442 \u0431\u044B\u0442\u044C \u043E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u0430 \u0442\u043E\u043B\u044C\u043A\u043E \u0438\u0437 \u0438\u043C\u043F\u043E\u0440\u0442\u0438\u0440\u043E\u0432\u0430\u043D\u043D\u043E\u0439 \u043E\u043F\u0435\u0440\u0430\u0446\u0438\u0438 \u043F\u043E\u043A\u0443\u043F\u043A\u0438. + +LedgerInsertActionGeneratedDeliveryOwnerNotFound = \u0412\u043B\u0430\u0434\u0435\u043B\u0435\u0446 \u0441\u0433\u0435\u043D\u0435\u0440\u0438\u0440\u043E\u0432\u0430\u043D\u043D\u043E\u0439 \u043F\u043E\u0441\u0442\u0430\u0432\u043A\u0438 Ledger \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D. + +LedgerInsertActionGeneratedDeliveryTypeMismatch = \u042D\u0442\u0430 \u0441\u0433\u0435\u043D\u0435\u0440\u0438\u0440\u043E\u0432\u0430\u043D\u043D\u0430\u044F \u043F\u043E\u0441\u0442\u0430\u0432\u043A\u0430 Ledger \u043C\u043E\u0436\u0435\u0442 \u0431\u044B\u0442\u044C \u043E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u0430 \u0442\u043E\u043B\u044C\u043A\u043E \u0438\u0437 \u0438\u043C\u043F\u043E\u0440\u0442\u0438\u0440\u043E\u0432\u0430\u043D\u043D\u043E\u0439 \u043E\u043F\u0435\u0440\u0430\u0446\u0438\u0438 \u043F\u043E\u043A\u0443\u043F\u043A\u0438. + +LedgerInsertActionInvestmentPlanLegacySettersNotSupported = \u041E\u043F\u0435\u0440\u0430\u0446\u0438\u0438 \u0438\u043D\u0432\u0435\u0441\u0442\u0438\u0446\u0438\u043E\u043D\u043D\u044B\u0445 \u043F\u043B\u0430\u043D\u043E\u0432, \u0443\u043F\u0440\u0430\u0432\u043B\u044F\u0435\u043C\u044B\u0435 Ledger, \u043D\u0435\u043B\u044C\u0437\u044F \u043E\u0431\u043D\u043E\u0432\u043B\u044F\u0442\u044C \u0441\u0442\u0430\u0440\u044B\u043C\u0438 import-setter. \u0418\u0441\u043F\u043E\u043B\u044C\u0437\u0443\u0439\u0442\u0435 \u043F\u0443\u0442\u044C \u043E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u0438\u044F Ledger. + +LedgerInsertActionUnsupportedInvestmentPlanTransactionUpdateType = \u0422\u0438\u043F \u043E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u0438\u044F \u0438\u043D\u0432\u0435\u0441\u0442\u0438\u0446\u0438\u043E\u043D\u043D\u043E\u0433\u043E \u043F\u043B\u0430\u043D\u0430 {0} \u043D\u0435 \u043F\u043E\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044F \u0434\u043B\u044F \u043E\u043F\u0435\u0440\u0430\u0446\u0438\u0439, \u0443\u043F\u0440\u0430\u0432\u043B\u044F\u0435\u043C\u044B\u0445 Ledger. + +LedgerParameterMissingAttribute = \u0423 \u043F\u0430\u0440\u0430\u043C\u0435\u0442\u0440\u0430 Ledger {0} \u043E\u0442\u0441\u0443\u0442\u0441\u0442\u0432\u0443\u0435\u0442 \u043E\u0431\u044F\u0437\u0430\u0442\u0435\u043B\u044C\u043D\u044B\u0439 \u0430\u0442\u0440\u0438\u0431\u0443\u0442. + +LedgerParameterMissingType = \u041F\u0430\u0440\u0430\u043C\u0435\u0442\u0440 Ledger \u0434\u043E\u043B\u0436\u0435\u043D \u0438\u043C\u0435\u0442\u044C \u0442\u0438\u043F. + +LedgerParameterMissingValueKind = \u041F\u0430\u0440\u0430\u043C\u0435\u0442\u0440 Ledger \u0434\u043E\u043B\u0436\u0435\u043D \u0443\u043A\u0430\u0437\u044B\u0432\u0430\u0442\u044C \u0442\u0438\u043F \u0437\u043D\u0430\u0447\u0435\u043D\u0438\u044F. + +LedgerParameterReferenceValueMissingValueNode = \u0421\u0441\u044B\u043B\u043E\u0447\u043D\u043E\u0435 \u0437\u043D\u0430\u0447\u0435\u043D\u0438\u0435 \u043F\u0430\u0440\u0430\u043C\u0435\u0442\u0440\u0430 Ledger \u0434\u043E\u043B\u0436\u043D\u043E \u0441\u043E\u0434\u0435\u0440\u0436\u0430\u0442\u044C \u0443\u0437\u0435\u043B \u0437\u043D\u0430\u0447\u0435\u043D\u0438\u044F. + +LedgerParameterUnsupportedValueKind = \u0422\u0438\u043F \u0437\u043D\u0430\u0447\u0435\u043D\u0438\u044F \u043F\u0430\u0440\u0430\u043C\u0435\u0442\u0440\u0430 Ledger {0} \u043D\u0435 \u043F\u043E\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044F. + +LedgerParameterValueKindRequiresStructuredValue = \u0422\u0438\u043F \u0437\u043D\u0430\u0447\u0435\u043D\u0438\u044F \u043F\u0430\u0440\u0430\u043C\u0435\u0442\u0440\u0430 Ledger {0} \u0442\u0440\u0435\u0431\u0443\u0435\u0442 \u0441\u0442\u0440\u0443\u043A\u0442\u0443\u0440\u0438\u0440\u043E\u0432\u0430\u043D\u043D\u043E\u0433\u043E \u0437\u043D\u0430\u0447\u0435\u043D\u0438\u044F. + +LedgerProtobufBooleanParameterMissingBooleanValue = \u041F\u0430\u0440\u0430\u043C\u0435\u0442\u0440 Ledger BOOLEAN \u0434\u043E\u043B\u0436\u0435\u043D \u0441\u043E\u0434\u0435\u0440\u0436\u0430\u0442\u044C booleanValue. + +LedgerProtobufInvalidLedgerPersistenceState = \u0421\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u043D\u044B\u0435 \u0434\u0430\u043D\u043D\u044B\u0435 Ledger Protobuf \u043D\u0435\u0441\u043E\u0433\u043B\u0430\u0441\u043E\u0432\u0430\u043D\u044B:\n{0} + +LedgerProtobufLocalDateParameterMissingLocalDateValue = \u041F\u0430\u0440\u0430\u043C\u0435\u0442\u0440 Ledger LOCAL_DATE \u0434\u043E\u043B\u0436\u0435\u043D \u0441\u043E\u0434\u0435\u0440\u0436\u0430\u0442\u044C localDateValue. + +LedgerRuntimeProjectionRestorerInvalidLedger = \u0412\u043E\u0441\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D\u0438\u0435 runtime-\u043F\u0440\u043E\u0435\u043A\u0446\u0438\u0439 \u043F\u0440\u043E\u043F\u0443\u0449\u0435\u043D\u043E, \u043F\u043E\u0442\u043E\u043C\u0443 \u0447\u0442\u043E \u0441\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u043D\u044B\u0435 \u0434\u0430\u043D\u043D\u044B\u0435 Ledger \u043D\u0435\u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0442\u0435\u043B\u044C\u043D\u044B. \u0414\u0430\u043D\u043D\u044B\u0435 Ledger \u043D\u0435 \u0438\u0437\u043C\u0435\u043D\u044F\u043B\u0438\u0441\u044C. \u0414\u0438\u0430\u0433\u043D\u043E\u0441\u0442\u0438\u043A\u0430:\n{0} + +LedgerRuntimeProjectionRestorerPartiallyRestored = Runtime-\u043F\u0440\u043E\u0435\u043A\u0446\u0438\u0438 \u0432\u043E\u0441\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D\u044B \u0442\u043E\u043B\u044C\u043A\u043E \u0434\u043B\u044F \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0442\u0435\u043B\u044C\u043D\u044B\u0445 \u0437\u0430\u043F\u0438\u0441\u0435\u0439 Ledger. \u0423\u0434\u0430\u043B\u0435\u043D\u043E \u0441\u0442\u0430\u0440\u044B\u0445 \u043F\u0440\u043E\u0435\u043A\u0446\u0438\u0439: {0}; \u0437\u0430\u043D\u043E\u0432\u043E \u0441\u043E\u0437\u0434\u0430\u043D\u043E \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0442\u0435\u043B\u044C\u043D\u044B\u0445 \u043F\u0440\u043E\u0435\u043A\u0446\u0438\u0439: {1}. \u041F\u0440\u043E\u043F\u0443\u0449\u0435\u043D\u043E \u043D\u0435\u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0442\u0435\u043B\u044C\u043D\u044B\u0445 \u0437\u0430\u043F\u0438\u0441\u0435\u0439 Ledger: {2}. \u0417\u0430\u0442\u0440\u043E\u043D\u0443\u0442\u044B\u0435 \u0432\u043B\u0430\u0434\u0435\u043B\u044C\u0446\u044B: {3}. \u041E\u0442\u0441\u0443\u0442\u0441\u0442\u0432\u0443\u044E\u0449\u0438\u0435 UUID \u043F\u0440\u043E\u0435\u043A\u0446\u0438\u0439: {4}. \u0414\u0443\u0431\u043B\u0438\u0440\u0443\u044E\u0449\u0438\u0435\u0441\u044F UUID \u043F\u0440\u043E\u0435\u043A\u0446\u0438\u0439: {5}. \u0423\u0441\u0442\u0430\u0440\u0435\u0432\u0448\u0438\u0435 UUID \u043F\u0440\u043E\u0435\u043A\u0446\u0438\u0439: {6}. \u0421\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u043D\u044B\u0435 \u0434\u0430\u043D\u043D\u044B\u0435 Ledger \u043D\u0435 \u0438\u0437\u043C\u0435\u043D\u044F\u043B\u0438\u0441\u044C. \u0414\u0438\u0430\u0433\u043D\u043E\u0441\u0442\u0438\u043A\u0430:\n{7} + +LedgerRuntimeProjectionRestorerRestored = Runtime-\u043F\u0440\u043E\u0435\u043A\u0446\u0438\u0438 \u0432\u043E\u0441\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D\u044B \u0438\u0437 \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0442\u0435\u043B\u044C\u043D\u044B\u0445 \u0434\u0430\u043D\u043D\u044B\u0445 Ledger. \u0423\u0434\u0430\u043B\u0435\u043D\u043E \u0441\u0442\u0430\u0440\u044B\u0445 \u043F\u0440\u043E\u0435\u043A\u0446\u0438\u0439: {0}; \u0437\u0430\u043D\u043E\u0432\u043E \u0441\u043E\u0437\u0434\u0430\u043D\u043E \u043F\u0440\u043E\u0435\u043A\u0446\u0438\u0439: {1}. \u0417\u0430\u0442\u0440\u043E\u043D\u0443\u0442\u044B\u0435 \u0432\u043B\u0430\u0434\u0435\u043B\u044C\u0446\u044B: {2}. \u041E\u0442\u0441\u0443\u0442\u0441\u0442\u0432\u0443\u044E\u0449\u0438\u0435 UUID \u043F\u0440\u043E\u0435\u043A\u0446\u0438\u0439: {3}. \u0414\u0443\u0431\u043B\u0438\u0440\u0443\u044E\u0449\u0438\u0435\u0441\u044F UUID \u043F\u0440\u043E\u0435\u043A\u0446\u0438\u0439: {4}. \u0423\u0441\u0442\u0430\u0440\u0435\u0432\u0448\u0438\u0435 UUID \u043F\u0440\u043E\u0435\u043A\u0446\u0438\u0439: {5}. \u0421\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u043D\u044B\u0435 \u0434\u0430\u043D\u043D\u044B\u0435 Ledger \u043D\u0435 \u0438\u0437\u043C\u0435\u043D\u044F\u043B\u0438\u0441\u044C. + +LedgerStructuralValidatorAccountProjectionPortfolioNotAllowed = \u041F\u0440\u043E\u0435\u043A\u0446\u0438\u044F \u0441\u0447\u0435\u0442\u0430 \u0434\u043E\u043B\u0436\u043D\u0430 \u0441\u0441\u044B\u043B\u0430\u0442\u044C\u0441\u044F \u0442\u043E\u043B\u044C\u043A\u043E \u043D\u0430 \u0441\u0447\u0435\u0442; \u0443\u0434\u0430\u043B\u0438\u0442\u0435 \u0441\u0441\u044B\u043B\u043A\u0443 \u043D\u0430 \u0441\u0447\u0435\u0442 \u0446\u0435\u043D\u043D\u044B\u0445 \u0431\u0443\u043C\u0430\u0433: {0} + +LedgerStructuralValidatorDividendSecurityRequired = \u0417\u0430\u043F\u0438\u0441\u0438 \u0434\u0438\u0432\u0438\u0434\u0435\u043D\u0434\u043E\u0432 \u0434\u043E\u043B\u0436\u043D\u044B \u0441\u0441\u044B\u043B\u0430\u0442\u044C\u0441\u044F \u043D\u0430 \u043F\u0440\u043E\u0432\u043E\u0434\u043A\u0443 \u0446\u0435\u043D\u043D\u043E\u0439 \u0431\u0443\u043C\u0430\u0433\u0438: {0} + +LedgerStructuralValidatorDuplicateEntryUuid = \u041A\u0430\u0436\u0434\u0430\u044F \u0437\u0430\u043F\u0438\u0441\u044C Ledger \u0434\u043E\u043B\u0436\u043D\u0430 \u0438\u043C\u0435\u0442\u044C \u0443\u043D\u0438\u043A\u0430\u043B\u044C\u043D\u044B\u0439 UUID. \u0414\u0443\u0431\u043B\u0438\u043A\u0430\u0442: {0} + +LedgerStructuralValidatorDuplicatePostingUuid = \u041A\u0430\u0436\u0434\u0430\u044F \u043F\u0440\u043E\u0432\u043E\u0434\u043A\u0430 Ledger \u0434\u043E\u043B\u0436\u043D\u0430 \u0438\u043C\u0435\u0442\u044C \u0443\u043D\u0438\u043A\u0430\u043B\u044C\u043D\u044B\u0439 UUID. \u0414\u0443\u0431\u043B\u0438\u043A\u0430\u0442: {0} + +LedgerStructuralValidatorDuplicateProjectionUuid = \u041A\u0430\u0436\u0434\u0430\u044F \u043F\u0440\u043E\u0435\u043A\u0446\u0438\u044F Ledger \u0434\u043E\u043B\u0436\u043D\u0430 \u0438\u043C\u0435\u0442\u044C \u0443\u043D\u0438\u043A\u0430\u043B\u044C\u043D\u044B\u0439 UUID. \u0414\u0443\u0431\u043B\u0438\u043A\u0430\u0442: {0} + +LedgerStructuralValidatorEntryDateTimeRequired = \u0417\u0430\u043F\u0438\u0441\u044C Ledger {0} \u0434\u043E\u043B\u0436\u043D\u0430 \u0438\u043C\u0435\u0442\u044C \u0434\u0430\u0442\u0443 \u0438 \u0432\u0440\u0435\u043C\u044F. + +LedgerStructuralValidatorEntryTypeRequired = \u0417\u0430\u043F\u0438\u0441\u044C Ledger {0} \u0434\u043E\u043B\u0436\u043D\u0430 \u0438\u043C\u0435\u0442\u044C \u0442\u0438\u043F \u0437\u0430\u043F\u0438\u0441\u0438. + +LedgerStructuralValidatorEntryUuidRequired = \u041A\u0430\u0436\u0434\u0430\u044F \u0437\u0430\u043F\u0438\u0441\u044C Ledger \u0434\u043E\u043B\u0436\u043D\u0430 \u0438\u043C\u0435\u0442\u044C UUID. + +LedgerStructuralValidatorExDateSecurityRequired = EX_DATE \u043C\u043E\u0436\u043D\u043E \u0438\u0441\u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u044C \u0442\u043E\u043B\u044C\u043A\u043E \u0432 \u043F\u0440\u043E\u0432\u043E\u0434\u043A\u0430\u0445, \u043A\u043E\u0442\u043E\u0440\u044B\u0435 \u0441\u0441\u044B\u043B\u0430\u044E\u0442\u0441\u044F \u043D\u0430 \u0446\u0435\u043D\u043D\u0443\u044E \u0431\u0443\u043C\u0430\u0433\u0443: {0} + +LedgerStructuralValidatorLedgerRequired = \u0422\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044F \u043E\u0431\u044A\u0435\u043A\u0442 Ledger. + +LedgerStructuralValidatorParameterCodeNotAllowed = \u041F\u0430\u0440\u0430\u043C\u0435\u0442\u0440 Ledger \u0434\u043B\u044F {0} \u0434\u043E\u043B\u0436\u0435\u043D \u0438\u0441\u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u044C \u043E\u0434\u0438\u043D \u0438\u0437 \u043D\u0430\u0441\u0442\u0440\u043E\u0435\u043D\u043D\u044B\u0445 \u043A\u043E\u0434\u043E\u0432. + +LedgerStructuralValidatorParameterMustUseValueKind = {0} \u0434\u043E\u043B\u0436\u0435\u043D \u0438\u0441\u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u044C \u0442\u0438\u043F \u0437\u043D\u0430\u0447\u0435\u043D\u0438\u044F {1}. + +LedgerStructuralValidatorParameterTypeRequired = \u041F\u0430\u0440\u0430\u043C\u0435\u0442\u0440 Ledger {0} \u0434\u043E\u043B\u0436\u0435\u043D \u0438\u043C\u0435\u0442\u044C \u0442\u0438\u043F \u043F\u0430\u0440\u0430\u043C\u0435\u0442\u0440\u0430. + +LedgerStructuralValidatorParameterValueKindMismatch = \u0417\u043D\u0430\u0447\u0435\u043D\u0438\u0435 \u043F\u0430\u0440\u0430\u043C\u0435\u0442\u0440\u0430 Ledger \u0434\u043E\u043B\u0436\u043D\u043E \u0441\u043E\u043E\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u043E\u0432\u0430\u0442\u044C \u0442\u0438\u043F\u0443 \u0437\u043D\u0430\u0447\u0435\u043D\u0438\u044F {0}. + +LedgerStructuralValidatorParameterValueKindRequired = \u041F\u0430\u0440\u0430\u043C\u0435\u0442\u0440 Ledger {0} \u0434\u043E\u043B\u0436\u0435\u043D \u0443\u043A\u0430\u0437\u044B\u0432\u0430\u0442\u044C \u0442\u0438\u043F \u0437\u043D\u0430\u0447\u0435\u043D\u0438\u044F. + +LedgerStructuralValidatorParameterValueRequired = \u041F\u0430\u0440\u0430\u043C\u0435\u0442\u0440 Ledger {0} \u0434\u043E\u043B\u0436\u0435\u043D \u0441\u043E\u0434\u0435\u0440\u0436\u0430\u0442\u044C \u0437\u043D\u0430\u0447\u0435\u043D\u0438\u0435. + +LedgerStructuralValidatorPortfolioProjectionAccountNotAllowed = \u041F\u0440\u043E\u0435\u043A\u0446\u0438\u044F \u0441\u0447\u0435\u0442\u0430 \u0446\u0435\u043D\u043D\u044B\u0445 \u0431\u0443\u043C\u0430\u0433 \u0434\u043E\u043B\u0436\u043D\u0430 \u0441\u0441\u044B\u043B\u0430\u0442\u044C\u0441\u044F \u0442\u043E\u043B\u044C\u043A\u043E \u043D\u0430 \u0441\u0447\u0435\u0442 \u0446\u0435\u043D\u043D\u044B\u0445 \u0431\u0443\u043C\u0430\u0433; \u0443\u0434\u0430\u043B\u0438\u0442\u0435 \u0441\u0441\u044B\u043B\u043A\u0443 \u043D\u0430 \u0441\u0447\u0435\u0442: {0} + +LedgerStructuralValidatorPostingCurrencyRequired = \u041F\u0440\u043E\u0432\u043E\u0434\u043A\u0430 Ledger {0} \u0434\u043E\u043B\u0436\u043D\u0430 \u0438\u043C\u0435\u0442\u044C \u0432\u0430\u043B\u044E\u0442\u0443. + +LedgerStructuralValidatorPostingExchangeRatePositive = \u041F\u0440\u043E\u0432\u043E\u0434\u043A\u0430 Ledger {0} \u0434\u043E\u043B\u0436\u043D\u0430 \u0438\u043C\u0435\u0442\u044C \u043E\u0431\u043C\u0435\u043D\u043D\u044B\u0439 \u043A\u0443\u0440\u0441 \u0431\u043E\u043B\u044C\u0448\u0435 \u043D\u0443\u043B\u044F. + +LedgerStructuralValidatorPostingGroupRefNotFound = \u0421\u0441\u044B\u043B\u043A\u0430 \u043D\u0430 \u0433\u0440\u0443\u043F\u043F\u0443 \u043F\u0440\u043E\u0432\u043E\u0434\u043E\u043A {0} \u0434\u043E\u043B\u0436\u043D\u0430 \u0443\u043A\u0430\u0437\u044B\u0432\u0430\u0442\u044C \u043D\u0430 \u043F\u0440\u043E\u0432\u043E\u0434\u043A\u0443 \u0432 \u0442\u043E\u0439 \u0436\u0435 \u0437\u0430\u043F\u0438\u0441\u0438. + +LedgerStructuralValidatorPostingSecurityRequired = \u041F\u0440\u043E\u0432\u043E\u0434\u043A\u0430 \u0446\u0435\u043D\u043D\u043E\u0439 \u0431\u0443\u043C\u0430\u0433\u0438 {0} \u0434\u043E\u043B\u0436\u043D\u0430 \u0441\u0441\u044B\u043B\u0430\u0442\u044C\u0441\u044F \u043D\u0430 \u0446\u0435\u043D\u043D\u0443\u044E \u0431\u0443\u043C\u0430\u0433\u0443. + +LedgerStructuralValidatorPostingTypeRequired = \u041F\u0440\u043E\u0432\u043E\u0434\u043A\u0430 Ledger {0} \u0434\u043E\u043B\u0436\u043D\u0430 \u0438\u043C\u0435\u0442\u044C \u0442\u0438\u043F \u043F\u0440\u043E\u0432\u043E\u0434\u043A\u0438. + +LedgerStructuralValidatorPostingUuidRequired = \u0417\u0430\u043F\u0438\u0441\u044C Ledger {0} \u0441\u043E\u0434\u0435\u0440\u0436\u0438\u0442 \u043F\u0440\u043E\u0432\u043E\u0434\u043A\u0443 \u0431\u0435\u0437 UUID. + +LedgerStructuralValidatorPrimaryPostingRefNotFound = \u0421\u0441\u044B\u043B\u043A\u0430 \u043D\u0430 \u043E\u0441\u043D\u043E\u0432\u043D\u0443\u044E \u043F\u0440\u043E\u0432\u043E\u0434\u043A\u0443 {0} \u0434\u043E\u043B\u0436\u043D\u0430 \u0443\u043A\u0430\u0437\u044B\u0432\u0430\u0442\u044C \u043D\u0430 \u043F\u0440\u043E\u0432\u043E\u0434\u043A\u0443 \u0432 \u0442\u043E\u0439 \u0436\u0435 \u0437\u0430\u043F\u0438\u0441\u0438. + +LedgerStructuralValidatorProjectionAccountRequired = \u041F\u0440\u043E\u0435\u043A\u0446\u0438\u044F Ledger {0} \u0434\u043E\u043B\u0436\u043D\u0430 \u0441\u0441\u044B\u043B\u0430\u0442\u044C\u0441\u044F \u043D\u0430 \u0441\u0447\u0435\u0442. + +LedgerStructuralValidatorProjectionGroupTargetConflict = \u041F\u0440\u043E\u0435\u043A\u0446\u0438\u044F \u0434\u043E\u043B\u0436\u043D\u0430 \u0438\u0441\u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u044C \u043B\u0438\u0431\u043E \u0441\u0441\u044B\u043B\u043A\u0438 \u0447\u043B\u0435\u043D\u0441\u0442\u0432\u0430, \u043B\u0438\u0431\u043E \u043E\u0434\u043D\u0443 \u0441\u0441\u044B\u043B\u043A\u0443 \u043D\u0430 \u0433\u0440\u0443\u043F\u043F\u0443 \u043F\u0440\u043E\u0432\u043E\u0434\u043E\u043A, \u043D\u043E \u043D\u0435 \u043E\u0431\u0430 \u0432\u0430\u0440\u0438\u0430\u043D\u0442\u0430. + +LedgerStructuralValidatorProjectionMembershipRefNotFound = \u0421\u0441\u044B\u043B\u043A\u0430 \u0447\u043B\u0435\u043D\u0441\u0442\u0432\u0430 \u043F\u0440\u043E\u0435\u043A\u0446\u0438\u0438 {0} \u0434\u043E\u043B\u0436\u043D\u0430 \u0443\u043A\u0430\u0437\u044B\u0432\u0430\u0442\u044C \u043D\u0430 \u043F\u0440\u043E\u0432\u043E\u0434\u043A\u0443 \u0432 \u0442\u043E\u0439 \u0436\u0435 \u0437\u0430\u043F\u0438\u0441\u0438. + +LedgerStructuralValidatorProjectionPortfolioRequired = \u041F\u0440\u043E\u0435\u043A\u0446\u0438\u044F Ledger {0} \u0434\u043E\u043B\u0436\u043D\u0430 \u0441\u0441\u044B\u043B\u0430\u0442\u044C\u0441\u044F \u043D\u0430 \u0441\u0447\u0435\u0442 \u0446\u0435\u043D\u043D\u044B\u0445 \u0431\u0443\u043C\u0430\u0433. + +LedgerStructuralValidatorProjectionPrimaryTargetConflict = \u041F\u0440\u043E\u0435\u043A\u0446\u0438\u044F \u0434\u043E\u043B\u0436\u043D\u0430 \u0438\u0441\u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u044C \u043B\u0438\u0431\u043E \u0441\u0441\u044B\u043B\u043A\u0438 \u0447\u043B\u0435\u043D\u0441\u0442\u0432\u0430, \u043B\u0438\u0431\u043E \u043E\u0434\u043D\u0443 \u0441\u0441\u044B\u043B\u043A\u0443 \u043D\u0430 \u043E\u0441\u043D\u043E\u0432\u043D\u0443\u044E \u043F\u0440\u043E\u0432\u043E\u0434\u043A\u0443, \u043D\u043E \u043D\u0435 \u043E\u0431\u0430 \u0432\u0430\u0440\u0438\u0430\u043D\u0442\u0430. + +LedgerStructuralValidatorProjectionRoleNotAllowed = \u0420\u043E\u043B\u044C \u043F\u0440\u043E\u0435\u043A\u0446\u0438\u0438 {0} \u043D\u0435\u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0442\u0435\u043B\u044C\u043D\u0430 \u0434\u043B\u044F \u0442\u0438\u043F\u0430 \u0437\u0430\u043F\u0438\u0441\u0438 {1}. + +LedgerStructuralValidatorProjectionRoleRequired = \u041F\u0440\u043E\u0435\u043A\u0446\u0438\u044F Ledger {0} \u0434\u043E\u043B\u0436\u043D\u0430 \u0438\u043C\u0435\u0442\u044C \u0440\u043E\u043B\u044C \u043F\u0440\u043E\u0435\u043A\u0446\u0438\u0438. + +LedgerStructuralValidatorProjectionRoleRequiredForType = \u0422\u0438\u043F \u0437\u0430\u043F\u0438\u0441\u0438 {0} \u0434\u043E\u043B\u0436\u0435\u043D \u0438\u043C\u0435\u0442\u044C \u0440\u043E\u0432\u043D\u043E \u043E\u0434\u043D\u0443 \u043F\u0440\u043E\u0435\u043A\u0446\u0438\u044E \u0441 \u0440\u043E\u043B\u044C\u044E {1}. + +LedgerStructuralValidatorProjectionUuidRequired = \u0417\u0430\u043F\u0438\u0441\u044C Ledger {0} \u0441\u043E\u0434\u0435\u0440\u0436\u0438\u0442 \u043F\u0440\u043E\u0435\u043A\u0446\u0438\u044E \u0431\u0435\u0437 UUID. + +LedgerStructuralValidatorSignedFactsNotAllowed = \u0422\u0438\u043F \u0437\u0430\u043F\u0438\u0441\u0438 {0} \u043F\u0440\u0438\u043D\u0438\u043C\u0430\u0435\u0442 \u0442\u043E\u043B\u044C\u043A\u043E \u043F\u043E\u043B\u043E\u0436\u0438\u0442\u0435\u043B\u044C\u043D\u044B\u0435 \u0437\u043D\u0430\u0447\u0435\u043D\u0438\u044F \u043F\u0440\u043E\u0432\u043E\u0434\u043E\u043A. \u0418\u0441\u043F\u043E\u043B\u044C\u0437\u0443\u0439\u0442\u0435 \u043F\u043E\u043B\u043E\u0436\u0438\u0442\u0435\u043B\u044C\u043D\u044B\u0435 \u0441\u0443\u043C\u043C\u044B \u0438 \u043A\u043E\u043B\u0438\u0447\u0435\u0441\u0442\u0432\u0430. + +LedgerStructuralValidatorTargetingRefRequired = \u0426\u0435\u043B\u0435\u0432\u044B\u0435 \u043F\u0440\u043E\u0435\u043A\u0446\u0438\u0438 Ledger \u0434\u043E\u043B\u0436\u043D\u044B \u0443\u043A\u0430\u0437\u044B\u0432\u0430\u0442\u044C \u043D\u0430 \u043E\u0441\u043D\u043E\u0432\u043D\u0443\u044E \u043F\u0440\u043E\u0432\u043E\u0434\u043A\u0443. + +LedgerXmlInvalidLedgerStructure = \u0421\u0442\u0440\u0443\u043A\u0442\u0443\u0440\u0430 Ledger \u043D\u0435\u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0442\u0435\u043B\u044C\u043D\u0430:\n{0} + MsgAlphaVantageAPIKeyMissing = Alpha Vantage API key \u043E\u0442\u0441\u0443\u0442\u0441\u0442\u0432\u0443\u0435\u0442. \u041F\u0440\u043E\u0432\u0435\u0440\u044C\u0442\u0435 \u043D\u0430\u0441\u0442\u0440\u043E\u0439\u043A\u0438 MsgCheckConfiguredAndCalculatedGrossValueDoNotMatch = \u0418\u043C\u043F\u043E\u0440\u0442\u0438\u0440\u043E\u0432\u0430\u043D\u043D\u043E\u0435 \u0432\u0430\u043B\u043E\u0432\u043E\u0435 \u0437\u043D\u0430\u0447\u0435\u043D\u0438\u0435 ({0}) \u0438 \u0440\u0430\u0441\u0441\u0447\u0438\u0442\u0430\u043D\u043D\u043E\u0435 \u0432\u0430\u043B\u043E\u0432\u043E\u0435 \u0437\u043D\u0430\u0447\u0435\u043D\u0438\u0435 ({1}) \u043D\u0435 \u0441\u043E\u0432\u043F\u0430\u0434\u0430\u0435\u0442 diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_sk.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_sk.properties index 8cdd0b3307..40e9c38ea4 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_sk.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_sk.properties @@ -472,6 +472,148 @@ LabelYahooFinance = Yahoo Finance LabelYahooFinanceAdjustedClose = Yahoo Finance (Upraven\u00E1 uzatv\u00E1racia cena) +LedgerDiagnosticMessageFormatterAccount = \u00DA\u010Det + +LedgerDiagnosticMessageFormatterAmount = Suma + +LedgerDiagnosticMessageFormatterContextUnavailable = nie je k dispoz\u00EDcii + +LedgerDiagnosticMessageFormatterDate = D\u00E1tum + +LedgerDiagnosticMessageFormatterIsin = ISIN + +LedgerDiagnosticMessageFormatterNote = Pozn\u00E1mka + +LedgerDiagnosticMessageFormatterPortfolio = \u00DA\u010Det cenn\u00FDch papierov + +LedgerDiagnosticMessageFormatterSecurity = Cenn\u00FD papier + +LedgerDiagnosticMessageFormatterShares = Akcie + +LedgerDiagnosticMessageFormatterSource = Zdroj + +LedgerDiagnosticMessageFormatterTicker = Ticker + +LedgerDiagnosticMessageFormatterTransactionContext = Detaily transakcie + +LedgerDiagnosticMessageFormatterType = Typ + +LedgerDiagnosticMessageFormatterUnnamedAccount = nepomenovan\u00FD \u00FA\u010Det + +LedgerDiagnosticMessageFormatterUnnamedPortfolio = nepomenovan\u00FD \u00FA\u010Det cenn\u00FDch papierov + +LedgerDiagnosticMessageFormatterUnnamedSecurity = nepomenovan\u00FD cenn\u00FD papier + +LedgerDiagnosticMessageFormatterWkn = WKN + +LedgerInsertActionGeneratedBuyMissingBuySellEntry = Vygenerovan\u00FA n\u00E1kupn\u00FA transakciu spravuje Ledger, ale prepojen\u00FD z\u00E1znam n\u00E1kup/predaj sa nena\u0161iel. + +LedgerInsertActionGeneratedBuyTypeMismatch = Tento vygenerovan\u00FD n\u00E1kup Ledger mo\u017Eno aktualizova\u0165 iba z importovanej n\u00E1kupnej transakcie. + +LedgerInsertActionGeneratedDeliveryOwnerNotFound = Vlastn\u00EDk vygenerovan\u00E9ho dodania Ledger sa nena\u0161iel. + +LedgerInsertActionGeneratedDeliveryTypeMismatch = Toto vygenerovan\u00E9 dodanie Ledger mo\u017Eno aktualizova\u0165 iba z importovanej n\u00E1kupnej transakcie. + +LedgerInsertActionInvestmentPlanLegacySettersNotSupported = Transakcie investi\u010Dn\u00E9ho pl\u00E1nu spravovan\u00E9 Ledgerom nemo\u017Eno aktualizova\u0165 star\u00FDmi importn\u00FDmi settermi. Pou\u017Eite aktualiza\u010Dn\u00FA cestu Ledger. + +LedgerInsertActionUnsupportedInvestmentPlanTransactionUpdateType = Typ aktualiz\u00E1cie investi\u010Dn\u00E9ho pl\u00E1nu {0} nie je podporovan\u00FD pre transakcie spravovan\u00E9 Ledgerom. + +LedgerParameterMissingAttribute = Parametru Ledger {0} ch\u00FDba povinn\u00FD atrib\u00FAt. + +LedgerParameterMissingType = Parameter Ledger mus\u00ED ma\u0165 typ. + +LedgerParameterMissingValueKind = Parameter Ledger mus\u00ED deklarova\u0165 druh hodnoty. + +LedgerParameterReferenceValueMissingValueNode = Referen\u010Dn\u00E1 hodnota parametra Ledger mus\u00ED obsahova\u0165 uzol hodnoty. + +LedgerParameterUnsupportedValueKind = Druh hodnoty parametra Ledger {0} nie je podporovan\u00FD. + +LedgerParameterValueKindRequiresStructuredValue = Druh hodnoty parametra Ledger {0} vy\u017Eaduje \u0161trukt\u00FArovan\u00FA hodnotu. + +LedgerProtobufBooleanParameterMissingBooleanValue = Parameter Ledger BOOLEAN mus\u00ED obsahova\u0165 booleanValue. + +LedgerProtobufInvalidLedgerPersistenceState = Ulo\u017Een\u00E9 d\u00E1ta Ledger Protobuf s\u00FA nekonzistentn\u00E9:\n{0} + +LedgerProtobufLocalDateParameterMissingLocalDateValue = Parameter Ledger LOCAL_DATE mus\u00ED obsahova\u0165 localDateValue. + +LedgerRuntimeProjectionRestorerInvalidLedger = Obnova projekci\u00ED za behu bola presko\u010Den\u00E1, preto\u017Ee ulo\u017Een\u00E9 d\u00E1ta Ledger nie s\u00FA platn\u00E9. D\u00E1ta Ledger neboli zmenen\u00E9. Diagnostika:\n{0} + +LedgerRuntimeProjectionRestorerPartiallyRestored = Projekcie za behu boli obnoven\u00E9 iba pre platn\u00E9 z\u00E1znamy Ledger. Odstr\u00E1nen\u00FDch bolo {0} star\u00FDch projekci\u00ED a znovu vytvoren\u00FDch {1} platn\u00FDch projekci\u00ED. Presko\u010Den\u00E9 neplatn\u00E9 z\u00E1znamy Ledger: {2}. Dotknut\u00ED vlastn\u00EDci: {3}. Ch\u00FDbaj\u00FAce UUID projekci\u00ED: {4}. Duplicitn\u00E9 UUID projekci\u00ED: {5}. Zastaran\u00E9 UUID projekci\u00ED: {6}. Ulo\u017Een\u00E9 d\u00E1ta Ledger neboli zmenen\u00E9. Diagnostika:\n{7} + +LedgerRuntimeProjectionRestorerRestored = Projekcie za behu boli obnoven\u00E9 z platn\u00FDch d\u00E1t Ledger. Odstr\u00E1nen\u00FDch bolo {0} star\u00FDch projekci\u00ED a znovu vytvoren\u00FDch {1} projekci\u00ED. Dotknut\u00ED vlastn\u00EDci: {2}. Ch\u00FDbaj\u00FAce UUID projekci\u00ED: {3}. Duplicitn\u00E9 UUID projekci\u00ED: {4}. Zastaran\u00E9 UUID projekci\u00ED: {5}. Ulo\u017Een\u00E9 d\u00E1ta Ledger neboli zmenen\u00E9. + +LedgerStructuralValidatorAccountProjectionPortfolioNotAllowed = Projekcia \u00FA\u010Dtu smie odkazova\u0165 iba na \u00FA\u010Det; odstr\u00E1\u0148te odkaz na \u00FA\u010Det cenn\u00FDch papierov: {0} + +LedgerStructuralValidatorDividendSecurityRequired = Dividendov\u00E9 z\u00E1znamy musia odkazova\u0165 na polo\u017Eku cenn\u00E9ho papiera: {0} + +LedgerStructuralValidatorDuplicateEntryUuid = Ka\u017Ed\u00FD z\u00E1znam Ledger mus\u00ED ma\u0165 jedine\u010Dn\u00E9 UUID. Duplicita: {0} + +LedgerStructuralValidatorDuplicatePostingUuid = Ka\u017Ed\u00E1 polo\u017Eka Ledger mus\u00ED ma\u0165 jedine\u010Dn\u00E9 UUID. Duplicita: {0} + +LedgerStructuralValidatorDuplicateProjectionUuid = Ka\u017Ed\u00E1 projekcia Ledger mus\u00ED ma\u0165 jedine\u010Dn\u00E9 UUID. Duplicita: {0} + +LedgerStructuralValidatorEntryDateTimeRequired = Z\u00E1znam Ledger {0} mus\u00ED ma\u0165 d\u00E1tum a \u010Das. + +LedgerStructuralValidatorEntryTypeRequired = Z\u00E1znam Ledger {0} mus\u00ED ma\u0165 typ z\u00E1znamu. + +LedgerStructuralValidatorEntryUuidRequired = Ka\u017Ed\u00FD z\u00E1znam Ledger mus\u00ED ma\u0165 UUID. + +LedgerStructuralValidatorExDateSecurityRequired = EX_DATE mo\u017Eno pou\u017Ei\u0165 iba pri polo\u017Ek\u00E1ch, ktor\u00E9 odkazuj\u00FA na cenn\u00FD papier: {0} + +LedgerStructuralValidatorLedgerRequired = Vy\u017Eaduje sa objekt Ledger. + +LedgerStructuralValidatorParameterCodeNotAllowed = Parameter Ledger pre {0} mus\u00ED pou\u017Ei\u0165 jeden z nakonfigurovan\u00FDch k\u00F3dov. + +LedgerStructuralValidatorParameterMustUseValueKind = {0} mus\u00ED pou\u017Ei\u0165 druh hodnoty {1}. + +LedgerStructuralValidatorParameterTypeRequired = Parameter Ledger {0} mus\u00ED ma\u0165 typ parametra. + +LedgerStructuralValidatorParameterValueKindMismatch = Hodnota parametra Ledger mus\u00ED zodpoveda\u0165 druhu hodnoty {0}. + +LedgerStructuralValidatorParameterValueKindRequired = Parameter Ledger {0} mus\u00ED deklarova\u0165 druh hodnoty. + +LedgerStructuralValidatorParameterValueRequired = Parameter Ledger {0} mus\u00ED obsahova\u0165 hodnotu. + +LedgerStructuralValidatorPortfolioProjectionAccountNotAllowed = Projekcia \u00FA\u010Dtu cenn\u00FDch papierov smie odkazova\u0165 iba na \u00FA\u010Det cenn\u00FDch papierov; odstr\u00E1\u0148te odkaz na \u00FA\u010Det: {0} + +LedgerStructuralValidatorPostingCurrencyRequired = Polo\u017Eka Ledger {0} mus\u00ED ma\u0165 menu. + +LedgerStructuralValidatorPostingExchangeRatePositive = Polo\u017Eka Ledger {0} mus\u00ED ma\u0165 v\u00FDmenn\u00FD kurz v\u00E4\u010D\u0161\u00ED ako nula. + +LedgerStructuralValidatorPostingGroupRefNotFound = Odkaz na skupinu polo\u017Eiek {0} mus\u00ED ukazova\u0165 na polo\u017Eku v tom istom z\u00E1zname. + +LedgerStructuralValidatorPostingSecurityRequired = Polo\u017Eka cenn\u00E9ho papiera {0} mus\u00ED odkazova\u0165 na cenn\u00FD papier. + +LedgerStructuralValidatorPostingTypeRequired = Polo\u017Eka Ledger {0} mus\u00ED ma\u0165 typ polo\u017Eky. + +LedgerStructuralValidatorPostingUuidRequired = Z\u00E1znam Ledger {0} obsahuje polo\u017Eku bez UUID. + +LedgerStructuralValidatorPrimaryPostingRefNotFound = Odkaz na prim\u00E1rnu polo\u017Eku {0} mus\u00ED ukazova\u0165 na polo\u017Eku v tom istom z\u00E1zname. + +LedgerStructuralValidatorProjectionAccountRequired = Projekcia Ledger {0} mus\u00ED odkazova\u0165 na \u00FA\u010Det. + +LedgerStructuralValidatorProjectionGroupTargetConflict = Projekcia mus\u00ED pou\u017Ei\u0165 bu\u010F \u010Dlensk\u00E9 odkazy, alebo jeden odkaz na skupinu polo\u017Eiek, nie oboje. + +LedgerStructuralValidatorProjectionMembershipRefNotFound = \u010Clensk\u00FD odkaz projekcie {0} mus\u00ED ukazova\u0165 na polo\u017Eku v tom istom z\u00E1zname. + +LedgerStructuralValidatorProjectionPortfolioRequired = Projekcia Ledger {0} mus\u00ED odkazova\u0165 na \u00FA\u010Det cenn\u00FDch papierov. + +LedgerStructuralValidatorProjectionPrimaryTargetConflict = Projekcia mus\u00ED pou\u017Ei\u0165 bu\u010F \u010Dlensk\u00E9 odkazy, alebo jeden odkaz na prim\u00E1rnu polo\u017Eku, nie oboje. + +LedgerStructuralValidatorProjectionRoleNotAllowed = Rola projekcie {0} nie je platn\u00E1 pre typ z\u00E1znamu {1}. + +LedgerStructuralValidatorProjectionRoleRequired = Projekcia Ledger {0} mus\u00ED ma\u0165 rolu projekcie. + +LedgerStructuralValidatorProjectionRoleRequiredForType = Typ z\u00E1znamu {0} mus\u00ED ma\u0165 pr\u00E1ve jednu projekciu s rolou {1}. + +LedgerStructuralValidatorProjectionUuidRequired = Z\u00E1znam Ledger {0} obsahuje projekciu bez UUID. + +LedgerStructuralValidatorSignedFactsNotAllowed = Typ z\u00E1znamu {0} prij\u00EDma iba kladn\u00E9 hodnoty polo\u017Eiek. Pou\u017Eite kladn\u00E9 sumy a po\u010Dty akci\u00ED. + +LedgerStructuralValidatorTargetingRefRequired = Cielen\u00E9 projekcie Ledger musia ukazova\u0165 na prim\u00E1rnu polo\u017Eku. + +LedgerXmlInvalidLedgerStructure = \u0160trukt\u00FAra Ledger nie je platn\u00E1:\n{0} + MsgAlphaVantageAPIKeyMissing = Ch\u00FDba k\u013E\u00FA\u010D API Alpha Vantage. Nakonfigurujte v nastaveniach. MsgCheckConfiguredAndCalculatedGrossValueDoNotMatch = Dovezen\u00E1 hrub\u00E1 hodnota ({0}) a vypo\u010D\u00EDtan\u00E1 hrub\u00E1 hodnota ({1}) sa nezhoduj\u00FA diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_tr.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_tr.properties index 63373f9313..c65c206a0c 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_tr.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_tr.properties @@ -469,6 +469,148 @@ LabelYahooFinance = Yahoo Finans LabelYahooFinanceAdjustedClose = Yahoo Finance (D\u00FCzeltilmi\u015F Fiyat) +LedgerDiagnosticMessageFormatterAccount = Hesap + +LedgerDiagnosticMessageFormatterAmount = Tutar + +LedgerDiagnosticMessageFormatterContextUnavailable = kullan\u0131lam\u0131yor + +LedgerDiagnosticMessageFormatterDate = Tarih + +LedgerDiagnosticMessageFormatterIsin = ISIN + +LedgerDiagnosticMessageFormatterNote = Not + +LedgerDiagnosticMessageFormatterPortfolio = Menkul k\u0131ymet hesab\u0131 + +LedgerDiagnosticMessageFormatterSecurity = Menkul k\u0131ymet + +LedgerDiagnosticMessageFormatterShares = Adet + +LedgerDiagnosticMessageFormatterSource = Kaynak + +LedgerDiagnosticMessageFormatterTicker = Ticker + +LedgerDiagnosticMessageFormatterTransactionContext = \u0130\u015Flem ayr\u0131nt\u0131lar\u0131 + +LedgerDiagnosticMessageFormatterType = T\u00FCr + +LedgerDiagnosticMessageFormatterUnnamedAccount = ads\u0131z hesap + +LedgerDiagnosticMessageFormatterUnnamedPortfolio = ads\u0131z menkul k\u0131ymet hesab\u0131 + +LedgerDiagnosticMessageFormatterUnnamedSecurity = ads\u0131z menkul k\u0131ymet + +LedgerDiagnosticMessageFormatterWkn = WKN + +LedgerInsertActionGeneratedBuyMissingBuySellEntry = Olu\u015Fturulan al\u0131\u015F i\u015Flemi Ledger taraf\u0131ndan y\u00F6netiliyor, ancak ba\u011Fl\u0131 al\u0131\u015F/sat\u0131\u015F kayd\u0131 bulunamad\u0131. + +LedgerInsertActionGeneratedBuyTypeMismatch = Bu olu\u015Fturulan Ledger al\u0131\u015F i\u015Flemi yaln\u0131zca i\u00E7e aktar\u0131lan bir al\u0131\u015F i\u015Flemiyle g\u00FCncellenebilir. + +LedgerInsertActionGeneratedDeliveryOwnerNotFound = Olu\u015Fturulan Ledger teslimat\u0131n\u0131n sahibi bulunamad\u0131. + +LedgerInsertActionGeneratedDeliveryTypeMismatch = Bu olu\u015Fturulan Ledger teslimat\u0131 yaln\u0131zca i\u00E7e aktar\u0131lan bir al\u0131\u015F i\u015Flemiyle g\u00FCncellenebilir. + +LedgerInsertActionInvestmentPlanLegacySettersNotSupported = Ledger taraf\u0131ndan y\u00F6netilen yat\u0131r\u0131m plan\u0131 i\u015Flemleri eski i\u00E7e aktarma setterlar\u0131 ile g\u00FCncellenemez. Ledger g\u00FCncelleme yolunu kullan\u0131n. + +LedgerInsertActionUnsupportedInvestmentPlanTransactionUpdateType = Yat\u0131r\u0131m plan\u0131 g\u00FCncelleme t\u00FCr\u00FC {0}, Ledger taraf\u0131ndan y\u00F6netilen i\u015Flemler i\u00E7in desteklenmiyor. + +LedgerParameterMissingAttribute = Ledger parametresi {0} zorunlu bir \u00F6znitelikten yoksun. + +LedgerParameterMissingType = Bir Ledger parametresinin t\u00FCr\u00FC olmal\u0131d\u0131r. + +LedgerParameterMissingValueKind = Bir Ledger parametresi de\u011Fer t\u00FCr\u00FCn\u00FC belirtmelidir. + +LedgerParameterReferenceValueMissingValueNode = Bir Ledger parametresi referans de\u011Feri bir de\u011Fer d\u00FC\u011F\u00FCm\u00FC i\u00E7ermelidir. + +LedgerParameterUnsupportedValueKind = Ledger parametresi de\u011Fer t\u00FCr\u00FC {0} desteklenmiyor. + +LedgerParameterValueKindRequiresStructuredValue = Ledger parametresi de\u011Fer t\u00FCr\u00FC {0} yap\u0131land\u0131r\u0131lm\u0131\u015F bir de\u011Fer gerektirir. + +LedgerProtobufBooleanParameterMissingBooleanValue = BOOLEAN Ledger parametresi booleanValue i\u00E7ermelidir. + +LedgerProtobufInvalidLedgerPersistenceState = Kaydedilmi\u015F Ledger Protobuf verileri tutars\u0131z:\n{0} + +LedgerProtobufLocalDateParameterMissingLocalDateValue = LOCAL_DATE Ledger parametresi localDateValue i\u00E7ermelidir. + +LedgerRuntimeProjectionRestorerInvalidLedger = Kaydedilmi\u015F Ledger verileri ge\u00E7ersiz oldu\u011Fu i\u00E7in \u00E7al\u0131\u015Fma zaman\u0131 projeksiyonlar\u0131 geri y\u00FCklenmedi. Ledger verileri de\u011Fi\u015Ftirilmedi. Tan\u0131lama:\n{0} + +LedgerRuntimeProjectionRestorerPartiallyRestored = \u00C7al\u0131\u015Fma zaman\u0131 projeksiyonlar\u0131 yaln\u0131zca ge\u00E7erli Ledger kay\u0131tlar\u0131 i\u00E7in geri y\u00FCklendi. {0} eski projeksiyon kald\u0131r\u0131ld\u0131 ve {1} ge\u00E7erli projeksiyon yeniden olu\u015Fturuldu. Atlanan ge\u00E7ersiz Ledger kay\u0131tlar\u0131: {2}. Etkilenen sahipler: {3}. Eksik projeksiyon UUIDleri: {4}. Yinelenen projeksiyon UUIDleri: {5}. Eski projeksiyon UUIDleri: {6}. Kaydedilmi\u015F Ledger verileri de\u011Fi\u015Ftirilmedi. Tan\u0131lama:\n{7} + +LedgerRuntimeProjectionRestorerRestored = \u00C7al\u0131\u015Fma zaman\u0131 projeksiyonlar\u0131 ge\u00E7erli Ledger verilerinden geri y\u00FCklendi. {0} eski projeksiyon kald\u0131r\u0131ld\u0131 ve {1} projeksiyon yeniden olu\u015Fturuldu. Etkilenen sahipler: {2}. Eksik projeksiyon UUIDleri: {3}. Yinelenen projeksiyon UUIDleri: {4}. Eski projeksiyon UUIDleri: {5}. Kaydedilmi\u015F Ledger verileri de\u011Fi\u015Ftirilmedi. + +LedgerStructuralValidatorAccountProjectionPortfolioNotAllowed = Bir hesap projeksiyonu yaln\u0131zca bir hesaba ba\u015Fvurmal\u0131d\u0131r; menkul k\u0131ymet hesab\u0131 ba\u015Fvurusunu kald\u0131r\u0131n: {0} + +LedgerStructuralValidatorDividendSecurityRequired = Temett\u00FC kay\u0131tlar\u0131 bir menkul k\u0131ymet kayd\u0131na ba\u015Fvurmal\u0131d\u0131r: {0} + +LedgerStructuralValidatorDuplicateEntryUuid = Her Ledger kayd\u0131n\u0131n benzersiz bir UUID de\u011Feri olmal\u0131d\u0131r. Yinelenen: {0} + +LedgerStructuralValidatorDuplicatePostingUuid = Her Ledger kayd\u0131n\u0131n benzersiz bir UUID de\u011Feri olmal\u0131d\u0131r. Yinelenen: {0} + +LedgerStructuralValidatorDuplicateProjectionUuid = Her Ledger projeksiyonunun benzersiz bir UUID de\u011Feri olmal\u0131d\u0131r. Yinelenen: {0} + +LedgerStructuralValidatorEntryDateTimeRequired = Ledger kayd\u0131 {0} tarih ve saate sahip olmal\u0131d\u0131r. + +LedgerStructuralValidatorEntryTypeRequired = Ledger kayd\u0131 {0} bir kay\u0131t t\u00FCr\u00FCne sahip olmal\u0131d\u0131r. + +LedgerStructuralValidatorEntryUuidRequired = Her Ledger kayd\u0131n\u0131n bir UUID de\u011Feri olmal\u0131d\u0131r. + +LedgerStructuralValidatorExDateSecurityRequired = EX_DATE yaln\u0131zca bir menkul k\u0131ymete ba\u015Fvuran kay\u0131tlarda kullan\u0131labilir: {0} + +LedgerStructuralValidatorLedgerRequired = Bir Ledger nesnesi gereklidir. + +LedgerStructuralValidatorParameterCodeNotAllowed = {0} i\u00E7in Ledger parametresi yap\u0131land\u0131r\u0131lm\u0131\u015F kodlardan birini kullanmal\u0131d\u0131r. + +LedgerStructuralValidatorParameterMustUseValueKind = {0}, de\u011Fer t\u00FCr\u00FC {1} kullanmal\u0131d\u0131r. + +LedgerStructuralValidatorParameterTypeRequired = Ledger parametresi {0} bir parametre t\u00FCr\u00FCne sahip olmal\u0131d\u0131r. + +LedgerStructuralValidatorParameterValueKindMismatch = Ledger parametresi de\u011Feri, de\u011Fer t\u00FCr\u00FC {0} ile e\u015Fle\u015Fmelidir. + +LedgerStructuralValidatorParameterValueKindRequired = Ledger parametresi {0} de\u011Fer t\u00FCr\u00FCn\u00FC belirtmelidir. + +LedgerStructuralValidatorParameterValueRequired = Ledger parametresi {0} bir de\u011Fer i\u00E7ermelidir. + +LedgerStructuralValidatorPortfolioProjectionAccountNotAllowed = Bir menkul k\u0131ymet hesab\u0131 projeksiyonu yaln\u0131zca bir menkul k\u0131ymet hesab\u0131na ba\u015Fvurmal\u0131d\u0131r; hesap ba\u015Fvurusunu kald\u0131r\u0131n: {0} + +LedgerStructuralValidatorPostingCurrencyRequired = Ledger kayd\u0131 {0} bir para birimine sahip olmal\u0131d\u0131r. + +LedgerStructuralValidatorPostingExchangeRatePositive = Ledger kayd\u0131 {0} s\u0131f\u0131rdan b\u00FCy\u00FCk bir d\u00F6viz kuruna sahip olmal\u0131d\u0131r. + +LedgerStructuralValidatorPostingGroupRefNotFound = Kay\u0131t grubu ba\u015Fvurusu {0}, ayn\u0131 giri\u015Fteki bir kayd\u0131 g\u00F6stermelidir. + +LedgerStructuralValidatorPostingSecurityRequired = Menkul k\u0131ymet kayd\u0131 {0} bir menkul k\u0131ymete ba\u015Fvurmal\u0131d\u0131r. + +LedgerStructuralValidatorPostingTypeRequired = Ledger kayd\u0131 {0} bir kay\u0131t t\u00FCr\u00FCne sahip olmal\u0131d\u0131r. + +LedgerStructuralValidatorPostingUuidRequired = Ledger giri\u015Fi {0} UUID olmayan bir kay\u0131t i\u00E7eriyor. + +LedgerStructuralValidatorPrimaryPostingRefNotFound = Birincil kay\u0131t ba\u015Fvurusu {0}, ayn\u0131 giri\u015Fteki bir kayd\u0131 g\u00F6stermelidir. + +LedgerStructuralValidatorProjectionAccountRequired = Ledger projeksiyonu {0} bir hesaba ba\u015Fvurmal\u0131d\u0131r. + +LedgerStructuralValidatorProjectionGroupTargetConflict = Bir projeksiyon ya \u00FCyelik ba\u015Fvurular\u0131 ya da tek bir kay\u0131t grubu ba\u015Fvurusu kullanmal\u0131d\u0131r, ikisini birden de\u011Fil. + +LedgerStructuralValidatorProjectionMembershipRefNotFound = Projeksiyon \u00FCyelik ba\u015Fvurusu {0}, ayn\u0131 giri\u015Fteki bir kayd\u0131 g\u00F6stermelidir. + +LedgerStructuralValidatorProjectionPortfolioRequired = Ledger projeksiyonu {0} bir menkul k\u0131ymet hesab\u0131na ba\u015Fvurmal\u0131d\u0131r. + +LedgerStructuralValidatorProjectionPrimaryTargetConflict = Bir projeksiyon ya \u00FCyelik ba\u015Fvurular\u0131 ya da tek bir birincil kay\u0131t ba\u015Fvurusu kullanmal\u0131d\u0131r, ikisini birden de\u011Fil. + +LedgerStructuralValidatorProjectionRoleNotAllowed = Projeksiyon rol\u00FC {0}, giri\u015F t\u00FCr\u00FC {1} i\u00E7in ge\u00E7erli de\u011Fildir. + +LedgerStructuralValidatorProjectionRoleRequired = Ledger projeksiyonu {0} bir projeksiyon rol\u00FCne sahip olmal\u0131d\u0131r. + +LedgerStructuralValidatorProjectionRoleRequiredForType = Giri\u015F t\u00FCr\u00FC {0}, {1} rol\u00FCne sahip tam olarak bir projeksiyona sahip olmal\u0131d\u0131r. + +LedgerStructuralValidatorProjectionUuidRequired = Ledger giri\u015Fi {0} UUID olmayan bir projeksiyon i\u00E7eriyor. + +LedgerStructuralValidatorSignedFactsNotAllowed = Giri\u015F t\u00FCr\u00FC {0} yaln\u0131zca pozitif kay\u0131t de\u011Ferlerini kabul eder. Pozitif tutarlar ve adetler kullan\u0131n. + +LedgerStructuralValidatorTargetingRefRequired = Hedefli Ledger projeksiyonlar\u0131 bir birincil kayd\u0131 g\u00F6stermelidir. + +LedgerXmlInvalidLedgerStructure = Ledger yap\u0131s\u0131 ge\u00E7ersiz:\n{0} + MsgAlphaVantageAPIKeyMissing = Alpha Vantage API anahtar\u0131 eksik. Ayarlardan yap\u0131land\u0131r\u0131n. MsgCheckConfiguredAndCalculatedGrossValueDoNotMatch = \u0130thal edilen br\u00FCt de\u011Fer ({0}) ve hesaplanan br\u00FCt de\u011Fer ({1}) e\u015Fle\u015Fmiyor diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_vi.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_vi.properties index 0e5aea599e..e24db90b5f 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_vi.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_vi.properties @@ -469,6 +469,148 @@ LabelYahooFinance = Yahoo Finance LabelYahooFinanceAdjustedClose = Yahoo Finance (Adjusted Close) +LedgerDiagnosticMessageFormatterAccount = T\u00E0i kho\u1EA3n + +LedgerDiagnosticMessageFormatterAmount = Gi\u00E1 tr\u1ECB + +LedgerDiagnosticMessageFormatterContextUnavailable = kh\u00F4ng kh\u1EA3 d\u1EE5ng + +LedgerDiagnosticMessageFormatterDate = Ng\u00E0y + +LedgerDiagnosticMessageFormatterIsin = ISIN + +LedgerDiagnosticMessageFormatterNote = Ghi ch\u00FA + +LedgerDiagnosticMessageFormatterPortfolio = T\u00E0i kho\u1EA3n ch\u1EE9ng kho\u00E1n + +LedgerDiagnosticMessageFormatterSecurity = Ch\u1EE9ng kho\u00E1n + +LedgerDiagnosticMessageFormatterShares = S\u1ED1 l\u01B0\u1EE3ng + +LedgerDiagnosticMessageFormatterSource = Ngu\u1ED3n + +LedgerDiagnosticMessageFormatterTicker = Ticker + +LedgerDiagnosticMessageFormatterTransactionContext = Chi ti\u1EBFt giao d\u1ECBch + +LedgerDiagnosticMessageFormatterType = Lo\u1EA1i + +LedgerDiagnosticMessageFormatterUnnamedAccount = t\u00E0i kho\u1EA3n ch\u01B0a \u0111\u1EB7t t\u00EAn + +LedgerDiagnosticMessageFormatterUnnamedPortfolio = t\u00E0i kho\u1EA3n ch\u1EE9ng kho\u00E1n ch\u01B0a \u0111\u1EB7t t\u00EAn + +LedgerDiagnosticMessageFormatterUnnamedSecurity = ch\u1EE9ng kho\u00E1n ch\u01B0a \u0111\u1EB7t t\u00EAn + +LedgerDiagnosticMessageFormatterWkn = WKN + +LedgerInsertActionGeneratedBuyMissingBuySellEntry = Giao d\u1ECBch mua \u0111\u01B0\u1EE3c t\u1EA1o do Ledger qu\u1EA3n l\u00FD, nh\u01B0ng kh\u00F4ng t\u00ECm th\u1EA5y m\u1EE5c mua/b\u00E1n li\u00EAn k\u1EBFt. + +LedgerInsertActionGeneratedBuyTypeMismatch = Giao d\u1ECBch mua Ledger \u0111\u01B0\u1EE3c t\u1EA1o n\u00E0y ch\u1EC9 c\u00F3 th\u1EC3 \u0111\u01B0\u1EE3c c\u1EADp nh\u1EADt t\u1EEB giao d\u1ECBch mua \u0111\u00E3 nh\u1EADp. + +LedgerInsertActionGeneratedDeliveryOwnerNotFound = Kh\u00F4ng t\u00ECm th\u1EA5y ch\u1EE7 s\u1EDF h\u1EEFu c\u1EE7a giao d\u1ECBch nh\u1EADn ch\u1EE9ng kho\u00E1n Ledger \u0111\u01B0\u1EE3c t\u1EA1o. + +LedgerInsertActionGeneratedDeliveryTypeMismatch = Giao d\u1ECBch nh\u1EADn ch\u1EE9ng kho\u00E1n Ledger \u0111\u01B0\u1EE3c t\u1EA1o n\u00E0y ch\u1EC9 c\u00F3 th\u1EC3 \u0111\u01B0\u1EE3c c\u1EADp nh\u1EADt t\u1EEB giao d\u1ECBch mua \u0111\u00E3 nh\u1EADp. + +LedgerInsertActionInvestmentPlanLegacySettersNotSupported = C\u00E1c giao d\u1ECBch k\u1EBF ho\u1EA1ch \u0111\u1EA7u t\u01B0 do Ledger qu\u1EA3n l\u00FD kh\u00F4ng th\u1EC3 \u0111\u01B0\u1EE3c c\u1EADp nh\u1EADt b\u1EB1ng setter nh\u1EADp c\u0169. H\u00E3y d\u00F9ng lu\u1ED3ng c\u1EADp nh\u1EADt Ledger. + +LedgerInsertActionUnsupportedInvestmentPlanTransactionUpdateType = Lo\u1EA1i c\u1EADp nh\u1EADt k\u1EBF ho\u1EA1ch \u0111\u1EA7u t\u01B0 {0} kh\u00F4ng \u0111\u01B0\u1EE3c h\u1ED7 tr\u1EE3 cho giao d\u1ECBch do Ledger qu\u1EA3n l\u00FD. + +LedgerParameterMissingAttribute = Tham s\u1ED1 Ledger {0} thi\u1EBFu thu\u1ED9c t\u00EDnh b\u1EAFt bu\u1ED9c. + +LedgerParameterMissingType = Tham s\u1ED1 Ledger ph\u1EA3i c\u00F3 lo\u1EA1i. + +LedgerParameterMissingValueKind = Tham s\u1ED1 Ledger ph\u1EA3i khai b\u00E1o ki\u1EC3u gi\u00E1 tr\u1ECB. + +LedgerParameterReferenceValueMissingValueNode = Gi\u00E1 tr\u1ECB tham chi\u1EBFu c\u1EE7a tham s\u1ED1 Ledger ph\u1EA3i ch\u1EE9a n\u00FAt gi\u00E1 tr\u1ECB. + +LedgerParameterUnsupportedValueKind = Ki\u1EC3u gi\u00E1 tr\u1ECB tham s\u1ED1 Ledger {0} kh\u00F4ng \u0111\u01B0\u1EE3c h\u1ED7 tr\u1EE3. + +LedgerParameterValueKindRequiresStructuredValue = Ki\u1EC3u gi\u00E1 tr\u1ECB tham s\u1ED1 Ledger {0} y\u00EAu c\u1EA7u gi\u00E1 tr\u1ECB c\u00F3 c\u1EA5u tr\u00FAc. + +LedgerProtobufBooleanParameterMissingBooleanValue = Tham s\u1ED1 Ledger BOOLEAN ph\u1EA3i ch\u1EE9a booleanValue. + +LedgerProtobufInvalidLedgerPersistenceState = D\u1EEF li\u1EC7u Ledger Protobuf \u0111\u00E3 l\u01B0u kh\u00F4ng nh\u1EA5t qu\u00E1n:\n{0} + +LedgerProtobufLocalDateParameterMissingLocalDateValue = Tham s\u1ED1 Ledger LOCAL_DATE ph\u1EA3i ch\u1EE9a localDateValue. + +LedgerRuntimeProjectionRestorerInvalidLedger = \u0110\u00E3 b\u1ECF qua vi\u1EC7c kh\u00F4i ph\u1EE5c ph\u00E9p chi\u1EBFu khi ch\u1EA1y v\u00EC d\u1EEF li\u1EC7u Ledger \u0111\u00E3 l\u01B0u kh\u00F4ng h\u1EE3p l\u1EC7. D\u1EEF li\u1EC7u Ledger kh\u00F4ng b\u1ECB thay \u0111\u1ED5i. Ch\u1EA9n \u0111o\u00E1n:\n{0} + +LedgerRuntimeProjectionRestorerPartiallyRestored = Ch\u1EC9 kh\u00F4i ph\u1EE5c ph\u00E9p chi\u1EBFu khi ch\u1EA1y cho c\u00E1c b\u1EA3n ghi Ledger h\u1EE3p l\u1EC7. \u0110\u00E3 x\u00F3a {0} ph\u00E9p chi\u1EBFu c\u0169 v\u00E0 t\u1EA1o l\u1EA1i {1} ph\u00E9p chi\u1EBFu h\u1EE3p l\u1EC7. B\u1EA3n ghi Ledger kh\u00F4ng h\u1EE3p l\u1EC7 b\u1ECB b\u1ECF qua: {2}. Ch\u1EE7 s\u1EDF h\u1EEFu b\u1ECB \u1EA3nh h\u01B0\u1EDFng: {3}. UUID ph\u00E9p chi\u1EBFu b\u1ECB thi\u1EBFu: {4}. UUID ph\u00E9p chi\u1EBFu tr\u00F9ng l\u1EB7p: {5}. UUID ph\u00E9p chi\u1EBFu l\u1ED7i th\u1EDDi: {6}. D\u1EEF li\u1EC7u Ledger \u0111\u00E3 l\u01B0u kh\u00F4ng b\u1ECB thay \u0111\u1ED5i. Ch\u1EA9n \u0111o\u00E1n:\n{7} + +LedgerRuntimeProjectionRestorerRestored = \u0110\u00E3 kh\u00F4i ph\u1EE5c ph\u00E9p chi\u1EBFu khi ch\u1EA1y t\u1EEB d\u1EEF li\u1EC7u Ledger h\u1EE3p l\u1EC7. \u0110\u00E3 x\u00F3a {0} ph\u00E9p chi\u1EBFu c\u0169 v\u00E0 t\u1EA1o l\u1EA1i {1} ph\u00E9p chi\u1EBFu. Ch\u1EE7 s\u1EDF h\u1EEFu b\u1ECB \u1EA3nh h\u01B0\u1EDFng: {2}. UUID ph\u00E9p chi\u1EBFu b\u1ECB thi\u1EBFu: {3}. UUID ph\u00E9p chi\u1EBFu tr\u00F9ng l\u1EB7p: {4}. UUID ph\u00E9p chi\u1EBFu l\u1ED7i th\u1EDDi: {5}. D\u1EEF li\u1EC7u Ledger \u0111\u00E3 l\u01B0u kh\u00F4ng b\u1ECB thay \u0111\u1ED5i. + +LedgerStructuralValidatorAccountProjectionPortfolioNotAllowed = Ph\u00E9p chi\u1EBFu t\u00E0i kho\u1EA3n ch\u1EC9 \u0111\u01B0\u1EE3c tham chi\u1EBFu \u0111\u1EBFn m\u1ED9t t\u00E0i kho\u1EA3n; h\u00E3y x\u00F3a tham chi\u1EBFu t\u00E0i kho\u1EA3n ch\u1EE9ng kho\u00E1n: {0} + +LedgerStructuralValidatorDividendSecurityRequired = B\u1EA3n ghi c\u1ED5 t\u1EE9c ph\u1EA3i tham chi\u1EBFu \u0111\u1EBFn m\u1ED9t b\u00FAt to\u00E1n ch\u1EE9ng kho\u00E1n: {0} + +LedgerStructuralValidatorDuplicateEntryUuid = M\u1ED7i b\u1EA3n ghi Ledger ph\u1EA3i c\u00F3 UUID duy nh\u1EA5t. Tr\u00F9ng l\u1EB7p: {0} + +LedgerStructuralValidatorDuplicatePostingUuid = M\u1ED7i b\u00FAt to\u00E1n Ledger ph\u1EA3i c\u00F3 UUID duy nh\u1EA5t. Tr\u00F9ng l\u1EB7p: {0} + +LedgerStructuralValidatorDuplicateProjectionUuid = M\u1ED7i ph\u00E9p chi\u1EBFu Ledger ph\u1EA3i c\u00F3 UUID duy nh\u1EA5t. Tr\u00F9ng l\u1EB7p: {0} + +LedgerStructuralValidatorEntryDateTimeRequired = B\u1EA3n ghi Ledger {0} ph\u1EA3i c\u00F3 ng\u00E0y v\u00E0 gi\u1EDD. + +LedgerStructuralValidatorEntryTypeRequired = B\u1EA3n ghi Ledger {0} ph\u1EA3i c\u00F3 lo\u1EA1i b\u1EA3n ghi. + +LedgerStructuralValidatorEntryUuidRequired = M\u1ED7i b\u1EA3n ghi Ledger ph\u1EA3i c\u00F3 UUID. + +LedgerStructuralValidatorExDateSecurityRequired = EX_DATE ch\u1EC9 c\u00F3 th\u1EC3 d\u00F9ng tr\u00EAn c\u00E1c b\u00FAt to\u00E1n tham chi\u1EBFu \u0111\u1EBFn ch\u1EE9ng kho\u00E1n: {0} + +LedgerStructuralValidatorLedgerRequired = C\u1EA7n c\u00F3 \u0111\u1ED1i t\u01B0\u1EE3ng Ledger. + +LedgerStructuralValidatorParameterCodeNotAllowed = Tham s\u1ED1 Ledger cho {0} ph\u1EA3i d\u00F9ng m\u1ED9t trong c\u00E1c m\u00E3 \u0111\u00E3 c\u1EA5u h\u00ECnh. + +LedgerStructuralValidatorParameterMustUseValueKind = {0} ph\u1EA3i d\u00F9ng ki\u1EC3u gi\u00E1 tr\u1ECB {1}. + +LedgerStructuralValidatorParameterTypeRequired = Tham s\u1ED1 Ledger {0} ph\u1EA3i c\u00F3 lo\u1EA1i tham s\u1ED1. + +LedgerStructuralValidatorParameterValueKindMismatch = Gi\u00E1 tr\u1ECB tham s\u1ED1 Ledger ph\u1EA3i kh\u1EDBp v\u1EDBi ki\u1EC3u gi\u00E1 tr\u1ECB {0}. + +LedgerStructuralValidatorParameterValueKindRequired = Tham s\u1ED1 Ledger {0} ph\u1EA3i khai b\u00E1o ki\u1EC3u gi\u00E1 tr\u1ECB. + +LedgerStructuralValidatorParameterValueRequired = Tham s\u1ED1 Ledger {0} ph\u1EA3i ch\u1EE9a m\u1ED9t gi\u00E1 tr\u1ECB. + +LedgerStructuralValidatorPortfolioProjectionAccountNotAllowed = Ph\u00E9p chi\u1EBFu t\u00E0i kho\u1EA3n ch\u1EE9ng kho\u00E1n ch\u1EC9 \u0111\u01B0\u1EE3c tham chi\u1EBFu \u0111\u1EBFn t\u00E0i kho\u1EA3n ch\u1EE9ng kho\u00E1n; h\u00E3y x\u00F3a tham chi\u1EBFu t\u00E0i kho\u1EA3n: {0} + +LedgerStructuralValidatorPostingCurrencyRequired = B\u00FAt to\u00E1n Ledger {0} ph\u1EA3i c\u00F3 \u0111\u01A1n v\u1ECB ti\u1EC1n t\u1EC7. + +LedgerStructuralValidatorPostingExchangeRatePositive = B\u00FAt to\u00E1n Ledger {0} ph\u1EA3i c\u00F3 t\u1EF7 gi\u00E1 l\u1EDBn h\u01A1n kh\u00F4ng. + +LedgerStructuralValidatorPostingGroupRefNotFound = Tham chi\u1EBFu nh\u00F3m b\u00FAt to\u00E1n {0} ph\u1EA3i tr\u1ECF \u0111\u1EBFn m\u1ED9t b\u00FAt to\u00E1n trong c\u00F9ng b\u1EA3n ghi. + +LedgerStructuralValidatorPostingSecurityRequired = B\u00FAt to\u00E1n ch\u1EE9ng kho\u00E1n {0} ph\u1EA3i tham chi\u1EBFu \u0111\u1EBFn ch\u1EE9ng kho\u00E1n. + +LedgerStructuralValidatorPostingTypeRequired = B\u00FAt to\u00E1n Ledger {0} ph\u1EA3i c\u00F3 lo\u1EA1i b\u00FAt to\u00E1n. + +LedgerStructuralValidatorPostingUuidRequired = B\u1EA3n ghi Ledger {0} ch\u1EE9a b\u00FAt to\u00E1n kh\u00F4ng c\u00F3 UUID. + +LedgerStructuralValidatorPrimaryPostingRefNotFound = Tham chi\u1EBFu b\u00FAt to\u00E1n ch\u00EDnh {0} ph\u1EA3i tr\u1ECF \u0111\u1EBFn m\u1ED9t b\u00FAt to\u00E1n trong c\u00F9ng b\u1EA3n ghi. + +LedgerStructuralValidatorProjectionAccountRequired = Ph\u00E9p chi\u1EBFu Ledger {0} ph\u1EA3i tham chi\u1EBFu \u0111\u1EBFn t\u00E0i kho\u1EA3n. + +LedgerStructuralValidatorProjectionGroupTargetConflict = M\u1ED9t ph\u00E9p chi\u1EBFu ph\u1EA3i d\u00F9ng tham chi\u1EBFu th\u00E0nh vi\u00EAn ho\u1EB7c m\u1ED9t tham chi\u1EBFu nh\u00F3m b\u00FAt to\u00E1n duy nh\u1EA5t, kh\u00F4ng d\u00F9ng c\u1EA3 hai. + +LedgerStructuralValidatorProjectionMembershipRefNotFound = Tham chi\u1EBFu th\u00E0nh vi\u00EAn ph\u00E9p chi\u1EBFu {0} ph\u1EA3i tr\u1ECF \u0111\u1EBFn m\u1ED9t b\u00FAt to\u00E1n trong c\u00F9ng b\u1EA3n ghi. + +LedgerStructuralValidatorProjectionPortfolioRequired = Ph\u00E9p chi\u1EBFu Ledger {0} ph\u1EA3i tham chi\u1EBFu \u0111\u1EBFn t\u00E0i kho\u1EA3n ch\u1EE9ng kho\u00E1n. + +LedgerStructuralValidatorProjectionPrimaryTargetConflict = M\u1ED9t ph\u00E9p chi\u1EBFu ph\u1EA3i d\u00F9ng tham chi\u1EBFu th\u00E0nh vi\u00EAn ho\u1EB7c m\u1ED9t tham chi\u1EBFu b\u00FAt to\u00E1n ch\u00EDnh duy nh\u1EA5t, kh\u00F4ng d\u00F9ng c\u1EA3 hai. + +LedgerStructuralValidatorProjectionRoleNotAllowed = Vai tr\u00F2 ph\u00E9p chi\u1EBFu {0} kh\u00F4ng h\u1EE3p l\u1EC7 cho lo\u1EA1i b\u1EA3n ghi {1}. + +LedgerStructuralValidatorProjectionRoleRequired = Ph\u00E9p chi\u1EBFu Ledger {0} ph\u1EA3i c\u00F3 vai tr\u00F2 ph\u00E9p chi\u1EBFu. + +LedgerStructuralValidatorProjectionRoleRequiredForType = Lo\u1EA1i b\u1EA3n ghi {0} ph\u1EA3i c\u00F3 \u0111\u00FAng m\u1ED9t ph\u00E9p chi\u1EBFu v\u1EDBi vai tr\u00F2 {1}. + +LedgerStructuralValidatorProjectionUuidRequired = B\u1EA3n ghi Ledger {0} ch\u1EE9a ph\u00E9p chi\u1EBFu kh\u00F4ng c\u00F3 UUID. + +LedgerStructuralValidatorSignedFactsNotAllowed = Lo\u1EA1i b\u1EA3n ghi {0} ch\u1EC9 ch\u1EA5p nh\u1EADn gi\u00E1 tr\u1ECB b\u00FAt to\u00E1n d\u01B0\u01A1ng. H\u00E3y d\u00F9ng s\u1ED1 ti\u1EC1n v\u00E0 s\u1ED1 l\u01B0\u1EE3ng d\u01B0\u01A1ng. + +LedgerStructuralValidatorTargetingRefRequired = Ph\u00E9p chi\u1EBFu Ledger c\u00F3 \u0111\u00EDch ph\u1EA3i tr\u1ECF \u0111\u1EBFn b\u00FAt to\u00E1n ch\u00EDnh. + +LedgerXmlInvalidLedgerStructure = C\u1EA5u tr\u00FAc Ledger kh\u00F4ng h\u1EE3p l\u1EC7:\n{0} + MsgAlphaVantageAPIKeyMissing = Kh\u00F3a API Alpha Vantage b\u1ECB thi\u1EBFu. Vui l\u00F2ng c\u1EA5u h\u00ECnh trong ph\u1EA7n c\u00E0i \u0111\u1EB7t. MsgCheckConfiguredAndCalculatedGrossValueDoNotMatch = Gi\u00E1 tr\u1ECB g\u1ED9p nh\u1EADp kh\u1EA9u ({0}) v\u00E0 gi\u00E1 tr\u1ECB g\u1ED9p t\u00EDnh to\u00E1n ({1}) kh\u00F4ng kh\u1EDBp diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_zh.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_zh.properties index 1c604b2d56..5a69e51c2c 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_zh.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_zh.properties @@ -472,6 +472,148 @@ LabelYahooFinance = Yahoo Finance LabelYahooFinanceAdjustedClose = Yahoo Finance (\u8C03\u6574\u540E\u6536\u76D8\u4EF7) +LedgerDiagnosticMessageFormatterAccount = \u8D26\u6237 + +LedgerDiagnosticMessageFormatterAmount = \u91D1\u989D + +LedgerDiagnosticMessageFormatterContextUnavailable = \u4E0D\u53EF\u7528 + +LedgerDiagnosticMessageFormatterDate = \u65E5\u671F + +LedgerDiagnosticMessageFormatterIsin = ISIN + +LedgerDiagnosticMessageFormatterNote = \u5907\u6CE8 + +LedgerDiagnosticMessageFormatterPortfolio = \u8BC1\u5238\u8D26\u6237 + +LedgerDiagnosticMessageFormatterSecurity = \u8BC1\u5238 + +LedgerDiagnosticMessageFormatterShares = \u4EFD\u989D + +LedgerDiagnosticMessageFormatterSource = \u6765\u6E90 + +LedgerDiagnosticMessageFormatterTicker = \u4EE3\u7801 + +LedgerDiagnosticMessageFormatterTransactionContext = \u4EA4\u6613\u8BE6\u7EC6\u4FE1\u606F + +LedgerDiagnosticMessageFormatterType = \u7C7B\u578B + +LedgerDiagnosticMessageFormatterUnnamedAccount = \u672A\u547D\u540D\u8D26\u6237 + +LedgerDiagnosticMessageFormatterUnnamedPortfolio = \u672A\u547D\u540D\u8BC1\u5238\u8D26\u6237 + +LedgerDiagnosticMessageFormatterUnnamedSecurity = \u672A\u547D\u540D\u8BC1\u5238 + +LedgerDiagnosticMessageFormatterWkn = WKN + +LedgerInsertActionGeneratedBuyMissingBuySellEntry = \u751F\u6210\u7684\u4E70\u5165\u4EA4\u6613\u7531 Ledger \u7BA1\u7406\uFF0C\u4F46\u672A\u627E\u5230\u5173\u8054\u7684\u4E70\u5165/\u5356\u51FA\u6761\u76EE\u3002 + +LedgerInsertActionGeneratedBuyTypeMismatch = \u6B64\u751F\u6210\u7684 Ledger \u4E70\u5165\u4EA4\u6613\u53EA\u80FD\u7531\u5BFC\u5165\u7684\u4E70\u5165\u4EA4\u6613\u66F4\u65B0\u3002 + +LedgerInsertActionGeneratedDeliveryOwnerNotFound = \u672A\u627E\u5230\u751F\u6210\u7684 Ledger \u4EA4\u5272\u6240\u6709\u8005\u3002 + +LedgerInsertActionGeneratedDeliveryTypeMismatch = \u6B64\u751F\u6210\u7684 Ledger \u4EA4\u5272\u53EA\u80FD\u7531\u5BFC\u5165\u7684\u4E70\u5165\u4EA4\u6613\u66F4\u65B0\u3002 + +LedgerInsertActionInvestmentPlanLegacySettersNotSupported = Ledger \u7BA1\u7406\u7684\u6295\u8D44\u8BA1\u5212\u4EA4\u6613\u4E0D\u80FD\u901A\u8FC7\u65E7\u7684\u5BFC\u5165 setter \u66F4\u65B0\u3002\u8BF7\u4F7F\u7528 Ledger \u66F4\u65B0\u8DEF\u5F84\u3002 + +LedgerInsertActionUnsupportedInvestmentPlanTransactionUpdateType = Ledger \u7BA1\u7406\u7684\u4EA4\u6613\u4E0D\u652F\u6301\u6295\u8D44\u8BA1\u5212\u66F4\u65B0\u7C7B\u578B {0}\u3002 + +LedgerParameterMissingAttribute = Ledger \u53C2\u6570 {0} \u7F3A\u5C11\u5FC5\u9700\u5C5E\u6027\u3002 + +LedgerParameterMissingType = Ledger \u53C2\u6570\u5FC5\u987B\u5177\u6709\u7C7B\u578B\u3002 + +LedgerParameterMissingValueKind = Ledger \u53C2\u6570\u5FC5\u987B\u58F0\u660E\u503C\u7C7B\u578B\u3002 + +LedgerParameterReferenceValueMissingValueNode = Ledger \u53C2\u6570\u5F15\u7528\u503C\u5FC5\u987B\u5305\u542B\u503C\u8282\u70B9\u3002 + +LedgerParameterUnsupportedValueKind = \u4E0D\u652F\u6301 Ledger \u53C2\u6570\u503C\u7C7B\u578B {0}\u3002 + +LedgerParameterValueKindRequiresStructuredValue = Ledger \u53C2\u6570\u503C\u7C7B\u578B {0} \u9700\u8981\u7ED3\u6784\u5316\u503C\u3002 + +LedgerProtobufBooleanParameterMissingBooleanValue = BOOLEAN Ledger \u53C2\u6570\u5FC5\u987B\u5305\u542B booleanValue\u3002 + +LedgerProtobufInvalidLedgerPersistenceState = \u4FDD\u5B58\u7684 Ledger Protobuf \u6570\u636E\u4E0D\u4E00\u81F4\uFF1A\n{0} + +LedgerProtobufLocalDateParameterMissingLocalDateValue = LOCAL_DATE Ledger \u53C2\u6570\u5FC5\u987B\u5305\u542B localDateValue\u3002 + +LedgerRuntimeProjectionRestorerInvalidLedger = \u7531\u4E8E\u4FDD\u5B58\u7684 Ledger \u6570\u636E\u65E0\u6548\uFF0C\u5DF2\u8DF3\u8FC7\u8FD0\u884C\u65F6\u6295\u5F71\u6062\u590D\u3002Ledger \u6570\u636E\u672A\u66F4\u6539\u3002\u8BCA\u65AD\uFF1A\n{0} + +LedgerRuntimeProjectionRestorerPartiallyRestored = \u4EC5\u4E3A\u6709\u6548\u7684 Ledger \u6761\u76EE\u6062\u590D\u4E86\u8FD0\u884C\u65F6\u6295\u5F71\u3002\u5DF2\u5220\u9664 {0} \u4E2A\u65E7\u8FD0\u884C\u65F6\u6295\u5F71\u5E76\u91CD\u65B0\u521B\u5EFA {1} \u4E2A\u6709\u6548\u6295\u5F71\u3002\u5DF2\u8DF3\u8FC7\u65E0\u6548 Ledger \u6761\u76EE\uFF1A{2}\u3002\u53D7\u5F71\u54CD\u6240\u6709\u8005\uFF1A{3}\u3002\u7F3A\u5931\u6295\u5F71 UUID\uFF1A{4}\u3002\u91CD\u590D\u6295\u5F71 UUID\uFF1A{5}\u3002\u8FC7\u671F\u6295\u5F71 UUID\uFF1A{6}\u3002\u4FDD\u5B58\u7684 Ledger \u6570\u636E\u672A\u66F4\u6539\u3002\u8BCA\u65AD\uFF1A\n{7} + +LedgerRuntimeProjectionRestorerRestored = \u5DF2\u4ECE\u6709\u6548 Ledger \u6570\u636E\u6062\u590D\u8FD0\u884C\u65F6\u6295\u5F71\u3002\u5DF2\u5220\u9664 {0} \u4E2A\u65E7\u8FD0\u884C\u65F6\u6295\u5F71\u5E76\u91CD\u65B0\u521B\u5EFA {1} \u4E2A\u6295\u5F71\u3002\u53D7\u5F71\u54CD\u6240\u6709\u8005\uFF1A{2}\u3002\u7F3A\u5931\u6295\u5F71 UUID\uFF1A{3}\u3002\u91CD\u590D\u6295\u5F71 UUID\uFF1A{4}\u3002\u8FC7\u671F\u6295\u5F71 UUID\uFF1A{5}\u3002\u4FDD\u5B58\u7684 Ledger \u6570\u636E\u672A\u66F4\u6539\u3002 + +LedgerStructuralValidatorAccountProjectionPortfolioNotAllowed = \u8D26\u6237\u6295\u5F71\u53EA\u80FD\u5F15\u7528\u8D26\u6237\uFF1B\u8BF7\u79FB\u9664\u8BC1\u5238\u8D26\u6237\u5F15\u7528\uFF1A{0} + +LedgerStructuralValidatorDividendSecurityRequired = \u80A1\u606F\u6761\u76EE\u5FC5\u987B\u5F15\u7528\u8BC1\u5238\u8FC7\u8D26\uFF1A{0} + +LedgerStructuralValidatorDuplicateEntryUuid = \u6BCF\u4E2A Ledger \u6761\u76EE\u90FD\u5FC5\u987B\u5177\u6709\u552F\u4E00 UUID\u3002\u91CD\u590D\u9879\uFF1A{0} + +LedgerStructuralValidatorDuplicatePostingUuid = \u6BCF\u4E2A Ledger \u8FC7\u8D26\u90FD\u5FC5\u987B\u5177\u6709\u552F\u4E00 UUID\u3002\u91CD\u590D\u9879\uFF1A{0} + +LedgerStructuralValidatorDuplicateProjectionUuid = \u6BCF\u4E2A Ledger \u6295\u5F71\u90FD\u5FC5\u987B\u5177\u6709\u552F\u4E00 UUID\u3002\u91CD\u590D\u9879\uFF1A{0} + +LedgerStructuralValidatorEntryDateTimeRequired = Ledger \u6761\u76EE {0} \u5FC5\u987B\u5177\u6709\u65E5\u671F\u548C\u65F6\u95F4\u3002 + +LedgerStructuralValidatorEntryTypeRequired = Ledger \u6761\u76EE {0} \u5FC5\u987B\u5177\u6709\u6761\u76EE\u7C7B\u578B\u3002 + +LedgerStructuralValidatorEntryUuidRequired = \u6BCF\u4E2A Ledger \u6761\u76EE\u90FD\u5FC5\u987B\u5177\u6709 UUID\u3002 + +LedgerStructuralValidatorExDateSecurityRequired = EX_DATE \u53EA\u80FD\u7528\u4E8E\u5F15\u7528\u8BC1\u5238\u7684\u8FC7\u8D26\uFF1A{0} + +LedgerStructuralValidatorLedgerRequired = \u9700\u8981 Ledger \u5BF9\u8C61\u3002 + +LedgerStructuralValidatorParameterCodeNotAllowed = {0} \u7684 Ledger \u53C2\u6570\u5FC5\u987B\u4F7F\u7528\u5DF2\u914D\u7F6E\u4EE3\u7801\u4E4B\u4E00\u3002 + +LedgerStructuralValidatorParameterMustUseValueKind = {0} \u5FC5\u987B\u4F7F\u7528\u503C\u7C7B\u578B {1}\u3002 + +LedgerStructuralValidatorParameterTypeRequired = Ledger \u53C2\u6570 {0} \u5FC5\u987B\u5177\u6709\u53C2\u6570\u7C7B\u578B\u3002 + +LedgerStructuralValidatorParameterValueKindMismatch = Ledger \u53C2\u6570\u503C\u5FC5\u987B\u4E0E\u503C\u7C7B\u578B {0} \u5339\u914D\u3002 + +LedgerStructuralValidatorParameterValueKindRequired = Ledger \u53C2\u6570 {0} \u5FC5\u987B\u58F0\u660E\u503C\u7C7B\u578B\u3002 + +LedgerStructuralValidatorParameterValueRequired = Ledger \u53C2\u6570 {0} \u5FC5\u987B\u5305\u542B\u503C\u3002 + +LedgerStructuralValidatorPortfolioProjectionAccountNotAllowed = \u8BC1\u5238\u8D26\u6237\u6295\u5F71\u53EA\u80FD\u5F15\u7528\u8BC1\u5238\u8D26\u6237\uFF1B\u8BF7\u79FB\u9664\u8D26\u6237\u5F15\u7528\uFF1A{0} + +LedgerStructuralValidatorPostingCurrencyRequired = Ledger \u8FC7\u8D26 {0} \u5FC5\u987B\u5177\u6709\u5E01\u79CD\u3002 + +LedgerStructuralValidatorPostingExchangeRatePositive = Ledger \u8FC7\u8D26 {0} \u5FC5\u987B\u5177\u6709\u5927\u4E8E\u96F6\u7684\u6C47\u7387\u3002 + +LedgerStructuralValidatorPostingGroupRefNotFound = \u8FC7\u8D26\u7EC4\u5F15\u7528 {0} \u5FC5\u987B\u6307\u5411\u540C\u4E00\u6761\u76EE\u4E2D\u7684\u8FC7\u8D26\u3002 + +LedgerStructuralValidatorPostingSecurityRequired = \u8BC1\u5238\u8FC7\u8D26 {0} \u5FC5\u987B\u5F15\u7528\u8BC1\u5238\u3002 + +LedgerStructuralValidatorPostingTypeRequired = Ledger \u8FC7\u8D26 {0} \u5FC5\u987B\u5177\u6709\u8FC7\u8D26\u7C7B\u578B\u3002 + +LedgerStructuralValidatorPostingUuidRequired = Ledger \u6761\u76EE {0} \u5305\u542B\u6CA1\u6709 UUID \u7684\u8FC7\u8D26\u3002 + +LedgerStructuralValidatorPrimaryPostingRefNotFound = \u4E3B\u8FC7\u8D26\u5F15\u7528 {0} \u5FC5\u987B\u6307\u5411\u540C\u4E00\u6761\u76EE\u4E2D\u7684\u8FC7\u8D26\u3002 + +LedgerStructuralValidatorProjectionAccountRequired = Ledger \u6295\u5F71 {0} \u5FC5\u987B\u5F15\u7528\u8D26\u6237\u3002 + +LedgerStructuralValidatorProjectionGroupTargetConflict = \u6295\u5F71\u5FC5\u987B\u4F7F\u7528\u6210\u5458\u5F15\u7528\u6216\u5355\u4E2A\u8FC7\u8D26\u7EC4\u5F15\u7528\uFF0C\u4E0D\u80FD\u540C\u65F6\u4F7F\u7528\u4E24\u8005\u3002 + +LedgerStructuralValidatorProjectionMembershipRefNotFound = \u6295\u5F71\u6210\u5458\u5F15\u7528 {0} \u5FC5\u987B\u6307\u5411\u540C\u4E00\u6761\u76EE\u4E2D\u7684\u8FC7\u8D26\u3002 + +LedgerStructuralValidatorProjectionPortfolioRequired = Ledger \u6295\u5F71 {0} \u5FC5\u987B\u5F15\u7528\u8BC1\u5238\u8D26\u6237\u3002 + +LedgerStructuralValidatorProjectionPrimaryTargetConflict = \u6295\u5F71\u5FC5\u987B\u4F7F\u7528\u6210\u5458\u5F15\u7528\u6216\u5355\u4E2A\u4E3B\u8FC7\u8D26\u5F15\u7528\uFF0C\u4E0D\u80FD\u540C\u65F6\u4F7F\u7528\u4E24\u8005\u3002 + +LedgerStructuralValidatorProjectionRoleNotAllowed = \u6295\u5F71\u89D2\u8272 {0} \u5BF9\u6761\u76EE\u7C7B\u578B {1} \u65E0\u6548\u3002 + +LedgerStructuralValidatorProjectionRoleRequired = Ledger \u6295\u5F71 {0} \u5FC5\u987B\u5177\u6709\u6295\u5F71\u89D2\u8272\u3002 + +LedgerStructuralValidatorProjectionRoleRequiredForType = \u6761\u76EE\u7C7B\u578B {0} \u5FC5\u987B\u5177\u6709\u4E14\u4EC5\u5177\u6709\u4E00\u4E2A\u89D2\u8272\u4E3A {1} \u7684\u6295\u5F71\u3002 + +LedgerStructuralValidatorProjectionUuidRequired = Ledger \u6761\u76EE {0} \u5305\u542B\u6CA1\u6709 UUID \u7684\u6295\u5F71\u3002 + +LedgerStructuralValidatorSignedFactsNotAllowed = \u6761\u76EE\u7C7B\u578B {0} \u53EA\u63A5\u53D7\u6B63\u7684\u8FC7\u8D26\u503C\u3002\u8BF7\u4F7F\u7528\u6B63\u91D1\u989D\u548C\u6B63\u4EFD\u989D\u3002 + +LedgerStructuralValidatorTargetingRefRequired = \u5B9A\u5411 Ledger \u6295\u5F71\u5FC5\u987B\u6307\u5411\u4E3B\u8FC7\u8D26\u3002 + +LedgerXmlInvalidLedgerStructure = Ledger \u7ED3\u6784\u65E0\u6548\uFF1A\n{0} + MsgAlphaVantageAPIKeyMissing = \u7F3A\u5C11 Alpha Vantage API \u5BC6\u94A5\u3002\u8BF7\u4E8E\u504F\u597D\u8BBE\u7F6E\u4E2D\u8BBE\u7F6E\u3002 MsgCheckConfiguredAndCalculatedGrossValueDoNotMatch = \u5BFC\u5165\u7684\u603B\u989D ({0}) \u4E0E\u8BA1\u7B97\u7684\u603B\u989D ({1}) \u4E0D\u5339\u914D diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_zh_TW.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_zh_TW.properties index 81c1fed263..f93fca1721 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_zh_TW.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_zh_TW.properties @@ -472,6 +472,148 @@ LabelYahooFinance = Yahoo \u8CA1\u7D93 LabelYahooFinanceAdjustedClose = Yahoo \u8CA1\u7D93 \uFF08\u7D93\u8ABF\u6574\u4E4B\u6536\u5E02\u50F9\uFF09 +LedgerDiagnosticMessageFormatterAccount = \u5E33\u6236 + +LedgerDiagnosticMessageFormatterAmount = \u91D1\u984D + +LedgerDiagnosticMessageFormatterContextUnavailable = \u7121\u6CD5\u4F7F\u7528 + +LedgerDiagnosticMessageFormatterDate = \u65E5\u671F + +LedgerDiagnosticMessageFormatterIsin = ISIN + +LedgerDiagnosticMessageFormatterNote = \u5907\u6CE8 + +LedgerDiagnosticMessageFormatterPortfolio = \u8B49\u5238\u5E33\u6236 + +LedgerDiagnosticMessageFormatterSecurity = \u8B49\u5238 + +LedgerDiagnosticMessageFormatterShares = \u80A1\u6578 + +LedgerDiagnosticMessageFormatterSource = \u4F86\u6E90 + +LedgerDiagnosticMessageFormatterTicker = \u4EE3\u7801 + +LedgerDiagnosticMessageFormatterTransactionContext = \u4EA4\u6613\u8A73\u7D30\u8CC7\u8A0A + +LedgerDiagnosticMessageFormatterType = \u7C7B\u578B + +LedgerDiagnosticMessageFormatterUnnamedAccount = \u672A\u547D\u540D\u5E33\u6236 + +LedgerDiagnosticMessageFormatterUnnamedPortfolio = \u672A\u547D\u540D\u8B49\u5238\u5E33\u6236 + +LedgerDiagnosticMessageFormatterUnnamedSecurity = \u672A\u547D\u540D\u8B49\u5238 + +LedgerDiagnosticMessageFormatterWkn = WKN + +LedgerInsertActionGeneratedBuyMissingBuySellEntry = \u751F\u6210\u7684\u8CB7\u9032\u4EA4\u6613\u7531 Ledger \u7BA1\u7406\uFF0C\u4F46\u672A\u627E\u5230\u5173\u8054\u7684\u8CB7\u9032/\u8CE3\u51FA\u689D\u76EE\u3002 + +LedgerInsertActionGeneratedBuyTypeMismatch = \u6B64\u751F\u6210\u7684 Ledger \u8CB7\u9032\u4EA4\u6613\u53EA\u80FD\u7531\u532F\u5165\u7684\u8CB7\u9032\u4EA4\u6613\u66F4\u65B0\u3002 + +LedgerInsertActionGeneratedDeliveryOwnerNotFound = \u672A\u627E\u5230\u751F\u6210\u7684 Ledger \u4EA4\u5272\u64C1\u6709\u8005\u3002 + +LedgerInsertActionGeneratedDeliveryTypeMismatch = \u6B64\u751F\u6210\u7684 Ledger \u4EA4\u5272\u53EA\u80FD\u7531\u532F\u5165\u7684\u8CB7\u9032\u4EA4\u6613\u66F4\u65B0\u3002 + +LedgerInsertActionInvestmentPlanLegacySettersNotSupported = Ledger \u7BA1\u7406\u7684\u6295\u8D44\u8BA1\u5212\u4EA4\u6613\u4E0D\u80FD\u901A\u8FC7\u820A\u7684\u532F\u5165 setter \u66F4\u65B0\u3002\u8BF7\u4F7F\u7528 Ledger \u66F4\u65B0\u8DEF\u5F84\u3002 + +LedgerInsertActionUnsupportedInvestmentPlanTransactionUpdateType = Ledger \u7BA1\u7406\u7684\u4EA4\u6613\u4E0D\u652F\u63F4\u6295\u8D44\u8BA1\u5212\u66F4\u65B0\u7C7B\u578B {0}\u3002 + +LedgerParameterMissingAttribute = Ledger \u53C3\u6578 {0} \u7F3A\u5C11\u5FC5\u9700\u5C6C\u6027\u3002 + +LedgerParameterMissingType = Ledger \u53C3\u6578\u5FC5\u987B\u5177\u6709\u7C7B\u578B\u3002 + +LedgerParameterMissingValueKind = Ledger \u53C3\u6578\u5FC5\u987B\u5BA3\u544A\u503C\u985E\u578B\u3002 + +LedgerParameterReferenceValueMissingValueNode = Ledger \u53C3\u6578\u53C3\u7167\u503C\u5FC5\u987B\u5305\u542B\u503C\u7BC0\u9EDE\u3002 + +LedgerParameterUnsupportedValueKind = \u4E0D\u652F\u63F4 Ledger \u53C3\u6578\u503C\u985E\u578B {0}\u3002 + +LedgerParameterValueKindRequiresStructuredValue = Ledger \u53C3\u6578\u503C\u985E\u578B {0} \u9700\u8981\u7D50\u69CB\u5316\u503C\u3002 + +LedgerProtobufBooleanParameterMissingBooleanValue = BOOLEAN Ledger \u53C3\u6578\u5FC5\u987B\u5305\u542B booleanValue\u3002 + +LedgerProtobufInvalidLedgerPersistenceState = \u5132\u5B58\u7684 Ledger Protobuf \u8CC7\u6599\u4E0D\u4E00\u81F4\uFF1A\n{0} + +LedgerProtobufLocalDateParameterMissingLocalDateValue = LOCAL_DATE Ledger \u53C3\u6578\u5FC5\u987B\u5305\u542B localDateValue\u3002 + +LedgerRuntimeProjectionRestorerInvalidLedger = \u7531\u4E8E\u5132\u5B58\u7684 Ledger \u8CC7\u6599\u7121\u6548\uFF0C\u5DF2\u8DF3\u8FC7\u57F7\u884C\u968E\u6BB5\u6295\u5F71\u6062\u590D\u3002Ledger \u8CC7\u6599\u672A\u66F4\u6539\u3002\u8BCA\u65AD\uFF1A\n{0} + +LedgerRuntimeProjectionRestorerPartiallyRestored = \u4EC5\u4E3A\u6709\u6548\u7684 Ledger \u689D\u76EE\u6062\u590D\u4E86\u57F7\u884C\u968E\u6BB5\u6295\u5F71\u3002\u5DF2\u5220\u9664 {0} \u4E2A\u820A\u57F7\u884C\u968E\u6BB5\u6295\u5F71\u5E76\u91CD\u65B0\u521B\u5EFA {1} \u4E2A\u6709\u6548\u6295\u5F71\u3002\u5DF2\u8DF3\u8FC7\u7121\u6548 Ledger \u689D\u76EE\uFF1A{2}\u3002\u53D7\u5F71\u54CD\u64C1\u6709\u8005\uFF1A{3}\u3002\u7F3A\u5C11\u6295\u5F71 UUID\uFF1A{4}\u3002\u91CD\u8907\u6295\u5F71 UUID\uFF1A{5}\u3002\u904E\u671F\u6295\u5F71 UUID\uFF1A{6}\u3002\u5132\u5B58\u7684 Ledger \u8CC7\u6599\u672A\u66F4\u6539\u3002\u8BCA\u65AD\uFF1A\n{7} + +LedgerRuntimeProjectionRestorerRestored = \u5DF2\u4ECE\u6709\u6548 Ledger \u8CC7\u6599\u6062\u590D\u57F7\u884C\u968E\u6BB5\u6295\u5F71\u3002\u5DF2\u5220\u9664 {0} \u4E2A\u820A\u57F7\u884C\u968E\u6BB5\u6295\u5F71\u5E76\u91CD\u65B0\u521B\u5EFA {1} \u4E2A\u6295\u5F71\u3002\u53D7\u5F71\u54CD\u64C1\u6709\u8005\uFF1A{2}\u3002\u7F3A\u5C11\u6295\u5F71 UUID\uFF1A{3}\u3002\u91CD\u8907\u6295\u5F71 UUID\uFF1A{4}\u3002\u904E\u671F\u6295\u5F71 UUID\uFF1A{5}\u3002\u5132\u5B58\u7684 Ledger \u8CC7\u6599\u672A\u66F4\u6539\u3002 + +LedgerStructuralValidatorAccountProjectionPortfolioNotAllowed = \u5E33\u6236\u6295\u5F71\u53EA\u80FD\u53C3\u7167\u5E33\u6236\uFF1B\u8BF7\u79FB\u9664\u8B49\u5238\u5E33\u6236\u53C3\u7167\uFF1A{0} + +LedgerStructuralValidatorDividendSecurityRequired = \u80A1\u5229\u689D\u76EE\u5FC5\u987B\u53C3\u7167\u8B49\u5238\u904E\u5E33\uFF1A{0} + +LedgerStructuralValidatorDuplicateEntryUuid = \u6BCF\u4E2A Ledger \u689D\u76EE\u90FD\u5FC5\u987B\u5177\u6709\u552F\u4E00 UUID\u3002\u91CD\u8907\u9879\uFF1A{0} + +LedgerStructuralValidatorDuplicatePostingUuid = \u6BCF\u4E2A Ledger \u904E\u5E33\u90FD\u5FC5\u987B\u5177\u6709\u552F\u4E00 UUID\u3002\u91CD\u8907\u9879\uFF1A{0} + +LedgerStructuralValidatorDuplicateProjectionUuid = \u6BCF\u4E2A Ledger \u6295\u5F71\u90FD\u5FC5\u987B\u5177\u6709\u552F\u4E00 UUID\u3002\u91CD\u8907\u9879\uFF1A{0} + +LedgerStructuralValidatorEntryDateTimeRequired = Ledger \u689D\u76EE {0} \u5FC5\u987B\u5177\u6709\u65E5\u671F\u548C\u65F6\u95F4\u3002 + +LedgerStructuralValidatorEntryTypeRequired = Ledger \u689D\u76EE {0} \u5FC5\u987B\u5177\u6709\u689D\u76EE\u7C7B\u578B\u3002 + +LedgerStructuralValidatorEntryUuidRequired = \u6BCF\u4E2A Ledger \u689D\u76EE\u90FD\u5FC5\u987B\u5177\u6709 UUID\u3002 + +LedgerStructuralValidatorExDateSecurityRequired = EX_DATE \u53EA\u80FD\u7528\u4E8E\u53C3\u7167\u8B49\u5238\u7684\u904E\u5E33\uFF1A{0} + +LedgerStructuralValidatorLedgerRequired = \u9700\u8981 Ledger \u5BF9\u8C61\u3002 + +LedgerStructuralValidatorParameterCodeNotAllowed = {0} \u7684 Ledger \u53C3\u6578\u5FC5\u987B\u4F7F\u7528\u5DF2\u8A2D\u5B9A\u4EE3\u7801\u4E4B\u4E00\u3002 + +LedgerStructuralValidatorParameterMustUseValueKind = {0} \u5FC5\u987B\u4F7F\u7528\u503C\u985E\u578B {1}\u3002 + +LedgerStructuralValidatorParameterTypeRequired = Ledger \u53C3\u6578 {0} \u5FC5\u987B\u5177\u6709\u53C3\u6578\u7C7B\u578B\u3002 + +LedgerStructuralValidatorParameterValueKindMismatch = Ledger \u53C3\u6578\u503C\u5FC5\u987B\u4E0E\u503C\u985E\u578B {0} \u5339\u914D\u3002 + +LedgerStructuralValidatorParameterValueKindRequired = Ledger \u53C3\u6578 {0} \u5FC5\u987B\u5BA3\u544A\u503C\u985E\u578B\u3002 + +LedgerStructuralValidatorParameterValueRequired = Ledger \u53C3\u6578 {0} \u5FC5\u987B\u5305\u542B\u503C\u3002 + +LedgerStructuralValidatorPortfolioProjectionAccountNotAllowed = \u8B49\u5238\u5E33\u6236\u6295\u5F71\u53EA\u80FD\u53C3\u7167\u8B49\u5238\u5E33\u6236\uFF1B\u8BF7\u79FB\u9664\u5E33\u6236\u53C3\u7167\uFF1A{0} + +LedgerStructuralValidatorPostingCurrencyRequired = Ledger \u904E\u5E33 {0} \u5FC5\u987B\u5177\u6709\u5E63\u5225\u3002 + +LedgerStructuralValidatorPostingExchangeRatePositive = Ledger \u904E\u5E33 {0} \u5FC5\u987B\u5177\u6709\u5927\u4E8E\u96F6\u7684\u532F\u7387\u3002 + +LedgerStructuralValidatorPostingGroupRefNotFound = \u904E\u5E33\u7EC4\u53C3\u7167 {0} \u5FC5\u987B\u6307\u5411\u540C\u4E00\u689D\u76EE\u4E2D\u7684\u904E\u5E33\u3002 + +LedgerStructuralValidatorPostingSecurityRequired = \u8B49\u5238\u904E\u5E33 {0} \u5FC5\u987B\u53C3\u7167\u8B49\u5238\u3002 + +LedgerStructuralValidatorPostingTypeRequired = Ledger \u904E\u5E33 {0} \u5FC5\u987B\u5177\u6709\u904E\u5E33\u7C7B\u578B\u3002 + +LedgerStructuralValidatorPostingUuidRequired = Ledger \u689D\u76EE {0} \u5305\u542B\u6CA1\u6709 UUID \u7684\u904E\u5E33\u3002 + +LedgerStructuralValidatorPrimaryPostingRefNotFound = \u4E3B\u904E\u5E33\u53C3\u7167 {0} \u5FC5\u987B\u6307\u5411\u540C\u4E00\u689D\u76EE\u4E2D\u7684\u904E\u5E33\u3002 + +LedgerStructuralValidatorProjectionAccountRequired = Ledger \u6295\u5F71 {0} \u5FC5\u987B\u53C3\u7167\u5E33\u6236\u3002 + +LedgerStructuralValidatorProjectionGroupTargetConflict = \u6295\u5F71\u5FC5\u987B\u4F7F\u7528\u6210\u54E1\u53C3\u7167\u6216\u5355\u4E2A\u904E\u5E33\u7EC4\u53C3\u7167\uFF0C\u4E0D\u80FD\u540C\u65F6\u4F7F\u7528\u4E24\u8005\u3002 + +LedgerStructuralValidatorProjectionMembershipRefNotFound = \u6295\u5F71\u6210\u54E1\u53C3\u7167 {0} \u5FC5\u987B\u6307\u5411\u540C\u4E00\u689D\u76EE\u4E2D\u7684\u904E\u5E33\u3002 + +LedgerStructuralValidatorProjectionPortfolioRequired = Ledger \u6295\u5F71 {0} \u5FC5\u987B\u53C3\u7167\u8B49\u5238\u5E33\u6236\u3002 + +LedgerStructuralValidatorProjectionPrimaryTargetConflict = \u6295\u5F71\u5FC5\u987B\u4F7F\u7528\u6210\u54E1\u53C3\u7167\u6216\u5355\u4E2A\u4E3B\u904E\u5E33\u53C3\u7167\uFF0C\u4E0D\u80FD\u540C\u65F6\u4F7F\u7528\u4E24\u8005\u3002 + +LedgerStructuralValidatorProjectionRoleNotAllowed = \u6295\u5F71\u89D2\u8272 {0} \u5BF9\u689D\u76EE\u7C7B\u578B {1} \u7121\u6548\u3002 + +LedgerStructuralValidatorProjectionRoleRequired = Ledger \u6295\u5F71 {0} \u5FC5\u987B\u5177\u6709\u6295\u5F71\u89D2\u8272\u3002 + +LedgerStructuralValidatorProjectionRoleRequiredForType = \u689D\u76EE\u7C7B\u578B {0} \u5FC5\u987B\u5177\u6709\u4E14\u4EC5\u5177\u6709\u4E00\u4E2A\u89D2\u8272\u4E3A {1} \u7684\u6295\u5F71\u3002 + +LedgerStructuralValidatorProjectionUuidRequired = Ledger \u689D\u76EE {0} \u5305\u542B\u6CA1\u6709 UUID \u7684\u6295\u5F71\u3002 + +LedgerStructuralValidatorSignedFactsNotAllowed = \u689D\u76EE\u7C7B\u578B {0} \u53EA\u63A5\u53D7\u6B63\u7684\u904E\u5E33\u503C\u3002\u8BF7\u4F7F\u7528\u6B63\u91D1\u984D\u548C\u6B63\u80A1\u6578\u3002 + +LedgerStructuralValidatorTargetingRefRequired = \u5B9A\u5411 Ledger \u6295\u5F71\u5FC5\u987B\u6307\u5411\u4E3B\u904E\u5E33\u3002 + +LedgerXmlInvalidLedgerStructure = Ledger \u7D50\u69CB\u7121\u6548\uFF1A\n{0} + MsgAlphaVantageAPIKeyMissing = \u7F3A\u5C11 Alpha Vantage API \u5BC6\u9470\u3002 \u5728\u504F\u597D\u8A2D\u5B9A\u4E2D\u8A2D\u5B9A\u3002 MsgCheckConfiguredAndCalculatedGrossValueDoNotMatch = \u532F\u5165\u7684\u6BDB\u503C\uFF08{0}\uFF09\u548C\u8A08\u7B97\u7684\u6BDB\u503C\uFF08{1}\uFF09\u4E0D\u5339\u914D 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/LedgerDiagnosticMessageFormatter.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerDiagnosticMessageFormatter.java new file mode 100644 index 0000000000..95610f6e59 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerDiagnosticMessageFormatter.java @@ -0,0 +1,356 @@ +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"), + details.get("projectionUUID"), details.get("primaryPostingUUID"), + details.get("postingGroupUUID"), details.get("membershipPostingUUID"))).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())) + || entry.getProjectionRefs().stream() + .anyMatch(projection -> uuid.equals(projection.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())); + for (var projection : entry.getProjectionRefs()) + add(values, accountSummary(projection.getAccount())); + + return values; + } + + private static Set portfolioNames(LedgerEntry entry) + { + var values = new LinkedHashSet(); + + for (var posting : entry.getPostings()) + add(values, portfolioSummary(posting.getPortfolio())); + for (var projection : entry.getProjectionRefs()) + add(values, portfolioSummary(projection.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(); + } + } +} From 844497ba3bc0d6bbf58bc98f68504bc778525309 Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Fri, 26 Jun 2026 19:31:03 +0200 Subject: [PATCH 12/68] Move Ledger spin-off XML fixture to tests --- .../ledger/LedgerSpinOffScenarioTest.java | 28 +- ...LedgerSpinOffXmlDefinitionCheckerTest.java | 3 +- ...ger-v6-spin-off-siemens-energy-example.xml | 676 ++++++++++++++++++ .../name/abuchen/portfolio/model/Client.java | 2 +- .../portfolio/model/ClientFactory.java | 3 + 5 files changed, 691 insertions(+), 21 deletions(-) create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/ledger-v6-spin-off-siemens-energy-example.xml diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffScenarioTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffScenarioTest.java index 69cbc14146..4482737d2f 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffScenarioTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffScenarioTest.java @@ -18,7 +18,6 @@ import java.time.Instant; import java.time.LocalDateTime; import java.util.List; -import java.util.regex.Pattern; import org.junit.Test; @@ -72,7 +71,8 @@ public class LedgerSpinOffScenarioTest { private static final Path XML_EXAMPLE = Path - .of("docs/ledger-v6/examples/ledger-v6-spin-off-siemens-energy-example.xml"); + .of("name.abuchen.portfolio.tests", "src", "name", "abuchen", "portfolio", "model", "ledger", + "ledger-v6-spin-off-siemens-energy-example.xml"); private static final LocalDateTime SPIN_OFF_DATE = LocalDateTime.of(2020, 9, 28, 0, 0); private static final LocalDateTime BUY_DATE = LocalDateTime.of(2020, 1, 2, 0, 0); @@ -423,21 +423,6 @@ public void testEditLoadedSpinOffExampleCashCompensationWithoutUuidLiterals() th * The result must keep ledger truth and visible runtime rows consistent. * This protects against duplicate truth or partial mutation. */ - @Test - public void testSpinOffDocumentationDoesNotExposeUuidConstruction() throws Exception - { - var markdown = Files.readString(xmlExample().resolveSibling("ledger-v6-spin-off-siemens-energy-example.md"), - StandardCharsets.UTF_8); - - assertFalse(markdown.contains("setPrimaryPostingUUID")); - assertFalse(markdown.contains("setPostingGroupUUID")); - assertFalse(markdown.contains("new LedgerPosting(\"")); - assertFalse(markdown.contains("new LedgerEntry(\"")); - assertFalse(markdown.contains("new LedgerProjectionRef(\"")); - assertFalse(Pattern.compile("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}") - .matcher(markdown).find()); - } - private SpinOffFixture fixture() { var client = new Client(); @@ -920,12 +905,17 @@ private Security security(String name, String isin, String ticker) } private Path xmlExample() + { + return findInWorkingTree(XML_EXAMPLE); + } + + private Path findInWorkingTree(Path relativePath) { var current = Path.of("").toAbsolutePath(); while (current != null) { - var candidate = current.resolve(XML_EXAMPLE); + var candidate = current.resolve(relativePath); if (Files.exists(candidate)) return candidate; @@ -933,7 +923,7 @@ private Path xmlExample() current = current.getParent(); } - return Path.of("").toAbsolutePath().resolve(XML_EXAMPLE); + return Path.of("").toAbsolutePath().resolve(relativePath); } private record SpinOffFixture(Client client, Account account, Portfolio portfolio, Security siemens, diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffXmlDefinitionCheckerTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffXmlDefinitionCheckerTest.java index fbd4634222..0811cfe919 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffXmlDefinitionCheckerTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffXmlDefinitionCheckerTest.java @@ -30,7 +30,8 @@ public final class LedgerSpinOffXmlDefinitionCheckerTest { private static final Path XML_EXAMPLE = Path - .of("docs/ledger-v6/examples/ledger-v6-spin-off-siemens-energy-example.xml"); + .of("name.abuchen.portfolio.tests", "src", "name", "abuchen", "portfolio", "model", "ledger", + "ledger-v6-spin-off-siemens-energy-example.xml"); /** * Checks the persistence scenario: siemens energy xml example matches static ledger definitions. diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/ledger-v6-spin-off-siemens-energy-example.xml b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/ledger-v6-spin-off-siemens-energy-example.xml new file mode 100644 index 0000000000..ae36ebfdab --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/ledger-v6-spin-off-siemens-energy-example.xml @@ -0,0 +1,676 @@ + + 69 + EUR + + + 10000000-0000-0000-0000-000000000003 + Siemens AG + EUR + DE0007236101 + SIE.DE + + + + + + false + 2026-06-15T08:00:00Z + + + 10000000-0000-0000-0000-000000000004 + Siemens Energy AG + EUR + DE000ENER6Y0 + ENR.DE + + + + + false + 2026-06-15T08:00:00Z + + + + + + 10000000-0000-0000-0000-000000000001 + Spin-off cash account + EUR + false + + + + + 2026-06-15T08:00:00Z + + + + + 10000000-0000-0000-0000-000000000002 + Corporate action proof portfolio + false + + + + + + 2026-06-15T10:40:41.103476400Z + + + + + + + + + + + https://finance.yahoo.com/quote/{tickerSymbol} + + + + https://www.onvista.de/suche.html?SEARCH_VALUE={isin} + + + + https://www.finanzen.net/suchergebnis.asp?frmAktiensucheTextfeld={isin} + + + + https://www.ariva.de/{isin} + + + + https://www.justetf.com/etf-profile.html?isin={isin} + + + + https://www.fondsweb.com/{isin} + + + + https://www.morningstar.de/de/funds/SecuritySearchResults.aspx?type=ALL&search={isin} + + + + https://extraetf.com/etf-profile/{isin} + + + + https://www.alleaktien.de/data/{isin} + + + + https://www.comdirect.de/inf/aktien/{isin} + + + + https://www.comdirect.de/inf/etfs/{isin} + + + + https://divvydiary.com/symbols/{isin} + + + + https://www.trackingdifferences.com/ETF/ISIN/{isin} + + + + https://www.tradingview.com/chart/?symbol=XETR:{tickerSymbolPrefix} + + + + https://www.cnbc.com/quotes/{tickerSymbolPrefix} + + + + https://www.nasdaq.com/market-activity/stocks/{tickerSymbolPrefix} + + + + https://aktienfinder.net/aktien-profil/{isin} + + + + http://aktien.guide/isin/aktien/{isin} + + + + + logo + Logo + Logo + name.abuchen.portfolio.model.Security + java.lang.String + name.abuchen.portfolio.model.AttributeType$ImageConverter + + + logo + Logo + Logo + name.abuchen.portfolio.model.Account + java.lang.String + name.abuchen.portfolio.model.AttributeType$ImageConverter + + + logo + Logo + Logo + name.abuchen.portfolio.model.Portfolio + java.lang.String + name.abuchen.portfolio.model.AttributeType$ImageConverter + + + logo + Logo + Logo + name.abuchen.portfolio.model.InvestmentPlan + java.lang.String + name.abuchen.portfolio.model.AttributeType$ImageConverter + + + ter + Gesamtkostenquote (TER) + TER + ter + name.abuchen.portfolio.model.Security + java.lang.Double + name.abuchen.portfolio.model.AttributeType$PercentConverter + + + aum + Fondsgröße + Fondsgröße + name.abuchen.portfolio.model.Security + java.lang.Long + name.abuchen.portfolio.model.AttributeType$AmountPlainConverter + + + vendor + Anbieter + Anbieter + vendor + name.abuchen.portfolio.model.Security + java.lang.String + name.abuchen.portfolio.model.AttributeType$StringConverter + + + acquisitionFee + Kaufgebühr (prozentual) + Kaufgebühr + name.abuchen.portfolio.model.Security + java.lang.Double + name.abuchen.portfolio.model.AttributeType$PercentConverter + + + managementFee + Verwaltungsgebühr (prozentual) + Verwaltungsgebühr + name.abuchen.portfolio.model.Security + java.lang.Double + name.abuchen.portfolio.model.AttributeType$PercentConverter + + + + + name.abuchen.portfolio.ui.views.SecuritiesTable + + + + 467280c9-78a9-4302-9c9b-4d8750311a5f + Standard + {"items":[{"id":"0","sortDirection":128,"width":400},{"id":"note","width":200},{"id":"1","width":100},{"id":"2","width":80},{"id":"7","width":80},{"id":"4","width":60},{"id":"5","width":80},{"id":"changeonpreviousamount","width":80},{"id":"9","width":80},{"id":"10","width":80},{"id":"q-date-first-historic","width":80}]} + + + + + + name.abuchen.portfolio.ui.views.StatementOfAssetsViewer + + + + d4542f7f-aa2f-4a29-be5d-f71786b2c021 + Standard + {"items":[{"id":"0","width":80},{"id":"1","width":300},{"id":"2","width":80},{"id":"4","width":60},{"id":"5","width":80},{"id":"6","width":80},{"id":"note","width":200}]} + + + + + + reporting-periods + + + + + + client-filter-definitions + + + + + + client-filter-selection + + + + + + + + + + 20000000-0000-0000-0000-000000000001 + SPIN_OFF + 2020-09-28T00:00 + Siemens Energy spin-off proof + Ledger-V6 Slice 13 proof + 2026-06-15T08:00:00Z + + + CORPORATE_ACTION_KIND + STRING + SPIN_OFF + + + CORPORATE_ACTION_SUBTYPE + STRING + STANDARD + + + EVENT_REFERENCE + STRING + Siemens Energy allocation + + + EVENT_STAGE + STRING + SETTLED + + + EFFECTIVE_DATE + LOCAL_DATE + 2020-09-28 + + + RECORD_DATE + LOCAL_DATE + 2020-09-25 + + + PAYMENT_DATE + LOCAL_DATE + 2020-09-28 + + + SETTLEMENT_DATE + LOCAL_DATE + 2020-09-28 + + + + + 20000000-0000-0000-0000-000000000002 + SECURITY + 109960 + EUR + + 1000000000 + + + + CORPORATE_ACTION_LEG + STRING + SOURCE_SECURITY + + + SOURCE_SECURITY + SECURITY + + + + TARGET_SECURITY + SECURITY + + + + RATIO_NUMERATOR + DECIMAL + 1 + + + RATIO_DENOMINATOR + DECIMAL + 2 + + + COST_ALLOCATION_METHOD + STRING + FMV_RATIO + + + SOURCE_COST_PERCENT + DECIMAL + 91 + + + AFFECTED_SOURCE_QUANTITY + DECIMAL + 10 + + + + + 20000000-0000-0000-0000-000000000007 + SECURITY + 109960 + EUR + + 1000000000 + + + + CORPORATE_ACTION_LEG + STRING + TARGET_SECURITY + + + SOURCE_SECURITY + SECURITY + + + + TARGET_SECURITY + SECURITY + + + + RATIO_NUMERATOR + DECIMAL + 1 + + + RATIO_DENOMINATOR + DECIMAL + 2 + + + SAME_SECURITY_AS_SOURCE + BOOLEAN + true + + + + + 20000000-0000-0000-0000-000000000003 + SECURITY + 10605 + EUR + + 500000000 + + + + CORPORATE_ACTION_LEG + STRING + TARGET_SECURITY + + + SOURCE_SECURITY + SECURITY + + + + TARGET_SECURITY + SECURITY + + + + RATIO_NUMERATOR + DECIMAL + 1 + + + RATIO_DENOMINATOR + DECIMAL + 2 + + + TARGET_COST_PERCENT + DECIMAL + 9 + + + REFERENCE_PRICE + MONEY + + + + FAIR_MARKET_VALUE + MONEY + + + + VALUATION_PRICE + MONEY + + + + MANUAL_VALUATION_OVERRIDE + BOOLEAN + false + + + + + 20000000-0000-0000-0000-000000000004 + CASH_COMPENSATION + 500 + EUR + 0 + + + + CORPORATE_ACTION_LEG + STRING + CASH_COMPENSATION + + + CASH_ACCOUNT + ACCOUNT + + + CASH_COMPENSATION_KIND + STRING + CASH_IN_LIEU + + + CASH_IN_LIEU_AMOUNT + MONEY + + + + CASH_IN_LIEU_APPLIED + BOOLEAN + true + + + FRACTION_QUANTITY + DECIMAL + 0.5 + + + FRACTION_TREATMENT + STRING + CASH_IN_LIEU + + + ROUNDING_MODE + STRING + FLOOR + + + + + 20000000-0000-0000-0000-000000000005 + FEE + 200 + EUR + 0 + + + + CORPORATE_ACTION_LEG + STRING + FEE + + + FEE_REASON + STRING + CORPORATE_ACTION_FEE + + + STAMP_DUTY + BOOLEAN + false + + + + + 20000000-0000-0000-0000-000000000006 + TAX + 100 + EUR + 0 + + + + CORPORATE_ACTION_LEG + STRING + TAX + + + TAX_REASON + STRING + WITHHOLDING_TAX + + + TAXABLE_DISTRIBUTION + BOOLEAN + true + + + WITHHOLDING_TAX + BOOLEAN + true + + + TRANSACTION_TAX + BOOLEAN + false + + + RECLAIMABLE_TAX + BOOLEAN + false + + + + + + + 30000000-0000-0000-0000-000000000001 + OLD_SECURITY_LEG + + 20000000-0000-0000-0000-000000000002 + + + 30000000-0000-0000-0000-000000000004 + DELIVERY_INBOUND + + 20000000-0000-0000-0000-000000000007 + + + 30000000-0000-0000-0000-000000000002 + NEW_SECURITY_LEG + + 20000000-0000-0000-0000-000000000003 + + + 30000000-0000-0000-0000-000000000003 + CASH_COMPENSATION + + 20000000-0000-0000-0000-000000000004 + 20000000-0000-0000-0000-000000000004 + + + + + 316212bb-23d1-486f-aaa9-fc2108d44e82 + BUY + 2020-01-02T00:00 + 2026-06-15T10:41:34.896212600Z + + + + 29ef4ac0-ce62-4bfb-8bed-5dbabf5a8ca4 + CASH + 118640 + EUR + 0 + + + + + 7898a9e0-ff1b-484b-81ff-37ad41d3b611 + SECURITY + 118640 + EUR + + 1000000000 + + + + + + + e34f70f5-810b-4224-868d-7069b89429e0 + ACCOUNT + + + + 24495c80-fea8-491e-9b57-8ecf2991605e + PORTFOLIO + + + + + + 9f3dfe85-9707-40bf-9153-816cf063d290 + DEPOSIT + 2019-12-30T00:00 + 2026-06-15T10:41:50.210577100Z + + + + ed4ed7ce-87dc-4c1c-a3cd-32dc41370bf1 + CASH + 1000000 + EUR + 0 + + + + + + + 70b3914c-f7a5-4b13-9a4d-4310890a8dae + ACCOUNT + + + + + + + \ No newline at end of file diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/Client.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/Client.java index c8e5fa8e16..8078369bc8 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/Client.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/Client.java @@ -32,7 +32,7 @@ public interface Properties // NOSONAR String WATCHLISTS = "watchlists"; //$NON-NLS-1$ } - public static final int CURRENT_VERSION = 69; + public static final int CURRENT_VERSION = 70; public static final int VERSION_WITH_CURRENCY_SUPPORT = 29; public static final int VERSION_WITH_UNIQUE_FILTER_KEY = 57; diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ClientFactory.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ClientFactory.java index b1452ae3e7..ad3c27e6e2 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ClientFactory.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ClientFactory.java @@ -1710,6 +1710,9 @@ else if (flags.contains(SaveFlag.COMPRESSED)) removeSourceAttributeFromTaxonomy(client); case 68: // NOSONAR // added exDate date field + case 69: // NOSONAR + // enable PP Ledger - projections derived from + // ledger data for AccountTransaction / PortfolioTransaction client.setVersion(Client.CURRENT_VERSION); break; From d6058ca1cecd9a427c1ab363ea467595f51f45a8 Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:30:08 +0200 Subject: [PATCH 13/68] Add Ledger semantic posting vocabulary Added typed semantic posting vocabulary fields to LedgerPosting with enums for role, direction, and unit role. Added focused tests for field storage and projection-ref materialization precedence. These additions prepare later derived projection work while keeping Phase 1 additive and behavior-neutral. Left XML/protobuf persistence, projection refs, memberships, UUID fields, and materialization unchanged. --- .../model/ledger/LedgerModelTest.java | 46 +++++++++++++ .../LedgerProjectionMaterializerTest.java | 28 ++++++++ .../portfolio/model/ledger/LedgerPosting.java | 67 +++++++++++++++++++ .../model/ledger/LedgerPostingDirection.java | 13 ++++ .../ledger/LedgerPostingSemanticRole.java | 20 ++++++ .../model/ledger/LedgerPostingUnitRole.java | 14 ++++ 6 files changed, 188 insertions(+) create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerPostingDirection.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerPostingSemanticRole.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerPostingUnitRole.java 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 index 6618a74cea..878ac06732 100644 --- 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 @@ -151,6 +151,12 @@ public void testLedgerPostingCarriesFieldsAndForexAsPostingData() 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)); @@ -163,6 +169,46 @@ public void testLedgerPostingCarriesFieldsAndForexAsPostingData() 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))); } /** diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerProjectionMaterializerTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerProjectionMaterializerTest.java index eac3147c60..9783c65255 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerProjectionMaterializerTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerProjectionMaterializerTest.java @@ -23,6 +23,7 @@ import name.abuchen.portfolio.model.Security; import name.abuchen.portfolio.model.Transaction; import name.abuchen.portfolio.model.Transaction.Unit; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountCashLeg; @@ -79,6 +80,33 @@ public void testServiceCreatesAccountBackedDepositProjection() assertThat(projection.getCurrencyCode(), is(CurrencyUnit.EUR)); } + /** + * Checks the Phase-1 scenario: semantic posting fields do not switch materialization. + * Projection refs remain the active compatibility-view source in this phase. + */ + @Test + public void testProjectionRefMaterializationRemainsActiveWithSemanticPostingVocabulary() + { + var client = new Client(); + var account = account(); + var entry = creator(client).createDeposit(metadata(), cashLeg(account, 100)).getEntry(); + var posting = entry.getPostings().get(0); + var projectionRef = entry.getProjectionRefs().get(0); + + posting.setSemanticRole(LedgerPostingSemanticRole.SECURITY); + posting.setDirection(LedgerPostingDirection.OUTBOUND); + posting.setCorporateActionLeg(CorporateActionLeg.SOURCE_SECURITY); + posting.setUnitRole(LedgerPostingUnitRole.FEE); + posting.setGroupKey("ignored-phase-1-group"); + posting.setLocalKey("ignored-phase-1-local"); + + var projection = LedgerProjectionService.createProjection(entry, projectionRef); + + assertThat(projection.getUUID(), is(projectionRef.getUUID())); + assertThat(((AccountTransaction) projection).getType(), is(AccountTransaction.Type.DEPOSIT)); + assertThat(projection.getAmount(), is(Values.Amount.factorize(100))); + } + /** * Checks the projection rebuild scenario: materializer adds account projection only. * Account and portfolio lists must be derived from the ledger entry. 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 index f7f26634d2..29d020c935 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerPosting.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerPosting.java @@ -10,6 +10,7 @@ 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; /** @@ -30,6 +31,12 @@ public class LedgerPosting 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() @@ -152,6 +159,66 @@ 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); 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..ee61b1e20b --- /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 + * projection refs 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 +} From f3b67512862e8a0a427ea5576a043fe8b11f87ff Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:39:51 +0200 Subject: [PATCH 14/68] Add derived Ledger projection descriptors Added runtime-only derived projection descriptor types and a derivation service that computes account, portfolio, transfer, delivery, and corporate-action projection descriptors from Ledger entry type and posting semantics. This enables parity checks and ambiguity diagnostics before switching materialization away from persisted projection refs. Left XML/protobuf persistence, projection refs, memberships, UUID fields, InvestmentPlan linkage, and active materialization unchanged. --- ...erivedProjectionDescriptorServiceTest.java | 568 ++++++++++++++++++ .../DerivedProjectionDescriptor.java | 88 +++ .../DerivedProjectionDescriptorService.java | 340 +++++++++++ .../projection/DerivedProjectionViewKind.java | 11 + 4 files changed, 1007 insertions(+) create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorServiceTest.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptor.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionViewKind.java diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorServiceTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorServiceTest.java new file mode 100644 index 0000000000..d2bcc79226 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorServiceTest.java @@ -0,0 +1,568 @@ +package name.abuchen.portfolio.model.ledger.projection; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.time.LocalDateTime; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import org.junit.Test; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerParameter; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerPostingDirection; +import name.abuchen.portfolio.model.ledger.LedgerPostingSemanticRole; +import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.LedgerTransactionMetadata; +import name.abuchen.portfolio.model.ledger.ProjectionMembershipRole; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountCashLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerCashTransferLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerCreationUnits; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerDeliveryLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioSecurityLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferLeg; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferSecurity; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerSecurityQuantity; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerTransactionCreator; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; +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.Money; +import name.abuchen.portfolio.money.Values; + +/** + * Tests runtime-only projection descriptors derived from posting semantics. + * Current projection refs remain the active materialization source in this phase. + */ +@SuppressWarnings("nls") +public class DerivedProjectionDescriptorServiceTest +{ + private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 1, 2, 0, 0); + + @Test + public void testAccountOnlyDescriptorMatchesProjectionRef() + { + var client = new Client(); + var account = account("Cash"); + var entry = creator(client).createDeposit(metadata(), LedgerAccountCashLeg.of(account, money(100))).getEntry(); + + annotateFromProjectionRefs(entry); + + var descriptors = descriptors(entry); + + assertThat(descriptors.size(), is(1)); + assertMatchesProjectionRef(entry, descriptors.get(0), entry.getProjectionRefs().get(0)); + } + + @Test + public void testBuySellDescriptorsMatchAccountAndPortfolioProjectionRefs() + { + var client = new Client(); + var account = account("Cash"); + var portfolio = portfolio("Portfolio"); + var entry = creator(client).createBuy(metadata(), LedgerAccountCashLeg.of(account, money(100)), + LedgerPortfolioSecurityLeg.of(portfolio, + LedgerSecurityQuantity.of(security("Security"), Values.Share.factorize(5)), + money(100)), + LedgerCreationUnits.none()).getEntry(); + + annotateFromProjectionRefs(entry); + + var descriptors = descriptors(entry); + + assertThat(roles(descriptors), is(Set.of(LedgerProjectionRole.ACCOUNT, LedgerProjectionRole.PORTFOLIO))); + assertMatchesProjectionRef(entry, descriptor(descriptors, LedgerProjectionRole.ACCOUNT), + projection(entry, LedgerProjectionRole.ACCOUNT)); + assertMatchesProjectionRef(entry, descriptor(descriptors, LedgerProjectionRole.PORTFOLIO), + projection(entry, LedgerProjectionRole.PORTFOLIO)); + } + + @Test + public void testDeliveryDescriptorMatchesProjectionRef() + { + var client = new Client(); + var portfolio = portfolio("Portfolio"); + var entry = creator(client).createInboundDelivery(metadata(), + LedgerDeliveryLeg.of(portfolio, + LedgerSecurityQuantity.of(security("Security"), Values.Share.factorize(5)), + money(100))) + .getEntry(); + + annotateFromProjectionRefs(entry); + + var descriptors = descriptors(entry); + + assertThat(roles(descriptors), is(Set.of(LedgerProjectionRole.DELIVERY_INBOUND))); + assertMatchesProjectionRef(entry, descriptors.get(0), projection(entry, LedgerProjectionRole.DELIVERY_INBOUND)); + } + + @Test + public void testCashTransferDerivesSourceAndTargetWithoutPostingOrder() + { + var client = new Client(); + var source = account("Source"); + var target = account("Target"); + var entry = creator(client).createAccountTransfer(metadata(), LedgerCashTransferLeg.of(source, money(100)), + LedgerCashTransferLeg.of(target, money(100))).getEntry(); + + annotateFromProjectionRefs(entry); + moveFirstPostingToEnd(entry); + + var descriptors = descriptors(entry); + + assertSame(source, descriptor(descriptors, LedgerProjectionRole.SOURCE_ACCOUNT).getAccount()); + assertSame(target, descriptor(descriptors, LedgerProjectionRole.TARGET_ACCOUNT).getAccount()); + assertMatchesProjectionRef(entry, descriptor(descriptors, LedgerProjectionRole.SOURCE_ACCOUNT), + projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT)); + assertMatchesProjectionRef(entry, descriptor(descriptors, LedgerProjectionRole.TARGET_ACCOUNT), + projection(entry, LedgerProjectionRole.TARGET_ACCOUNT)); + } + + @Test + public void testSecurityTransferDerivesSourceAndTargetWithoutPostingOrder() + { + var client = new Client(); + var source = portfolio("Source"); + var target = portfolio("Target"); + var security = security("Security"); + var entry = creator(client).createPortfolioTransfer(metadata(), + LedgerPortfolioTransferSecurity.of(security, Values.Share.factorize(5)), + LedgerPortfolioTransferLeg.of(source, money(100)), + LedgerPortfolioTransferLeg.of(target, money(100))).getEntry(); + + annotateFromProjectionRefs(entry); + moveFirstPostingToEnd(entry); + + var descriptors = descriptors(entry); + + assertSame(source, descriptor(descriptors, LedgerProjectionRole.SOURCE_PORTFOLIO).getPortfolio()); + assertSame(target, descriptor(descriptors, LedgerProjectionRole.TARGET_PORTFOLIO).getPortfolio()); + assertMatchesProjectionRef(entry, descriptor(descriptors, LedgerProjectionRole.SOURCE_PORTFOLIO), + projection(entry, LedgerProjectionRole.SOURCE_PORTFOLIO)); + assertMatchesProjectionRef(entry, descriptor(descriptors, LedgerProjectionRole.TARGET_PORTFOLIO), + projection(entry, LedgerProjectionRole.TARGET_PORTFOLIO)); + } + + @Test + public void testSiemensSpinOffDescriptorsMatchTargetedProjectionRefs() + { + var fixture = fixture(); + var entry = spinOffEntry(fixture); + + var descriptors = descriptors(entry); + + assertThat(roles(descriptors), is(Set.of(LedgerProjectionRole.OLD_SECURITY_LEG, + LedgerProjectionRole.DELIVERY_INBOUND, LedgerProjectionRole.NEW_SECURITY_LEG, + LedgerProjectionRole.CASH_COMPENSATION))); + assertMatchesProjectionRef(entry, descriptor(descriptors, LedgerProjectionRole.OLD_SECURITY_LEG), + projection(entry, LedgerProjectionRole.OLD_SECURITY_LEG)); + assertMatchesProjectionRef(entry, descriptor(descriptors, LedgerProjectionRole.DELIVERY_INBOUND), + projection(entry, LedgerProjectionRole.DELIVERY_INBOUND)); + assertMatchesProjectionRef(entry, descriptor(descriptors, LedgerProjectionRole.NEW_SECURITY_LEG), + projection(entry, LedgerProjectionRole.NEW_SECURITY_LEG)); + assertMatchesProjectionRef(entry, descriptor(descriptors, LedgerProjectionRole.CASH_COMPENSATION), + projection(entry, LedgerProjectionRole.CASH_COMPENSATION)); + } + + @Test + public void testNativeCorporateActionSmokeDescriptors() + { + assertThat(roles(descriptors(nativeSinglePortfolioEntry(LedgerEntryType.STOCK_DIVIDEND, + CorporateActionLeg.TARGET_SECURITY))), is(Set.of(LedgerProjectionRole.DELIVERY_INBOUND))); + assertThat(roles(descriptors(nativeSinglePortfolioEntry(LedgerEntryType.BONUS_ISSUE, + CorporateActionLeg.TARGET_SECURITY))), is(Set.of(LedgerProjectionRole.DELIVERY_INBOUND))); + assertThat(roles(descriptors(nativeSinglePortfolioEntry(LedgerEntryType.RIGHTS_DISTRIBUTION, + CorporateActionLeg.RIGHT_SECURITY))), is(Set.of(LedgerProjectionRole.NEW_SECURITY_LEG))); + assertThat(roles(descriptors(bondConversionEntry())), + is(Set.of(LedgerProjectionRole.OLD_SECURITY_LEG, LedgerProjectionRole.NEW_SECURITY_LEG))); + } + + @Test + public void testDuplicateSemanticPrimaryIsReportedAsAmbiguous() + { + var entry = new LedgerEntry("entry-1"); + var account = account("Cash"); + + entry.setType(LedgerEntryType.DEPOSIT); + entry.setDateTime(DATE_TIME); + entry.addPosting(primaryCash("posting-1", account)); + entry.addPosting(primaryCash("posting-2", account)); + + var result = new DerivedProjectionDescriptorService().derive(entry); + + assertFalse(result.isOK()); + assertThat(result.getDescriptors().size(), is(0)); + assertThat(result.getDiagnostics().get(0).getCode(), + is(DerivedProjectionDescriptorService.Diagnostic.IssueCode.AMBIGUOUS_SEMANTIC_PRIMARY)); + assertThat(result.getDiagnostics().get(0).getPostingUUIDs(), is(List.of("posting-1", "posting-2"))); + } + + @Test + public void testMissingSemanticPrimaryIsReported() + { + var entry = new LedgerEntry("entry-1"); + var account = account("Cash"); + var posting = primaryCash("posting-1", account); + + posting.setUnitRole(null); + entry.setType(LedgerEntryType.DEPOSIT); + entry.setDateTime(DATE_TIME); + entry.addPosting(posting); + + var result = new DerivedProjectionDescriptorService().derive(entry); + + assertFalse(result.isOK()); + assertThat(result.getDescriptors().size(), is(0)); + assertThat(result.getDiagnostics().get(0).getCode(), + is(DerivedProjectionDescriptorService.Diagnostic.IssueCode.MISSING_SEMANTIC_PRIMARY)); + } + + private List descriptors(LedgerEntry entry) + { + var result = new DerivedProjectionDescriptorService().derive(entry); + + assertTrue(result.formatDiagnostics(), result.isOK()); + + return result.getDescriptors(); + } + + private void assertMatchesProjectionRef(LedgerEntry entry, DerivedProjectionDescriptor descriptor, + LedgerProjectionRef projectionRef) + { + assertSame(entry, descriptor.getEntry()); + assertThat(descriptor.getRole(), is(projectionRef.getRole())); + assertSame(LedgerProjectionSupport.primaryPosting(entry, projectionRef), descriptor.getPrimaryPosting()); + + if (descriptor.getViewKind() == DerivedProjectionViewKind.ACCOUNT) + assertSame(projectionRef.getAccount(), descriptor.getAccount()); + else + assertSame(projectionRef.getPortfolio(), descriptor.getPortfolio()); + } + + private Set roles(List descriptors) + { + return descriptors.stream().map(DerivedProjectionDescriptor::getRole).collect(Collectors.toSet()); + } + + private DerivedProjectionDescriptor descriptor(List descriptors, + LedgerProjectionRole role) + { + return descriptors.stream().filter(descriptor -> descriptor.getRole() == role).findFirst().orElseThrow(); + } + + private LedgerProjectionRef projection(LedgerEntry entry, LedgerProjectionRole role) + { + return entry.getProjectionRefs().stream().filter(ref -> ref.getRole() == role).findFirst().orElseThrow(); + } + + private void annotateFromProjectionRefs(LedgerEntry entry) + { + for (var ref : entry.getProjectionRefs()) + { + var primary = LedgerProjectionSupport.primaryPosting(entry, ref); + + markPrimary(primary, ref.getRole()); + + for (var membership : ref.getMemberships()) + markUnitPosting(entry, membership.getPostingUUID(), membership.getRole(), primary.getGroupKey()); + } + } + + private void markPrimary(LedgerPosting posting, LedgerProjectionRole role) + { + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + posting.setLocalKey(role.name()); + + if (posting.getAccount() != null) + posting.setSemanticRole(LedgerPostingSemanticRole.CASH); + else if (posting.getPortfolio() != null) + posting.setSemanticRole(LedgerPostingSemanticRole.SECURITY); + + posting.setDirection(direction(role)); + posting.setCorporateActionLeg(corporateActionLeg(posting)); + } + + private void markUnitPosting(LedgerEntry entry, String postingUUID, ProjectionMembershipRole role, String groupKey) + { + var posting = entry.getPostings().stream().filter(candidate -> postingUUID.equals(candidate.getUUID())) + .findFirst().orElseThrow(); + + if (posting.getUnitRole() == LedgerPostingUnitRole.PRIMARY) + return; + + posting.setGroupKey(groupKey); + posting.setUnitRole(switch (role) + { + case FEE_UNIT -> LedgerPostingUnitRole.FEE; + case TAX_UNIT -> LedgerPostingUnitRole.TAX; + case GROSS_VALUE_UNIT -> LedgerPostingUnitRole.GROSS_VALUE; + default -> posting.getUnitRole(); + }); + } + + private LedgerPostingDirection direction(LedgerProjectionRole role) + { + return switch (role) + { + case SOURCE_ACCOUNT, SOURCE_PORTFOLIO, OLD_SECURITY_LEG, DELIVERY_OUTBOUND -> LedgerPostingDirection.OUTBOUND; + case TARGET_ACCOUNT, TARGET_PORTFOLIO, NEW_SECURITY_LEG, DELIVERY, DELIVERY_INBOUND -> + LedgerPostingDirection.INBOUND; + default -> LedgerPostingDirection.NEUTRAL; + }; + } + + private CorporateActionLeg corporateActionLeg(LedgerPosting posting) + { + return posting.getParameters().stream() // + .filter(parameter -> parameter.getType() == LedgerParameterType.CORPORATE_ACTION_LEG) // + .map(LedgerParameter::getValue) // + .filter(String.class::isInstance) // + .map(String.class::cast) // + .map(this::corporateActionLeg) // + .findFirst().orElse(null); + } + + private CorporateActionLeg corporateActionLeg(String code) + { + for (var leg : CorporateActionLeg.values()) + if (leg.getCode().equals(code)) + return leg; + + throw new IllegalArgumentException(code); + } + + private void moveFirstPostingToEnd(LedgerEntry entry) + { + var posting = entry.getPostings().get(0); + + entry.removePosting(posting); + entry.addPosting(posting); + } + + private LedgerEntry spinOffEntry(Fixture fixture) + { + var entry = new LedgerEntry("spin-off"); + var oldLeg = portfolioPosting("old-siemens", fixture.portfolio, fixture.siemens, 10, 100, + CorporateActionLeg.SOURCE_SECURITY, LedgerProjectionRole.OLD_SECURITY_LEG); + var retainedLeg = portfolioPosting("retained-siemens", fixture.portfolio, fixture.siemens, 10, 100, + CorporateActionLeg.TARGET_SECURITY, LedgerProjectionRole.DELIVERY_INBOUND); + var newLeg = portfolioPosting("new-siemens-energy", fixture.portfolio, fixture.siemensEnergy, 5, 50, + CorporateActionLeg.TARGET_SECURITY, LedgerProjectionRole.NEW_SECURITY_LEG); + var compensation = accountPosting("cash-compensation", fixture.account, 5, + CorporateActionLeg.CASH_COMPENSATION, LedgerProjectionRole.CASH_COMPENSATION); + var fee = unitPosting("fee", LedgerPostingType.FEE, 2, LedgerPostingUnitRole.FEE); + var tax = unitPosting("tax", LedgerPostingType.TAX, 1, LedgerPostingUnitRole.TAX); + + entry.setType(LedgerEntryType.SPIN_OFF); + entry.setDateTime(DATE_TIME); + entry.addPosting(oldLeg); + entry.addPosting(retainedLeg); + entry.addPosting(newLeg); + entry.addPosting(compensation); + entry.addPosting(fee); + entry.addPosting(tax); + entry.addProjectionRef(portfolioProjection(LedgerProjectionRole.OLD_SECURITY_LEG, fixture.portfolio, oldLeg)); + entry.addProjectionRef(portfolioProjection(LedgerProjectionRole.DELIVERY_INBOUND, fixture.portfolio, + retainedLeg)); + entry.addProjectionRef(portfolioProjection(LedgerProjectionRole.NEW_SECURITY_LEG, fixture.portfolio, newLeg)); + var cashProjection = accountProjection(LedgerProjectionRole.CASH_COMPENSATION, fixture.account, compensation); + cashProjection.setPostingGroup(compensation); + cashProjection.addMembership(fee.getUUID(), ProjectionMembershipRole.FEE_UNIT); + cashProjection.addMembership(tax.getUUID(), ProjectionMembershipRole.TAX_UNIT); + entry.addProjectionRef(cashProjection); + fee.setGroupKey(compensation.getGroupKey()); + tax.setGroupKey(compensation.getGroupKey()); + + return entry; + } + + private LedgerEntry nativeSinglePortfolioEntry(LedgerEntryType type, CorporateActionLeg leg) + { + var fixture = fixture(); + var role = type == LedgerEntryType.RIGHTS_DISTRIBUTION ? LedgerProjectionRole.NEW_SECURITY_LEG + : LedgerProjectionRole.DELIVERY_INBOUND; + var entry = new LedgerEntry(type.name()); + var posting = portfolioPosting("primary", fixture.portfolio, fixture.siemensEnergy, 5, 50, leg, role); + + entry.setType(type); + entry.setDateTime(DATE_TIME); + entry.addPosting(posting); + + return entry; + } + + private LedgerEntry bondConversionEntry() + { + var fixture = fixture(); + var entry = new LedgerEntry("bond-conversion"); + var source = portfolioPosting("bond", fixture.portfolio, fixture.siemens, 10, 100, + CorporateActionLeg.CONVERSION_SOURCE, LedgerProjectionRole.OLD_SECURITY_LEG); + var target = portfolioPosting("share", fixture.portfolio, fixture.siemensEnergy, 5, 50, + CorporateActionLeg.CONVERSION_TARGET, LedgerProjectionRole.NEW_SECURITY_LEG); + + entry.setType(LedgerEntryType.BOND_CONVERSION); + entry.setDateTime(DATE_TIME); + entry.addPosting(source); + entry.addPosting(target); + + return entry; + } + + private LedgerPosting primaryCash(String uuid, Account account) + { + var posting = new LedgerPosting(uuid); + + posting.setType(LedgerPostingType.CASH); + posting.setAccount(account); + posting.setAmount(Values.Amount.factorize(100)); + posting.setCurrency(CurrencyUnit.EUR); + posting.setSemanticRole(LedgerPostingSemanticRole.CASH); + posting.setDirection(LedgerPostingDirection.NEUTRAL); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + + return posting; + } + + private LedgerPosting portfolioPosting(String uuid, Portfolio portfolio, Security security, int shares, int amount, + CorporateActionLeg leg, LedgerProjectionRole role) + { + var posting = new LedgerPosting(uuid); + + posting.setType(LedgerPostingType.SECURITY); + posting.setPortfolio(portfolio); + posting.setSecurity(security); + posting.setShares(Values.Share.factorize(shares)); + posting.setAmount(Values.Amount.factorize(amount)); + posting.setCurrency(CurrencyUnit.EUR); + posting.setSemanticRole(LedgerPostingSemanticRole.SECURITY); + posting.setDirection(direction(role)); + posting.setCorporateActionLeg(leg); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + posting.setGroupKey(role.name()); + posting.setLocalKey(role.name()); + posting.addParameter(LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_LEG, leg.getCode())); + + return posting; + } + + private LedgerPosting accountPosting(String uuid, Account account, int amount, CorporateActionLeg leg, + LedgerProjectionRole role) + { + var posting = new LedgerPosting(uuid); + + posting.setType(LedgerPostingType.CASH_COMPENSATION); + posting.setAccount(account); + posting.setAmount(Values.Amount.factorize(amount)); + posting.setCurrency(CurrencyUnit.EUR); + posting.setSemanticRole(LedgerPostingSemanticRole.CASH_COMPENSATION); + posting.setDirection(direction(role)); + posting.setCorporateActionLeg(leg); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + posting.setGroupKey(role.name()); + posting.setLocalKey(role.name()); + posting.addParameter(LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_LEG, leg.getCode())); + + return posting; + } + + private LedgerPosting unitPosting(String uuid, LedgerPostingType type, int amount, LedgerPostingUnitRole role) + { + var posting = new LedgerPosting(uuid); + + posting.setType(type); + posting.setAmount(Values.Amount.factorize(amount)); + posting.setCurrency(CurrencyUnit.EUR); + posting.setSemanticRole(type == LedgerPostingType.FEE ? LedgerPostingSemanticRole.FEE + : LedgerPostingSemanticRole.TAX); + posting.setUnitRole(role); + + return posting; + } + + private LedgerProjectionRef accountProjection(LedgerProjectionRole role, Account account, LedgerPosting posting) + { + var projection = new LedgerProjectionRef(role.name()); + + projection.setRole(role); + projection.setAccount(account); + projection.setPrimaryPosting(posting); + + return projection; + } + + private LedgerProjectionRef portfolioProjection(LedgerProjectionRole role, Portfolio portfolio, + LedgerPosting posting) + { + var projection = new LedgerProjectionRef(role.name()); + + projection.setRole(role); + projection.setPortfolio(portfolio); + projection.setPrimaryPosting(posting); + + return projection; + } + + private LedgerTransactionCreator creator(Client client) + { + return new LedgerTransactionCreator(client); + } + + private LedgerTransactionMetadata metadata() + { + return LedgerTransactionMetadata.of(DATE_TIME).withNote("note").withSource("source"); + } + + private Account account(String name) + { + var account = new Account(); + + account.setName(name); + account.setCurrencyCode(CurrencyUnit.EUR); + + return account; + } + + private Portfolio portfolio(String name) + { + var portfolio = new Portfolio(); + + portfolio.setName(name); + + return portfolio; + } + + private Security security(String name) + { + return new Security(name, CurrencyUnit.EUR); + } + + private Money money(int amount) + { + return Money.of(CurrencyUnit.EUR, Values.Amount.factorize(amount)); + } + + private Fixture fixture() + { + return new Fixture(account("Cash"), portfolio("Portfolio"), security("Siemens AG"), + security("Siemens Energy AG")); + } + + private record Fixture(Account account, Portfolio portfolio, Security siemens, Security siemensEnergy) + { + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptor.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptor.java new file mode 100644 index 0000000000..35bb35c517 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptor.java @@ -0,0 +1,88 @@ +package name.abuchen.portfolio.model.ledger.projection; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; + +/** + * Runtime-only description of a compatibility projection derived from Ledger + * entry and posting semantics. + */ +public final class DerivedProjectionDescriptor +{ + private final LedgerEntry entry; + private final LedgerProjectionRole role; + private final DerivedProjectionViewKind viewKind; + private final Account account; + private final Portfolio portfolio; + private final LedgerPosting primaryPosting; + private final List unitPostings; + private final String primarySelector; + private final String unitSelector; + + DerivedProjectionDescriptor(LedgerEntry entry, LedgerProjectionRole role, DerivedProjectionViewKind viewKind, + Account account, Portfolio portfolio, LedgerPosting primaryPosting, List unitPostings, + String primarySelector, String unitSelector) + { + this.entry = Objects.requireNonNull(entry); + this.role = Objects.requireNonNull(role); + this.viewKind = Objects.requireNonNull(viewKind); + this.account = account; + this.portfolio = portfolio; + this.primaryPosting = Objects.requireNonNull(primaryPosting); + this.unitPostings = List.copyOf(unitPostings); + this.primarySelector = Objects.requireNonNull(primarySelector); + this.unitSelector = Objects.requireNonNull(unitSelector); + } + + public LedgerEntry getEntry() + { + return entry; + } + + public LedgerProjectionRole getRole() + { + return role; + } + + public DerivedProjectionViewKind getViewKind() + { + return viewKind; + } + + public Account getAccount() + { + return account; + } + + public Portfolio getPortfolio() + { + return portfolio; + } + + public LedgerPosting getPrimaryPosting() + { + return primaryPosting; + } + + public List getUnitPostings() + { + return Collections.unmodifiableList(unitPostings); + } + + public String getPrimarySelector() + { + return primarySelector; + } + + public String getUnitSelector() + { + return unitSelector; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java new file mode 100644 index 0000000000..a877be4c27 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java @@ -0,0 +1,340 @@ +package name.abuchen.portfolio.model.ledger.projection; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerPostingDirection; +import name.abuchen.portfolio.model.ledger.LedgerPostingSemanticRole; +import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; + +/** + * Derives runtime projection descriptors from Ledger entry type and posting + * semantics. The descriptors are comparison metadata only; current projection + * refs remain the active materialization source. + */ +public final class DerivedProjectionDescriptorService +{ + public Result derive(LedgerEntry entry) + { + Objects.requireNonNull(entry); + + var descriptors = new ArrayList(); + var diagnostics = new ArrayList(); + + if (entry.getType() == null) + { + diagnostics.add(Diagnostic.missing(entry, null, "Ledger entry type is required")); //$NON-NLS-1$ + return new Result(descriptors, diagnostics); + } + + switch (entry.getType()) + { + case DEPOSIT, REMOVAL, INTEREST, INTEREST_CHARGE, FEES, FEES_REFUND, TAXES, TAX_REFUND, DIVIDENDS -> + account(entry, LedgerProjectionRole.ACCOUNT, primary(), diagnostics).ifPresent(descriptors::add); + case BUY, SELL -> { + account(entry, LedgerProjectionRole.ACCOUNT, + primary().and(semantic(LedgerPostingSemanticRole.CASH)), diagnostics) + .ifPresent(descriptors::add); + portfolio(entry, LedgerProjectionRole.PORTFOLIO, + primary().and(semantic(LedgerPostingSemanticRole.SECURITY)), diagnostics) + .ifPresent(descriptors::add); + } + case DELIVERY_INBOUND -> portfolio(entry, LedgerProjectionRole.DELIVERY_INBOUND, + primary().and(semantic(LedgerPostingSemanticRole.SECURITY)) + .and(direction(LedgerPostingDirection.INBOUND)), + diagnostics).ifPresent(descriptors::add); + case DELIVERY_OUTBOUND -> portfolio(entry, LedgerProjectionRole.DELIVERY_OUTBOUND, + primary().and(semantic(LedgerPostingSemanticRole.SECURITY)) + .and(direction(LedgerPostingDirection.OUTBOUND)), + diagnostics).ifPresent(descriptors::add); + case CASH_TRANSFER -> { + account(entry, LedgerProjectionRole.SOURCE_ACCOUNT, + primary().and(direction(LedgerPostingDirection.OUTBOUND)), diagnostics) + .ifPresent(descriptors::add); + account(entry, LedgerProjectionRole.TARGET_ACCOUNT, + primary().and(direction(LedgerPostingDirection.INBOUND)), diagnostics) + .ifPresent(descriptors::add); + } + case SECURITY_TRANSFER -> { + portfolio(entry, LedgerProjectionRole.SOURCE_PORTFOLIO, + primary().and(direction(LedgerPostingDirection.OUTBOUND)), diagnostics) + .ifPresent(descriptors::add); + portfolio(entry, LedgerProjectionRole.TARGET_PORTFOLIO, + primary().and(direction(LedgerPostingDirection.INBOUND)), diagnostics) + .ifPresent(descriptors::add); + } + case SPIN_OFF -> { + portfolio(entry, LedgerProjectionRole.OLD_SECURITY_LEG, + primary().and(corporateLeg(CorporateActionLeg.SOURCE_SECURITY)), diagnostics) + .ifPresent(descriptors::add); + portfolio(entry, LedgerProjectionRole.DELIVERY_INBOUND, + primary().and(corporateLeg(CorporateActionLeg.TARGET_SECURITY)) + .and(localKey(LedgerProjectionRole.DELIVERY_INBOUND)), + diagnostics).ifPresent(descriptors::add); + portfolio(entry, LedgerProjectionRole.NEW_SECURITY_LEG, + primary().and(corporateLeg(CorporateActionLeg.TARGET_SECURITY)) + .and(localKey(LedgerProjectionRole.NEW_SECURITY_LEG)), + diagnostics).ifPresent(descriptors::add); + optionalAccount(entry, LedgerProjectionRole.CASH_COMPENSATION, + primary().and(corporateLeg(CorporateActionLeg.CASH_COMPENSATION)), diagnostics) + .ifPresent(descriptors::add); + } + case STOCK_DIVIDEND, BONUS_ISSUE -> { + portfolio(entry, LedgerProjectionRole.DELIVERY_INBOUND, + primary().and(corporateLeg(CorporateActionLeg.TARGET_SECURITY)), diagnostics) + .ifPresent(descriptors::add); + optionalAccount(entry, LedgerProjectionRole.CASH_COMPENSATION, + primary().and(corporateLeg(CorporateActionLeg.CASH_COMPENSATION)), diagnostics) + .ifPresent(descriptors::add); + } + case RIGHTS_DISTRIBUTION -> { + portfolio(entry, LedgerProjectionRole.NEW_SECURITY_LEG, + primary().and(posting -> posting.getCorporateActionLeg() == CorporateActionLeg.RIGHT_SECURITY + || posting.getCorporateActionLeg() == CorporateActionLeg.DISTRIBUTED_SECURITY), + diagnostics).ifPresent(descriptors::add); + optionalPortfolio(entry, LedgerProjectionRole.OLD_SECURITY_LEG, + primary().and(corporateLeg(CorporateActionLeg.SOURCE_SECURITY)), diagnostics) + .ifPresent(descriptors::add); + optionalAccount(entry, LedgerProjectionRole.CASH_COMPENSATION, + primary().and(corporateLeg(CorporateActionLeg.CASH_COMPENSATION)), diagnostics) + .ifPresent(descriptors::add); + } + case BOND_CONVERSION -> { + portfolio(entry, LedgerProjectionRole.OLD_SECURITY_LEG, + primary().and(corporateLeg(CorporateActionLeg.CONVERSION_SOURCE)), diagnostics) + .ifPresent(descriptors::add); + portfolio(entry, LedgerProjectionRole.NEW_SECURITY_LEG, + primary().and(corporateLeg(CorporateActionLeg.CONVERSION_TARGET)), diagnostics) + .ifPresent(descriptors::add); + optionalAccount(entry, LedgerProjectionRole.CASH_COMPENSATION, + primary().and(corporateLeg(CorporateActionLeg.CASH_COMPENSATION)), diagnostics) + .ifPresent(descriptors::add); + } + default -> diagnostics.add(Diagnostic.missing(entry, null, + "No derived projection rule for entry type " + entry.getType())); //$NON-NLS-1$ + } + + return new Result(descriptors, diagnostics); + } + + private java.util.Optional account(LedgerEntry entry, LedgerProjectionRole role, + Predicate selector, List diagnostics) + { + return descriptor(entry, role, DerivedProjectionViewKind.ACCOUNT, selector, false, diagnostics); + } + + private java.util.Optional optionalAccount(LedgerEntry entry, LedgerProjectionRole role, + Predicate selector, List diagnostics) + { + return descriptor(entry, role, DerivedProjectionViewKind.ACCOUNT, selector, true, diagnostics); + } + + private java.util.Optional portfolio(LedgerEntry entry, LedgerProjectionRole role, + Predicate selector, List diagnostics) + { + return descriptor(entry, role, DerivedProjectionViewKind.PORTFOLIO, selector, false, diagnostics); + } + + private java.util.Optional optionalPortfolio(LedgerEntry entry, + LedgerProjectionRole role, Predicate selector, List diagnostics) + { + return descriptor(entry, role, DerivedProjectionViewKind.PORTFOLIO, selector, true, diagnostics); + } + + private java.util.Optional descriptor(LedgerEntry entry, LedgerProjectionRole role, + DerivedProjectionViewKind viewKind, Predicate selector, boolean optional, + List diagnostics) + { + var matches = entry.getPostings().stream().filter(selector).toList(); + + if (matches.isEmpty()) + { + if (!optional) + diagnostics.add(Diagnostic.missing(entry, role, "Missing semantic primary posting")); //$NON-NLS-1$ + return java.util.Optional.empty(); + } + + if (matches.size() > 1) + { + diagnostics.add(Diagnostic.ambiguous(entry, role, "Ambiguous semantic primary postings", matches)); //$NON-NLS-1$ + return java.util.Optional.empty(); + } + + var primary = matches.get(0); + var account = primary.getAccount(); + var portfolio = primary.getPortfolio(); + + if (viewKind == DerivedProjectionViewKind.ACCOUNT && account == null) + { + diagnostics.add(Diagnostic.missing(entry, role, "Semantic account owner is missing")); //$NON-NLS-1$ + return java.util.Optional.empty(); + } + + if (viewKind == DerivedProjectionViewKind.PORTFOLIO && portfolio == null) + { + diagnostics.add(Diagnostic.missing(entry, role, "Semantic portfolio owner is missing")); //$NON-NLS-1$ + return java.util.Optional.empty(); + } + + return java.util.Optional.of(new DerivedProjectionDescriptor(entry, role, viewKind, account, portfolio, + primary, unitPostings(entry, primary), primarySelector(role), unitSelector(primary))); + } + + private List unitPostings(LedgerEntry entry, LedgerPosting primary) + { + return entry.getPostings().stream() // + .filter(posting -> posting != primary) // + .filter(posting -> posting.getUnitRole() != null) // + .filter(posting -> posting.getUnitRole() != LedgerPostingUnitRole.PRIMARY) // + .filter(posting -> primary.getGroupKey() == null + || Objects.equals(primary.getGroupKey(), posting.getGroupKey())) + .toList(); + } + + private String primarySelector(LedgerProjectionRole role) + { + return "semantic primary for " + role; //$NON-NLS-1$ + } + + private String unitSelector(LedgerPosting primary) + { + return primary.getGroupKey() == null ? "semantic unit role" //$NON-NLS-1$ + : "semantic unit role in group " + primary.getGroupKey(); //$NON-NLS-1$ + } + + private Predicate primary() + { + return posting -> posting.getUnitRole() == LedgerPostingUnitRole.PRIMARY; + } + + private Predicate semantic(LedgerPostingSemanticRole role) + { + return posting -> posting.getSemanticRole() == role; + } + + private Predicate direction(LedgerPostingDirection direction) + { + return posting -> posting.getDirection() == direction; + } + + private Predicate corporateLeg(CorporateActionLeg leg) + { + return posting -> posting.getCorporateActionLeg() == leg; + } + + private Predicate localKey(LedgerProjectionRole role) + { + return posting -> role.name().equals(posting.getLocalKey()); + } + + public static final class Result + { + private final List descriptors; + private final List diagnostics; + + private Result(List descriptors, List diagnostics) + { + this.descriptors = List.copyOf(descriptors); + this.diagnostics = List.copyOf(diagnostics); + } + + public boolean isOK() + { + return diagnostics.isEmpty(); + } + + public List getDescriptors() + { + return Collections.unmodifiableList(descriptors); + } + + public List getDiagnostics() + { + return Collections.unmodifiableList(diagnostics); + } + + public String formatDiagnostics() + { + return diagnostics.stream().map(Diagnostic::format).collect(Collectors.joining("\n")); //$NON-NLS-1$ + } + } + + public static final class Diagnostic + { + public enum IssueCode + { + MISSING_SEMANTIC_PRIMARY, + AMBIGUOUS_SEMANTIC_PRIMARY + } + + private final IssueCode code; + private final LedgerEntry entry; + private final LedgerProjectionRole role; + private final String message; + private final List postingUUIDs; + + private Diagnostic(IssueCode code, LedgerEntry entry, LedgerProjectionRole role, String message, + List postingUUIDs) + { + this.code = Objects.requireNonNull(code); + this.entry = Objects.requireNonNull(entry); + this.role = role; + this.message = Objects.requireNonNull(message); + this.postingUUIDs = List.copyOf(postingUUIDs); + } + + private static Diagnostic missing(LedgerEntry entry, LedgerProjectionRole role, String message) + { + return new Diagnostic(IssueCode.MISSING_SEMANTIC_PRIMARY, entry, role, message, List.of()); + } + + private static Diagnostic ambiguous(LedgerEntry entry, LedgerProjectionRole role, String message, + List postings) + { + return new Diagnostic(IssueCode.AMBIGUOUS_SEMANTIC_PRIMARY, entry, role, message, + postings.stream().map(LedgerPosting::getUUID).toList()); + } + + public IssueCode getCode() + { + return code; + } + + public LedgerEntry getEntry() + { + return entry; + } + + public LedgerProjectionRole getRole() + { + return role; + } + + public String getMessage() + { + return message; + } + + public List getPostingUUIDs() + { + return Collections.unmodifiableList(postingUUIDs); + } + + public String format() + { + return "[" + code + "] entry=" + entry.getUUID() + " role=" + role + " " + message //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ + + (postingUUIDs.isEmpty() ? "" : " postings=" + postingUUIDs); //$NON-NLS-1$ //$NON-NLS-2$ + } + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionViewKind.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionViewKind.java new file mode 100644 index 0000000000..ddedb88b9c --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionViewKind.java @@ -0,0 +1,11 @@ +package name.abuchen.portfolio.model.ledger.projection; + +/** + * Classifies the compatibility owner-list view described by a derived projection + * descriptor. + */ +public enum DerivedProjectionViewKind +{ + ACCOUNT, + PORTFOLIO +} From da0dd6c2b9b7e43aec8daada6a69edce9ef88c52 Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:58:25 +0200 Subject: [PATCH 15/68] Emit semantic postings from Ledger creators Ledger transaction creators and the native entry assembler now populate semantic posting roles, directions, corporate action legs, unit roles, and stable grouping metadata when creating entries. This unblocks descriptor derivation from production-created entries while preserving the existing ProjectionRef and ProjectionMembership materialization path. Runtime materialization, persistence schemas, fixture generation, InvestmentPlan linkage, and UUID/projection-ref removal are intentionally left unchanged. --- .../ledger/LedgerTransactionCreatorTest.java | 133 ++++++++++++++ .../LedgerNativeEntryAssemblerTest.java | 166 ++++++++++++++++++ ...erivedProjectionDescriptorServiceTest.java | 34 +++- .../model/ledger/LedgerModelCopy.java | 6 + .../LedgerTransactionCreator.java | 97 ++++++++++ .../LedgerUnitPostingUpdater.java | 24 +++ .../LedgerNativeEntryDefinitionValidator.java | 17 +- .../LedgerNativeEntryAssembler.java | 80 +++++++++ 8 files changed, 545 insertions(+), 12 deletions(-) diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerTransactionCreatorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerTransactionCreatorTest.java index 928e9a7675..952fda06c8 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerTransactionCreatorTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerTransactionCreatorTest.java @@ -325,6 +325,108 @@ public void testCreateStandardFamiliesBindProjectionRefsToPrimaryPostings() LedgerProjectionRole.SOURCE_PORTFOLIO, LedgerProjectionRole.TARGET_PORTFOLIO); } + @Test + public void testCreateStandardFamiliesEmitSemanticPostings() + { + assertAccountOnlySemantics(LedgerEntryType.DEPOSIT, + creator -> creator.createDeposit(metadata(), cashLeg(account(), 10)).getEntry(), + LedgerPostingType.CASH, LedgerPostingSemanticRole.CASH); + assertAccountOnlySemantics(LedgerEntryType.REMOVAL, + creator -> creator.createRemoval(metadata(), cashLeg(account(), 10)).getEntry(), + LedgerPostingType.CASH, LedgerPostingSemanticRole.CASH); + assertAccountOnlySemantics(LedgerEntryType.INTEREST, + creator -> creator.createInterest(metadata(), cashLeg(account(), 10), + LedgerOptionalSecurity.none(), LedgerCreationUnits.none()).getEntry(), + LedgerPostingType.CASH, LedgerPostingSemanticRole.CASH); + assertAccountOnlySemantics(LedgerEntryType.INTEREST_CHARGE, + creator -> creator.createInterestCharge(metadata(), cashLeg(account(), 10), + LedgerOptionalSecurity.none(), LedgerCreationUnits.none()).getEntry(), + LedgerPostingType.CASH, LedgerPostingSemanticRole.CASH); + assertAccountOnlySemantics(LedgerEntryType.DIVIDENDS, + creator -> creator.createDividend(metadata(), + LedgerDividend.withoutExDate(cashLeg(account(), 10), + LedgerOptionalSecurity.of(security()), + LedgerCreationUnits.none())) + .getEntry(), + LedgerPostingType.CASH, LedgerPostingSemanticRole.CASH); + assertAccountOnlySemantics(LedgerEntryType.FEES, + creator -> creator.createFee(metadata(), cashLeg(account(), 10), + LedgerOptionalSecurity.none(), LedgerCreationUnits.none()).getEntry(), + LedgerPostingType.FEE, LedgerPostingSemanticRole.FEE); + assertAccountOnlySemantics(LedgerEntryType.FEES_REFUND, + creator -> creator.createFeeRefund(metadata(), cashLeg(account(), 10), + LedgerOptionalSecurity.none(), LedgerCreationUnits.none()).getEntry(), + LedgerPostingType.FEE, LedgerPostingSemanticRole.FEE); + assertAccountOnlySemantics(LedgerEntryType.TAXES, + creator -> creator.createTax(metadata(), cashLeg(account(), 10), + LedgerOptionalSecurity.none(), LedgerCreationUnits.none()).getEntry(), + LedgerPostingType.TAX, LedgerPostingSemanticRole.TAX); + assertAccountOnlySemantics(LedgerEntryType.TAX_REFUND, + creator -> creator.createTaxRefund(metadata(), cashLeg(account(), 10), + LedgerOptionalSecurity.none(), LedgerCreationUnits.none()).getEntry(), + LedgerPostingType.TAX, LedgerPostingSemanticRole.TAX); + + var client = new Client(); + var creator = new LedgerTransactionCreator(client); + var buy = creator.createBuy(metadata(), cashLeg(account(), 10), portfolioLeg(new Portfolio(), 10), + LedgerCreationUnits.of(LedgerCreationUnit.fee(money(1)))).getEntry(); + + assertPrimarySemantics(buy.getPostings().get(0), LedgerPostingType.CASH, LedgerPostingSemanticRole.CASH, + LedgerPostingDirection.OUTBOUND, LedgerProjectionRole.ACCOUNT); + assertPrimarySemantics(buy.getPostings().get(1), LedgerPostingType.SECURITY, + LedgerPostingSemanticRole.SECURITY, LedgerPostingDirection.INBOUND, + LedgerProjectionRole.PORTFOLIO); + assertUnitSemantics(posting(buy, LedgerPostingType.FEE), LedgerPostingSemanticRole.FEE, + LedgerPostingUnitRole.FEE); + + var sell = creator.createSell(metadata(), cashLeg(account(), 10), portfolioLeg(new Portfolio(), 10), + LedgerCreationUnits.none()).getEntry(); + + assertPrimarySemantics(sell.getPostings().get(0), LedgerPostingType.CASH, LedgerPostingSemanticRole.CASH, + LedgerPostingDirection.INBOUND, LedgerProjectionRole.ACCOUNT); + assertPrimarySemantics(sell.getPostings().get(1), LedgerPostingType.SECURITY, + LedgerPostingSemanticRole.SECURITY, LedgerPostingDirection.OUTBOUND, + LedgerProjectionRole.PORTFOLIO); + } + + @Test + public void testCreateTransferAndDeliveryFamiliesEmitDirectionalSemanticPostings() + { + var client = new Client(); + var creator = new LedgerTransactionCreator(client); + var inbound = creator.createInboundDelivery(metadata(), deliveryLeg(new Portfolio())).getEntry(); + var outbound = creator.createOutboundDelivery(metadata(), deliveryLeg(new Portfolio())).getEntry(); + + assertPrimarySemantics(inbound.getPostings().get(0), LedgerPostingType.SECURITY, + LedgerPostingSemanticRole.SECURITY, LedgerPostingDirection.INBOUND, + LedgerProjectionRole.DELIVERY_INBOUND); + assertPrimarySemantics(outbound.getPostings().get(0), LedgerPostingType.SECURITY, + LedgerPostingSemanticRole.SECURITY, LedgerPostingDirection.OUTBOUND, + LedgerProjectionRole.DELIVERY_OUTBOUND); + + var cashTransfer = creator.createAccountTransfer(metadata(), LedgerCashTransferLeg.of(account(), money(10)), + LedgerCashTransferLeg.of(account(), money(10))).getEntry(); + + assertPrimarySemantics(cashTransfer.getPostings().get(0), LedgerPostingType.CASH, + LedgerPostingSemanticRole.CASH, LedgerPostingDirection.OUTBOUND, + LedgerProjectionRole.SOURCE_ACCOUNT); + assertPrimarySemantics(cashTransfer.getPostings().get(1), LedgerPostingType.CASH, + LedgerPostingSemanticRole.CASH, LedgerPostingDirection.INBOUND, + LedgerProjectionRole.TARGET_ACCOUNT); + + var securityTransfer = creator.createPortfolioTransfer(metadata(), + LedgerPortfolioTransferSecurity.of(security(), Values.Share.factorize(5)), + LedgerPortfolioTransferLeg.of(new Portfolio(), money(10)), + LedgerPortfolioTransferLeg.of(new Portfolio(), money(10))).getEntry(); + + assertPrimarySemantics(securityTransfer.getPostings().get(0), LedgerPostingType.SECURITY, + LedgerPostingSemanticRole.SECURITY, LedgerPostingDirection.OUTBOUND, + LedgerProjectionRole.SOURCE_PORTFOLIO); + assertPrimarySemantics(securityTransfer.getPostings().get(1), LedgerPostingType.SECURITY, + LedgerPostingSemanticRole.SECURITY, LedgerPostingDirection.INBOUND, + LedgerProjectionRole.TARGET_PORTFOLIO); + } + /** * Checks the ledger-backed editing scenario: create dividend preserves ex-date units and forex. * The visible transaction must reflect the ledger entry after the operation. @@ -602,6 +704,37 @@ private void assertUnitForex(LedgerEntry entry, LedgerPostingType type, long for assertThat(posting.getExchangeRate(), is(exchangeRate)); } + private void assertAccountOnlySemantics(LedgerEntryType type, + java.util.function.Function factory, + LedgerPostingType postingType, LedgerPostingSemanticRole semanticRole) + { + var client = new Client(); + var entry = factory.apply(new LedgerTransactionCreator(client)); + + assertThat(entry.getType(), is(type)); + assertPrimarySemantics(entry.getPostings().get(0), postingType, semanticRole, LedgerPostingDirection.NEUTRAL, + LedgerProjectionRole.ACCOUNT); + assertOK(client); + } + + private void assertPrimarySemantics(LedgerPosting posting, LedgerPostingType type, + LedgerPostingSemanticRole semanticRole, LedgerPostingDirection direction, + LedgerProjectionRole localKey) + { + assertThat(posting.getType(), is(type)); + assertThat(posting.getSemanticRole(), is(semanticRole)); + assertThat(posting.getDirection(), is(direction)); + assertThat(posting.getUnitRole(), is(LedgerPostingUnitRole.PRIMARY)); + assertThat(posting.getLocalKey(), is(localKey.name())); + } + + private void assertUnitSemantics(LedgerPosting posting, LedgerPostingSemanticRole semanticRole, + LedgerPostingUnitRole unitRole) + { + assertThat(posting.getSemanticRole(), is(semanticRole)); + assertThat(posting.getUnitRole(), is(unitRole)); + } + private LedgerProjectionRef projection(LedgerEntry entry, LedgerProjectionRole role) { return entry.getProjectionRefs().stream().filter(p -> p.getRole() == role).findFirst().orElseThrow(); diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblerTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblerTest.java index 98626d18da..452f8e911c 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblerTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblerTest.java @@ -25,10 +25,14 @@ import name.abuchen.portfolio.model.ledger.Ledger; import name.abuchen.portfolio.model.ledger.LedgerParameter; import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerPostingDirection; +import name.abuchen.portfolio.model.ledger.LedgerPostingSemanticRole; +import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; import name.abuchen.portfolio.model.ledger.ProjectionMembershipRole; import name.abuchen.portfolio.model.ledger.configuration.CashCompensationKind; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; import name.abuchen.portfolio.model.ledger.configuration.CorporateActionKind; import name.abuchen.portfolio.model.ledger.configuration.CorporateActionSubtype; import name.abuchen.portfolio.model.ledger.configuration.EventStage; @@ -42,6 +46,8 @@ import name.abuchen.portfolio.model.ledger.configuration.LedgerReportingClass; import name.abuchen.portfolio.model.ledger.configuration.RoundingModeCode; import name.abuchen.portfolio.model.ledger.configuration.TaxReason; +import name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor; +import name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptorService; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; import name.abuchen.portfolio.money.CurrencyUnit; import name.abuchen.portfolio.money.Money; @@ -398,6 +404,45 @@ public void testBuildsDetachedMinimalStockDividendThroughGenericForType() assertThat(fixture.client.getLedger().getEntries().size(), is(0)); } + @Test + public void testNativeAssemblerEmitsSemanticPostingsForSiemensSpinOff() + { + var fixture = fixture(); + var entry = spinOffWithRetainedAndNewLegs(fixture).buildDetached().getEntry(); + var oldLeg = descriptor(entry, LedgerProjectionRole.OLD_SECURITY_LEG).getPrimaryPosting(); + var retainedLeg = descriptor(entry, LedgerProjectionRole.DELIVERY_INBOUND).getPrimaryPosting(); + var newLeg = descriptor(entry, LedgerProjectionRole.NEW_SECURITY_LEG).getPrimaryPosting(); + var compensation = descriptor(entry, LedgerProjectionRole.CASH_COMPENSATION).getPrimaryPosting(); + + assertPrimarySemantics(oldLeg, LedgerPostingSemanticRole.SECURITY, LedgerPostingDirection.OUTBOUND, + CorporateActionLeg.SOURCE_SECURITY, LedgerProjectionRole.OLD_SECURITY_LEG); + assertPrimarySemantics(retainedLeg, LedgerPostingSemanticRole.SECURITY, LedgerPostingDirection.INBOUND, + CorporateActionLeg.TARGET_SECURITY, LedgerProjectionRole.DELIVERY_INBOUND); + assertPrimarySemantics(newLeg, LedgerPostingSemanticRole.SECURITY, LedgerPostingDirection.INBOUND, + CorporateActionLeg.TARGET_SECURITY, LedgerProjectionRole.NEW_SECURITY_LEG); + assertPrimarySemantics(compensation, LedgerPostingSemanticRole.CASH_COMPENSATION, + LedgerPostingDirection.NEUTRAL, CorporateActionLeg.CASH_COMPENSATION, + LedgerProjectionRole.CASH_COMPENSATION); + assertThat(entry.getPostings().stream().filter(posting -> posting.getType() == LedgerPostingType.FEE) + .findFirst().orElseThrow().getGroupKey(), is(LedgerProjectionRole.CASH_COMPENSATION.name())); + assertThat(entry.getPostings().stream().filter(posting -> posting.getType() == LedgerPostingType.TAX) + .findFirst().orElseThrow().getUnitRole(), is(LedgerPostingUnitRole.TAX)); + } + + @Test + public void testNativeAssemblerDescriptorsCoverCorporateActionFamilies() + { + assertThat(roles(stockDividendEntry(fixture()).buildDetached().getEntry()), + is(java.util.Set.of(LedgerProjectionRole.DELIVERY_INBOUND))); + assertThat(roles(bonusIssueEntry(fixture()).buildDetached().getEntry()), + is(java.util.Set.of(LedgerProjectionRole.DELIVERY_INBOUND))); + assertThat(roles(rightsDistributionEntry(fixture()).buildDetached().getEntry()), + is(java.util.Set.of(LedgerProjectionRole.NEW_SECURITY_LEG))); + assertThat(roles(bondConversionEntry(fixture()).buildDetached().getEntry()), + is(java.util.Set.of(LedgerProjectionRole.OLD_SECURITY_LEG, + LedgerProjectionRole.NEW_SECURITY_LEG))); + } + /** * Checks the Ledger-V6 scenario: build and add creates spin off and materializes runtime projections. * The result must keep ledger truth and visible runtime rows consistent. @@ -604,6 +649,93 @@ private static LedgerNativeEntryAssembler.EntryBuilder baseSpinOff(Fixture fixtu .event(event(LedgerEntryType.SPIN_OFF)); } + private static LedgerNativeEntryAssembler.EntryBuilder spinOffWithRetainedAndNewLegs(Fixture fixture) + { + return baseSpinOff(fixture) // + .securityLeg(sourceLeg(fixture).build()) // + .securityLeg(targetLeg(fixture).projectAs(LedgerProjectionRole.DELIVERY_INBOUND).build()) // + .securityLeg(targetLeg(fixture).projectAs(LedgerProjectionRole.NEW_SECURITY_LEG).build()) // + .cashCompensation(NativeCashCompensation.builder() // + .account(fixture.account) // + .amount(money(5)) // + .kind(CashCompensationKind.CASH_IN_LIEU) // + .applied(true) // + .fractionQuantity(new BigDecimal("0.5")) // + .fractionTreatment(FractionTreatment.CASH_IN_LIEU) // + .roundingMode(RoundingModeCode.FLOOR) // + .build()) // + .fee(NativeFee.of(fixture.account, money(2), FeeReason.CORPORATE_ACTION_FEE)) // + .tax(NativeTax.withholding(fixture.account, money(1))); + } + + private static LedgerNativeEntryAssembler.EntryBuilder stockDividendEntry(Fixture fixture) + { + return LedgerNativeEntryAssembler.forClient(fixture.client) // + .forType(LedgerEntryType.STOCK_DIVIDEND) // + .metadata(metadata()) // + .event(event(LedgerEntryType.STOCK_DIVIDEND)) // + .securityLeg(targetLeg(fixture).projectAs(LedgerProjectionRole.DELIVERY_INBOUND).build()); + } + + private static LedgerNativeEntryAssembler.EntryBuilder bonusIssueEntry(Fixture fixture) + { + return LedgerNativeEntryAssembler.forClient(fixture.client) // + .forType(LedgerEntryType.BONUS_ISSUE) // + .metadata(metadata()) // + .event(event(LedgerEntryType.BONUS_ISSUE)) // + .securityLeg(targetLeg(fixture).projectAs(LedgerProjectionRole.DELIVERY_INBOUND).build()); + } + + private static LedgerNativeEntryAssembler.EntryBuilder rightsDistributionEntry(Fixture fixture) + { + var distributedSecurity = NativeSecurityLeg.ofType(LedgerPostingType.SECURITY) // + .legCode(CorporateActionLeg.DISTRIBUTED_SECURITY.getCode()) // + .portfolio(fixture.portfolio) // + .security(fixture.siemensEnergy) // + .shares(Values.Share.factorize(5)) // + .amount(money(50)) // + .projectAs(LedgerProjectionRole.NEW_SECURITY_LEG) // + .build(); + + return LedgerNativeEntryAssembler.forClient(fixture.client) // + .forType(LedgerEntryType.RIGHTS_DISTRIBUTION) // + .metadata(metadata()) // + .event(event(LedgerEntryType.RIGHTS_DISTRIBUTION)) // + .securityLeg(distributedSecurity); + } + + private static LedgerNativeEntryAssembler.EntryBuilder bondConversionEntry(Fixture fixture) + { + var sourceBond = NativeSecurityLeg.ofType(LedgerPostingType.BOND) // + .legCode(CorporateActionLeg.CONVERSION_SOURCE.getCode()) // + .portfolio(fixture.portfolio) // + .security(fixture.siemens) // + .shares(Values.Share.factorize(10)) // + .amount(money(100)) // + .parameter(LedgerParameterType.SOURCE_SECURITY, fixture.siemens) // + .parameter(LedgerParameterType.NOMINAL_VALUE, money(100)) // + .parameter(LedgerParameterType.QUOTATION_STYLE, "PERCENT") // + .parameter(LedgerParameterType.CONVERSION_RATIO, BigDecimal.valueOf(2)) // + .projectAs(LedgerProjectionRole.OLD_SECURITY_LEG) // + .build(); + var targetSecurity = NativeSecurityLeg.ofType(LedgerPostingType.SECURITY) // + .legCode(CorporateActionLeg.CONVERSION_TARGET.getCode()) // + .portfolio(fixture.portfolio) // + .security(fixture.siemensEnergy) // + .shares(Values.Share.factorize(5)) // + .amount(money(50)) // + .parameter(LedgerParameterType.TARGET_SECURITY, fixture.siemensEnergy) // + .projectAs(LedgerProjectionRole.NEW_SECURITY_LEG) // + .build(); + + return LedgerNativeEntryAssembler.forClient(fixture.client) // + .forType(LedgerEntryType.BOND_CONVERSION) // + .metadata(metadata()) // + .event(event(LedgerEntryType.BOND_CONVERSION)) // + .securityLeg(sourceBond) // + .securityLeg(targetSecurity); + } + private static NativeEntryMetadata metadata() { return NativeEntryMetadata.of(LocalDateTime.of(2020, 9, 28, 0, 0)) // @@ -660,6 +792,40 @@ private static LedgerParameter parameter(Collection> param return parameters.stream().filter(parameter -> parameter.getType() == type).findFirst().orElseThrow(); } + private static DerivedProjectionDescriptor descriptor(name.abuchen.portfolio.model.ledger.LedgerEntry entry, + LedgerProjectionRole role) + { + return descriptors(entry).stream().filter(descriptor -> descriptor.getRole() == role).findFirst() + .orElseThrow(); + } + + private static java.util.List descriptors( + name.abuchen.portfolio.model.ledger.LedgerEntry entry) + { + var result = new DerivedProjectionDescriptorService().derive(entry); + + assertTrue(result.formatDiagnostics(), result.isOK()); + + return result.getDescriptors(); + } + + private static java.util.Set roles(name.abuchen.portfolio.model.ledger.LedgerEntry entry) + { + return descriptors(entry).stream().map(DerivedProjectionDescriptor::getRole) + .collect(Collectors.toSet()); + } + + private static void assertPrimarySemantics(LedgerPosting posting, LedgerPostingSemanticRole semanticRole, + LedgerPostingDirection direction, CorporateActionLeg leg, LedgerProjectionRole role) + { + assertThat(posting.getSemanticRole(), is(semanticRole)); + assertThat(posting.getDirection(), is(direction)); + assertThat(posting.getCorporateActionLeg(), is(leg)); + assertThat(posting.getUnitRole(), is(LedgerPostingUnitRole.PRIMARY)); + assertThat(posting.getGroupKey(), is(role.name())); + assertThat(posting.getLocalKey(), is(role.name())); + } + private static LedgerBackedTransaction portfolioProjection(Portfolio portfolio, LedgerProjectionRole role) { return portfolio.getTransactions().stream() // diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorServiceTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorServiceTest.java index d2bcc79226..338b61963e 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorServiceTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorServiceTest.java @@ -61,8 +61,6 @@ public void testAccountOnlyDescriptorMatchesProjectionRef() var account = account("Cash"); var entry = creator(client).createDeposit(metadata(), LedgerAccountCashLeg.of(account, money(100))).getEntry(); - annotateFromProjectionRefs(entry); - var descriptors = descriptors(entry); assertThat(descriptors.size(), is(1)); @@ -81,10 +79,22 @@ public void testBuySellDescriptorsMatchAccountAndPortfolioProjectionRefs() money(100)), LedgerCreationUnits.none()).getEntry(); - annotateFromProjectionRefs(entry); - var descriptors = descriptors(entry); + assertThat(roles(descriptors), is(Set.of(LedgerProjectionRole.ACCOUNT, LedgerProjectionRole.PORTFOLIO))); + assertMatchesProjectionRef(entry, descriptor(descriptors, LedgerProjectionRole.ACCOUNT), + projection(entry, LedgerProjectionRole.ACCOUNT)); + assertMatchesProjectionRef(entry, descriptor(descriptors, LedgerProjectionRole.PORTFOLIO), + projection(entry, LedgerProjectionRole.PORTFOLIO)); + + entry = creator(client).createSell(metadata(), LedgerAccountCashLeg.of(account, money(100)), + LedgerPortfolioSecurityLeg.of(portfolio, + LedgerSecurityQuantity.of(security("Security"), Values.Share.factorize(5)), + money(100)), + LedgerCreationUnits.none()).getEntry(); + + descriptors = descriptors(entry); + assertThat(roles(descriptors), is(Set.of(LedgerProjectionRole.ACCOUNT, LedgerProjectionRole.PORTFOLIO))); assertMatchesProjectionRef(entry, descriptor(descriptors, LedgerProjectionRole.ACCOUNT), projection(entry, LedgerProjectionRole.ACCOUNT)); @@ -103,12 +113,22 @@ public void testDeliveryDescriptorMatchesProjectionRef() money(100))) .getEntry(); - annotateFromProjectionRefs(entry); - var descriptors = descriptors(entry); assertThat(roles(descriptors), is(Set.of(LedgerProjectionRole.DELIVERY_INBOUND))); assertMatchesProjectionRef(entry, descriptors.get(0), projection(entry, LedgerProjectionRole.DELIVERY_INBOUND)); + + entry = creator(client).createOutboundDelivery(metadata(), + LedgerDeliveryLeg.of(portfolio, + LedgerSecurityQuantity.of(security("Security"), Values.Share.factorize(5)), + money(100))) + .getEntry(); + + descriptors = descriptors(entry); + + assertThat(roles(descriptors), is(Set.of(LedgerProjectionRole.DELIVERY_OUTBOUND))); + assertMatchesProjectionRef(entry, descriptors.get(0), + projection(entry, LedgerProjectionRole.DELIVERY_OUTBOUND)); } @Test @@ -120,7 +140,6 @@ public void testCashTransferDerivesSourceAndTargetWithoutPostingOrder() var entry = creator(client).createAccountTransfer(metadata(), LedgerCashTransferLeg.of(source, money(100)), LedgerCashTransferLeg.of(target, money(100))).getEntry(); - annotateFromProjectionRefs(entry); moveFirstPostingToEnd(entry); var descriptors = descriptors(entry); @@ -145,7 +164,6 @@ public void testSecurityTransferDerivesSourceAndTargetWithoutPostingOrder() LedgerPortfolioTransferLeg.of(source, money(100)), LedgerPortfolioTransferLeg.of(target, money(100))).getEntry(); - annotateFromProjectionRefs(entry); moveFirstPostingToEnd(entry); var descriptors = descriptors(entry); 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 index 15e540b456..b41a76fbce 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerModelCopy.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerModelCopy.java @@ -53,6 +53,12 @@ static LedgerPosting copyPosting(LedgerPosting source) 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; diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerTransactionCreator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerTransactionCreator.java index 3d54065abc..559ff2dd83 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerTransactionCreator.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerTransactionCreator.java @@ -12,6 +12,9 @@ import name.abuchen.portfolio.model.ledger.LedgerMutationContext; import name.abuchen.portfolio.model.ledger.LedgerParameter; import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerPostingDirection; +import name.abuchen.portfolio.model.ledger.LedgerPostingSemanticRole; +import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.LedgerTransactionMetadata; @@ -108,6 +111,8 @@ public CreatedTransaction createDividend(LedgerTransactionMetadata metadata, Led applySecurity(cashPosting, dividend.getSecurity()); cashPosting.setShares(dividend.getShares()); + markPrimary(cashPosting, LedgerPostingSemanticRole.CASH, LedgerPostingDirection.NEUTRAL, + LedgerProjectionRole.ACCOUNT); if (dividend.hasExDate()) cashPosting.addParameter( @@ -163,6 +168,10 @@ LedgerEntry createAccountTransferEntry(LedgerTransactionMetadata metadata, Ledge var sourcePosting = createCashPosting(LedgerPostingType.CASH, source); var targetPosting = createCashPosting(LedgerPostingType.CASH, target); + markPrimary(sourcePosting, LedgerPostingSemanticRole.CASH, LedgerPostingDirection.OUTBOUND, + LedgerProjectionRole.SOURCE_ACCOUNT); + markPrimary(targetPosting, LedgerPostingSemanticRole.CASH, LedgerPostingDirection.INBOUND, + LedgerProjectionRole.TARGET_ACCOUNT); entry.addPosting(sourcePosting); entry.addPosting(targetPosting); @@ -193,6 +202,10 @@ LedgerEntry createPortfolioTransferEntry(LedgerTransactionMetadata metadata, var sourcePosting = createSecurityPosting(source, security); var targetPosting = createSecurityPosting(target, security); + markPrimary(sourcePosting, LedgerPostingSemanticRole.SECURITY, LedgerPostingDirection.OUTBOUND, + LedgerProjectionRole.SOURCE_PORTFOLIO); + markPrimary(targetPosting, LedgerPostingSemanticRole.SECURITY, LedgerPostingDirection.INBOUND, + LedgerProjectionRole.TARGET_PORTFOLIO); entry.addPosting(sourcePosting); entry.addPosting(targetPosting); @@ -216,6 +229,7 @@ private LedgerEntry createAccountEntry(LedgerTransactionMetadata metadata, Ledge var posting = createCashPosting(postingType, cashLeg); applySecurity(posting, security); + markPrimary(posting, semanticRole(postingType), LedgerPostingDirection.NEUTRAL, LedgerProjectionRole.ACCOUNT); entry.addPosting(posting); var unitPostings = addUnitPostings(entry, units); @@ -241,6 +255,7 @@ private LedgerEntry createDeliveryEntry(LedgerTransactionMetadata metadata, Ledg posting.setSecurity(securityQuantity.getSecurity()); posting.setShares(securityQuantity.getShares()); applyMoney(posting, value, deliveryLeg.getForex()); + markPrimary(posting, LedgerPostingSemanticRole.SECURITY, direction(role), role); entry.addPosting(posting); var unitPostings = addUnitPostings(entry, deliveryLeg.getUnits()); @@ -262,6 +277,10 @@ private LedgerEntry createBuySellEntry(LedgerTransactionMetadata metadata, Ledge var cashPosting = createCashPosting(LedgerPostingType.CASH, cashLeg); var securityPosting = createSecurityPosting(securityLeg); + markPrimary(cashPosting, LedgerPostingSemanticRole.CASH, cashDirection(entryType), + LedgerProjectionRole.ACCOUNT); + markPrimary(securityPosting, LedgerPostingSemanticRole.SECURITY, securityDirection(entryType), + LedgerProjectionRole.PORTFOLIO); entry.addPosting(cashPosting); entry.addPosting(securityPosting); @@ -351,6 +370,7 @@ private List addUnitPostings(LedgerEntry entry, LedgerCreationUni posting.setType(unit.getPostingType()); applyMoney(posting, unit.getAmount(), unit.getForex()); + markUnit(posting); entry.addPosting(posting); postings.add(posting); } @@ -391,6 +411,83 @@ private void applySecurity(LedgerPosting posting, LedgerOptionalSecurity securit posting.setSecurity(security.getSecurity()); } + private void markPrimary(LedgerPosting posting, LedgerPostingSemanticRole semanticRole, + LedgerPostingDirection direction, LedgerProjectionRole role) + { + posting.setSemanticRole(semanticRole); + posting.setDirection(direction); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + posting.setLocalKey(role.name()); + } + + private void markUnit(LedgerPosting posting) + { + posting.setSemanticRole(semanticRole(posting.getType())); + posting.setUnitRole(unitRole(posting.getType())); + } + + private LedgerPostingSemanticRole semanticRole(LedgerPostingType type) + { + return switch (type) + { + case CASH -> LedgerPostingSemanticRole.CASH; + case SECURITY -> LedgerPostingSemanticRole.SECURITY; + case FEE -> LedgerPostingSemanticRole.FEE; + case TAX -> LedgerPostingSemanticRole.TAX; + case GROSS_VALUE -> LedgerPostingSemanticRole.GROSS_VALUE; + case CASH_COMPENSATION -> LedgerPostingSemanticRole.CASH_COMPENSATION; + case RIGHT -> LedgerPostingSemanticRole.RIGHT; + case BOND -> LedgerPostingSemanticRole.BOND; + case ACCRUED_INTEREST -> LedgerPostingSemanticRole.ACCRUED_INTEREST; + case PRINCIPAL_REDEMPTION -> LedgerPostingSemanticRole.PRINCIPAL_REDEMPTION; + case FOREX -> LedgerPostingSemanticRole.FOREX_CONTEXT; + }; + } + + private LedgerPostingUnitRole unitRole(LedgerPostingType type) + { + return switch (type) + { + case FEE -> LedgerPostingUnitRole.FEE; + case TAX -> LedgerPostingUnitRole.TAX; + case GROSS_VALUE -> LedgerPostingUnitRole.GROSS_VALUE; + default -> throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_062 + .message("Unsupported unit posting type: " + type)); //$NON-NLS-1$ + }; + } + + private LedgerPostingDirection cashDirection(LedgerEntryType entryType) + { + return switch (entryType) + { + case BUY -> LedgerPostingDirection.OUTBOUND; + case SELL -> LedgerPostingDirection.INBOUND; + default -> LedgerPostingDirection.NEUTRAL; + }; + } + + private LedgerPostingDirection securityDirection(LedgerEntryType entryType) + { + return switch (entryType) + { + case BUY -> LedgerPostingDirection.INBOUND; + case SELL -> LedgerPostingDirection.OUTBOUND; + default -> LedgerPostingDirection.NEUTRAL; + }; + } + + private LedgerPostingDirection direction(LedgerProjectionRole role) + { + return switch (role) + { + case SOURCE_ACCOUNT, SOURCE_PORTFOLIO, OLD_SECURITY_LEG, DELIVERY_OUTBOUND -> + LedgerPostingDirection.OUTBOUND; + case TARGET_ACCOUNT, TARGET_PORTFOLIO, NEW_SECURITY_LEG, DELIVERY, DELIVERY_INBOUND -> + LedgerPostingDirection.INBOUND; + default -> LedgerPostingDirection.NEUTRAL; + }; + } + private LedgerProjectionRef accountProjection(Account account, LedgerPosting posting) { return accountProjection(LedgerProjectionRole.ACCOUNT, account, posting); diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerUnitPostingUpdater.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerUnitPostingUpdater.java index b8c39e2cdc..6242a5a4dc 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerUnitPostingUpdater.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerUnitPostingUpdater.java @@ -6,6 +6,8 @@ import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerEntryEditSupport; import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerPostingSemanticRole; +import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; import name.abuchen.portfolio.model.ledger.ProjectionMembershipRole; /** @@ -45,6 +47,7 @@ private void add(LedgerEntry entry, LedgerUnitPostingEdit edit) posting.setType(edit.getPostingType()); edit.getPostingPatch().applyTo(posting); + markUnit(posting); entry.addPosting(posting); addUnitMemberships(entry, posting); } @@ -88,4 +91,25 @@ private ProjectionMembershipRole unitMembershipRole(LedgerPosting posting) default -> throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_071.message("Unsupported unit posting type: " + posting.getType())); //$NON-NLS-1$ }; } + + private void markUnit(LedgerPosting posting) + { + switch (posting.getType()) + { + case FEE -> { + posting.setSemanticRole(LedgerPostingSemanticRole.FEE); + posting.setUnitRole(LedgerPostingUnitRole.FEE); + } + case TAX -> { + posting.setSemanticRole(LedgerPostingSemanticRole.TAX); + posting.setUnitRole(LedgerPostingUnitRole.TAX); + } + case GROSS_VALUE -> { + posting.setSemanticRole(LedgerPostingSemanticRole.GROSS_VALUE); + posting.setUnitRole(LedgerPostingUnitRole.GROSS_VALUE); + } + default -> throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_071 + .message("Unsupported unit posting type: " + posting.getType())); //$NON-NLS-1$ + } + } } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java index 7476dea9bc..ec34277ff7 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java @@ -216,11 +216,11 @@ private static LegMatch matchProjectedLeg(LedgerEntry entry, LedgerEntryDefiniti List issues) { var refs = entry.getProjectionRefs().stream().filter(ref -> ref.getRole() == projectionRole).toList(); - var sameTypePostings = entry.getPostings().stream().filter(posting -> posting.getType() == leg.getPostingType()) - .toList(); + var matchingLegPostings = entry.getPostings().stream() + .filter(posting -> postingMatchesLeg(entry.getType(), posting, leg)).toList(); var matchingPostings = new ArrayList(); - if (refs.isEmpty() && (requiresLeg(leg.getCardinality()) || !sameTypePostings.isEmpty())) + if (refs.isEmpty() && (requiresLeg(leg.getCardinality()) || !matchingLegPostings.isEmpty())) issues.add(issue(IssueCode.REQUIRED_PROJECTION_MISSING, LedgerDiagnosticCode.LEDGER_STRUCT_042 .message("Native leg projection is missing: " + projectionRole), //$NON-NLS-1$ @@ -246,7 +246,8 @@ private static LegMatch matchProjectedLeg(LedgerEntry entry, LedgerEntryDefiniti { if (postingMatchesLeg(entry.getType(), posting, leg)) matchingPostings.add(posting); - else + else if (!postingMatchesAnyLegWithProjectionRole(entry.getType(), posting, definition, + projectionRole)) issues.add(issue(IssueCode.PROJECTION_PRIMARY_POSTING_MISMATCH, LedgerDiagnosticCode.LEDGER_STRUCT_044.message( "Projection primary posting does not match native leg " //$NON-NLS-1$ @@ -287,6 +288,14 @@ else if (!postingsByUUID.containsKey(ref.getPostingGroupUUID())) return new LegMatch(matchingPostings); } + private static boolean postingMatchesAnyLegWithProjectionRole(LedgerEntryType entryType, LedgerPosting posting, + LedgerEntryDefinition definition, LedgerProjectionRole projectionRole) + { + return definition.getLegDefinitions().stream() + .filter(leg -> leg.getProjectionRole().filter(projectionRole::equals).isPresent()) + .anyMatch(leg -> postingMatchesLeg(entryType, posting, leg)); + } + private static void validateAllowedProjectionRole(LedgerEntryDefinition definition, LedgerEntry entry, LedgerLegDefinition leg, LedgerProjectionRole projectionRole, List issues) { diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssembler.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssembler.java index d83da82c86..2f0f45c1a0 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssembler.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssembler.java @@ -18,6 +18,9 @@ import name.abuchen.portfolio.model.ledger.LedgerMutationContext; import name.abuchen.portfolio.model.ledger.LedgerParameter; import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerPostingDirection; +import name.abuchen.portfolio.model.ledger.LedgerPostingSemanticRole; +import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; @@ -239,6 +242,8 @@ private void addSecurityLeg(LedgerEntry entry, NativeSecurityLeg leg) posting.setSecurity(leg.getSecurity()); posting.setShares(leg.getShares()); applyMoney(posting, leg.getAmount()); + markPrimary(posting, semanticRole(leg.getPostingType()), direction(leg.getProjectionRole()), + corporateActionLeg(leg.getLegCode()), leg.getProjectionRole()); if (leg.getLegCode() != null) addPostingParameter(posting, leg.getPostingType(), @@ -261,6 +266,8 @@ private LedgerPosting addCashCompensation(LedgerEntry entry, NativeCashCompensat posting.setType(LedgerPostingType.CASH_COMPENSATION); posting.setAccount(compensation.getAccount()); applyMoney(posting, compensation.getAmount()); + markPrimary(posting, LedgerPostingSemanticRole.CASH_COMPENSATION, LedgerPostingDirection.NEUTRAL, + CorporateActionLeg.CASH_COMPENSATION, LedgerProjectionRole.CASH_COMPENSATION); if (compensation.getAccount() != null) addPostingParameter(posting, LedgerPostingType.CASH_COMPENSATION, @@ -299,6 +306,8 @@ private void addFee(LedgerEntry entry, NativeFee fee) posting.setType(LedgerPostingType.FEE); posting.setAccount(fee.getAccount()); applyMoney(posting, fee.getAmount()); + markUnit(posting, LedgerPostingSemanticRole.FEE, LedgerPostingUnitRole.FEE, + LedgerProjectionRole.CASH_COMPENSATION.name()); addPostingParameter(posting, LedgerPostingType.FEE, new NativeParameterValue(LedgerParameterType.CORPORATE_ACTION_LEG, CorporateActionLeg.FEE.getCode())); @@ -317,6 +326,8 @@ private void addTax(LedgerEntry entry, NativeTax tax) posting.setType(LedgerPostingType.TAX); posting.setAccount(tax.getAccount()); applyMoney(posting, tax.getAmount()); + markUnit(posting, LedgerPostingSemanticRole.TAX, LedgerPostingUnitRole.TAX, + LedgerProjectionRole.CASH_COMPENSATION.name()); addPostingParameter(posting, LedgerPostingType.TAX, new NativeParameterValue(LedgerParameterType.CORPORATE_ACTION_LEG, CorporateActionLeg.TAX.getCode())); @@ -337,6 +348,75 @@ private void applyMoney(LedgerPosting posting, Money amount) posting.setCurrency(amount.getCurrencyCode()); } + private void markPrimary(LedgerPosting posting, LedgerPostingSemanticRole semanticRole, + LedgerPostingDirection direction, CorporateActionLeg leg, LedgerProjectionRole projectionRole) + { + posting.setSemanticRole(semanticRole); + posting.setDirection(direction); + posting.setCorporateActionLeg(leg); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + + if (projectionRole != null) + { + posting.setGroupKey(projectionRole.name()); + posting.setLocalKey(projectionRole.name()); + } + } + + private void markUnit(LedgerPosting posting, LedgerPostingSemanticRole semanticRole, + LedgerPostingUnitRole unitRole, String groupKey) + { + posting.setSemanticRole(semanticRole); + posting.setUnitRole(unitRole); + posting.setGroupKey(groupKey); + } + + private LedgerPostingSemanticRole semanticRole(LedgerPostingType type) + { + return switch (type) + { + case SECURITY -> LedgerPostingSemanticRole.SECURITY; + case RIGHT -> LedgerPostingSemanticRole.RIGHT; + case BOND -> LedgerPostingSemanticRole.BOND; + case CASH_COMPENSATION -> LedgerPostingSemanticRole.CASH_COMPENSATION; + case FEE -> LedgerPostingSemanticRole.FEE; + case TAX -> LedgerPostingSemanticRole.TAX; + case ACCRUED_INTEREST -> LedgerPostingSemanticRole.ACCRUED_INTEREST; + case PRINCIPAL_REDEMPTION -> LedgerPostingSemanticRole.PRINCIPAL_REDEMPTION; + case CASH -> LedgerPostingSemanticRole.CASH; + case GROSS_VALUE -> LedgerPostingSemanticRole.GROSS_VALUE; + case FOREX -> LedgerPostingSemanticRole.FOREX_CONTEXT; + }; + } + + private LedgerPostingDirection direction(LedgerProjectionRole role) + { + if (role == null) + return LedgerPostingDirection.NEUTRAL; + + return switch (role) + { + case SOURCE_ACCOUNT, SOURCE_PORTFOLIO, OLD_SECURITY_LEG, DELIVERY_OUTBOUND -> + LedgerPostingDirection.OUTBOUND; + case TARGET_ACCOUNT, TARGET_PORTFOLIO, NEW_SECURITY_LEG, DELIVERY, DELIVERY_INBOUND -> + LedgerPostingDirection.INBOUND; + case ACCOUNT, PORTFOLIO, CASH_COMPENSATION -> LedgerPostingDirection.NEUTRAL; + }; + } + + private CorporateActionLeg corporateActionLeg(String code) + { + if (code == null) + return null; + + for (var leg : CorporateActionLeg.values()) + if (leg.getCode().equals(code)) + return leg; + + throw issue(LedgerNativeEntryAssemblyIssue.PARAMETER_CODE_NOT_ALLOWED, + code + " is not a corporate action leg"); //$NON-NLS-1$ + } + private void addPortfolioProjection(LedgerEntry entry, LedgerProjectionRole role, Portfolio portfolio, LedgerPosting posting) { From 3b9b4a5d5332d5f9bb4025403da7014a589f8ba0 Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:18:31 +0200 Subject: [PATCH 16/68] Switch Ledger materialization to derived descriptors Runtime Ledger projection creation now derives descriptor-backed compatibility transactions from entry type and posting semantics. The factory builds runtime adapter refs from descriptors, and restoration diagnostics use descriptor-created projections for expected owner-list state. This switches active materialization away from persisted projection refs while preserving visible account, portfolio, transfer, buy/sell, and Siemens spin-off projections. XML/protobuf schemas, persisted projection refs, projection memberships, Ledger UUID fields, and InvestmentPlan linkage are intentionally left unchanged for later phases. --- .../LedgerProjectionMaterializerTest.java | 244 +++++++++++++++--- .../ledger/LedgerSpinOffScenarioTest.java | 8 +- ...erivedProjectionDescriptorServiceTest.java | 1 + .../LedgerRuntimeProjectionRestorerTest.java | 71 ++++- .../DerivedProjectionDescriptorService.java | 165 ++++++++++-- .../projection/LedgerProjectionFactory.java | 130 +++++++++- .../LedgerRuntimeProjectionRestorer.java | 17 +- 7 files changed, 557 insertions(+), 79 deletions(-) diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerProjectionMaterializerTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerProjectionMaterializerTest.java index 9783c65255..c6285593a0 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerProjectionMaterializerTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerProjectionMaterializerTest.java @@ -9,6 +9,7 @@ import static org.junit.Assert.assertTrue; import java.math.BigDecimal; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; @@ -17,13 +18,15 @@ import name.abuchen.portfolio.model.Account; import name.abuchen.portfolio.model.AccountTransaction; import name.abuchen.portfolio.model.Client; -import name.abuchen.portfolio.model.LedgerDiagnosticCode; 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.model.Transaction.Unit; -import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; +import name.abuchen.portfolio.model.ledger.configuration.CashCompensationKind; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionKind; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionSubtype; +import name.abuchen.portfolio.model.ledger.configuration.EventStage; import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountCashLeg; @@ -39,6 +42,12 @@ import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferSecurity; import name.abuchen.portfolio.model.ledger.compatibility.LedgerSecurityQuantity; import name.abuchen.portfolio.model.ledger.compatibility.LedgerTransactionCreator; +import name.abuchen.portfolio.model.ledger.nativeentry.LedgerNativeEntryAssembler; +import name.abuchen.portfolio.model.ledger.nativeentry.NativeCashCompensation; +import name.abuchen.portfolio.model.ledger.nativeentry.NativeCorporateActionEvent; +import name.abuchen.portfolio.model.ledger.nativeentry.NativeEntryMetadata; +import name.abuchen.portfolio.model.ledger.nativeentry.NativeSecurityLeg; +import name.abuchen.portfolio.model.ledger.nativeentry.Ratio; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedAccountTransaction; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedPortfolioTransaction; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; @@ -80,31 +89,111 @@ public void testServiceCreatesAccountBackedDepositProjection() assertThat(projection.getCurrencyCode(), is(CurrencyUnit.EUR)); } - /** - * Checks the Phase-1 scenario: semantic posting fields do not switch materialization. - * Projection refs remain the active compatibility-view source in this phase. - */ @Test - public void testProjectionRefMaterializationRemainsActiveWithSemanticPostingVocabulary() + public void testMaterializationUsesDerivedDescriptorWhenProjectionRefsAreAbsent() { var client = new Client(); var account = account(); var entry = creator(client).createDeposit(metadata(), cashLeg(account, 100)).getEntry(); - var posting = entry.getPostings().get(0); - var projectionRef = entry.getProjectionRefs().get(0); - posting.setSemanticRole(LedgerPostingSemanticRole.SECURITY); - posting.setDirection(LedgerPostingDirection.OUTBOUND); - posting.setCorporateActionLeg(CorporateActionLeg.SOURCE_SECURITY); - posting.setUnitRole(LedgerPostingUnitRole.FEE); - posting.setGroupKey("ignored-phase-1-group"); - posting.setLocalKey("ignored-phase-1-local"); + removeProjectionRefs(entry); + LedgerProjectionService.materialize(client); - var projection = LedgerProjectionService.createProjection(entry, projectionRef); + assertTrue(entry.getProjectionRefs().isEmpty()); + assertThat(account.getTransactions().size(), is(1)); + assertThat(account.getTransactions().get(0).getType(), is(AccountTransaction.Type.DEPOSIT)); + assertThat(account.getTransactions().get(0).getAmount(), is(Values.Amount.factorize(100))); + } - assertThat(projection.getUUID(), is(projectionRef.getUUID())); - assertThat(((AccountTransaction) projection).getType(), is(AccountTransaction.Type.DEPOSIT)); - assertThat(projection.getAmount(), is(Values.Amount.factorize(100))); + @Test + public void testFixedShapeMaterializationUsesDescriptorsWithoutProjectionRefs() + { + assertDescriptorMaterializesBuySell(PortfolioTransaction.Type.BUY, AccountTransaction.Type.BUY); + assertDescriptorMaterializesBuySell(PortfolioTransaction.Type.SELL, AccountTransaction.Type.SELL); + assertDescriptorMaterializesDelivery(true); + assertDescriptorMaterializesDelivery(false); + assertDescriptorMaterializesCashTransfer(); + assertDescriptorMaterializesSecurityTransfer(); + } + + @Test + public void testSiemensSpinOffMaterializesFromDerivedDescriptorsWithoutProjectionRefs() + { + var client = new Client(); + var account = account(); + var portfolio = portfolio(); + var siemens = new Security("Siemens AG", CurrencyUnit.EUR); + var siemensEnergy = new Security("Siemens Energy AG", CurrencyUnit.EUR); + + siemens.setIsin("DE0007236101"); + siemensEnergy.setIsin("DE000ENER6Y0"); + client.addAccount(account); + client.addPortfolio(portfolio); + client.addSecurity(siemens); + client.addSecurity(siemensEnergy); + + var entry = LedgerNativeEntryAssembler.forClient(client).spinOff() // + .metadata(NativeEntryMetadata.of(DATE_TIME).note("Siemens spin-off").source("test")) // + .event(NativeCorporateActionEvent.builder() // + .kind(CorporateActionKind.SPIN_OFF) // + .subtype(CorporateActionSubtype.STANDARD) // + .stage(EventStage.SETTLED) // + .effectiveDate(LocalDate.of(2020, 9, 28)) // + .build()) // + .securityLeg(NativeSecurityLeg.source() // + .portfolio(portfolio) // + .security(siemens) // + .shares(Values.Share.factorize(10)) // + .amount(money(100)) // + .sourceSecurity(siemens) // + .targetSecurity(siemensEnergy) // + .ratio(Ratio.of(BigDecimal.ONE, BigDecimal.valueOf(2))) // + .build()) // + .securityLeg(NativeSecurityLeg.target() // + .portfolio(portfolio) // + .security(siemens) // + .shares(Values.Share.factorize(10)) // + .amount(money(100)) // + .sourceSecurity(siemens) // + .targetSecurity(siemensEnergy) // + .ratio(Ratio.of(BigDecimal.ONE, BigDecimal.valueOf(2))) // + .projectAs(LedgerProjectionRole.DELIVERY_INBOUND) // + .build()) // + .securityLeg(NativeSecurityLeg.target() // + .portfolio(portfolio) // + .security(siemensEnergy) // + .shares(Values.Share.factorize(5)) // + .amount(money(50)) // + .sourceSecurity(siemens) // + .targetSecurity(siemensEnergy) // + .ratio(Ratio.of(BigDecimal.ONE, BigDecimal.valueOf(2))) // + .projectAs(LedgerProjectionRole.NEW_SECURITY_LEG) // + .build()) // + .cashCompensation(NativeCashCompensation.builder() // + .account(account) // + .amount(money(5)) // + .kind(CashCompensationKind.CASH_IN_LIEU) // + .build()) // + .buildDetached().getEntry(); + + removeProjectionRefs(entry); + client.getLedger().addEntry(entry); + + LedgerProjectionService.materialize(client); + + assertTrue(entry.getProjectionRefs().isEmpty()); + assertThat(portfolio.getTransactions().size(), is(3)); + assertThat(account.getTransactions().size(), is(1)); + assertTrue(portfolio.getTransactions().stream() + .anyMatch(transaction -> transaction.getType() == PortfolioTransaction.Type.DELIVERY_OUTBOUND + && transaction.getSecurity() == siemens)); + assertTrue(portfolio.getTransactions().stream() + .anyMatch(transaction -> transaction.getType() == PortfolioTransaction.Type.DELIVERY_INBOUND + && transaction.getSecurity() == siemens)); + assertTrue(portfolio.getTransactions().stream() + .anyMatch(transaction -> transaction.getType() == PortfolioTransaction.Type.DELIVERY_INBOUND + && transaction.getSecurity() == siemensEnergy)); + assertThat(account.getTransactions().get(0).getType(), is(AccountTransaction.Type.DEPOSIT)); } /** @@ -414,13 +503,13 @@ public void testLedgerBackedSettersAreRejected() public void testUnsupportedAccountProjectionMessageHasProjectionCode() { var entry = accountProjectionEntry(LedgerEntryType.DELIVERY_INBOUND); - var transaction = (AccountTransaction) LedgerProjectionService.createProjection(entry, - entry.getProjectionRefs().get(0)); - var exception = assertThrows(UnsupportedOperationException.class, transaction::getType); + var exception = assertThrows(IllegalArgumentException.class, + () -> LedgerProjectionService.createProjection(entry, entry.getProjectionRefs().get(0))); - assertThat(exception.getMessage(), is(LedgerDiagnosticCode.LEDGER_PROJ_066 - .message("Unsupported account projection for DELIVERY_INBOUND"))); + assertThat(exception.getMessage(), is( + "[MISSING_SEMANTIC_PRIMARY] entry=" + entry.getUUID() //$NON-NLS-1$ + + " role=DELIVERY_INBOUND Missing semantic primary posting")); //$NON-NLS-1$ } /** @@ -432,13 +521,12 @@ public void testUnsupportedAccountProjectionMessageHasProjectionCode() public void testUnsupportedPortfolioProjectionMessageHasProjectionCode() { var entry = portfolioProjectionEntry(LedgerEntryType.DEPOSIT); - var transaction = (PortfolioTransaction) LedgerProjectionService.createProjection(entry, - entry.getProjectionRefs().get(0)); - var exception = assertThrows(UnsupportedOperationException.class, transaction::getType); + var exception = assertThrows(IllegalArgumentException.class, + () -> LedgerProjectionService.createProjection(entry, entry.getProjectionRefs().get(0))); - assertThat(exception.getMessage(), is(LedgerDiagnosticCode.LEDGER_PROJ_069 - .message("Unsupported portfolio projection for DEPOSIT"))); + assertThat(exception.getMessage(), is("[MISSING_SEMANTIC_PRIMARY] entry=" + entry.getUUID() //$NON-NLS-1$ + + " role=ACCOUNT Missing semantic primary posting")); //$NON-NLS-1$ } private LedgerTransactionCreator creator(Client client) @@ -527,4 +615,102 @@ private LedgerEntry portfolioProjectionEntry(LedgerEntryType type) return entry; } + + private void assertDescriptorMaterializesBuySell(PortfolioTransaction.Type portfolioType, + AccountTransaction.Type accountType) + { + var client = new Client(); + var account = account(); + var portfolio = portfolio(); + var entry = portfolioType == PortfolioTransaction.Type.BUY + ? creator(client).createBuy(metadata(), cashLeg(account, 100), portfolioLeg(portfolio, 100), + LedgerCreationUnits.of(LedgerCreationUnit.fee(money(1)))).getEntry() + : creator(client).createSell(metadata(), cashLeg(account, 100), portfolioLeg(portfolio, 100), + LedgerCreationUnits.of(LedgerCreationUnit.tax(money(2)))).getEntry(); + + removeProjectionRefs(entry); + LedgerProjectionService.materialize(client); + + assertTrue(entry.getProjectionRefs().isEmpty()); + assertThat(account.getTransactions().get(0).getType(), is(accountType)); + assertThat(portfolio.getTransactions().get(0).getType(), is(portfolioType)); + assertSame(account.getTransactions().get(0).getCrossEntry(), portfolio.getTransactions().get(0).getCrossEntry()); + } + + private void assertDescriptorMaterializesDelivery(boolean inbound) + { + var client = new Client(); + var portfolio = portfolio(); + var security = security(); + var entry = inbound + ? creator(client).createInboundDelivery(metadata(), LedgerDeliveryLeg.of(portfolio, + LedgerSecurityQuantity.of(security, Values.Share.factorize(5)), money(100))) + .getEntry() + : creator(client).createOutboundDelivery(metadata(), LedgerDeliveryLeg.of(portfolio, + LedgerSecurityQuantity.of(security, Values.Share.factorize(5)), money(100))) + .getEntry(); + + removeProjectionRefs(entry); + LedgerProjectionService.materialize(client); + + assertTrue(entry.getProjectionRefs().isEmpty()); + assertThat(portfolio.getTransactions().get(0).getType(), is(inbound + ? PortfolioTransaction.Type.DELIVERY_INBOUND + : PortfolioTransaction.Type.DELIVERY_OUTBOUND)); + } + + private void assertDescriptorMaterializesCashTransfer() + { + var client = new Client(); + var source = account(); + var target = account(); + var entry = creator(client).createAccountTransfer(metadata(), LedgerCashTransferLeg.of(source, money(100)), + LedgerCashTransferLeg.of(target, money(100))).getEntry(); + + removeProjectionRefs(entry); + moveFirstPostingToEnd(entry); + LedgerProjectionService.materialize(client); + + assertTrue(entry.getProjectionRefs().isEmpty()); + assertThat(source.getTransactions().get(0).getType(), is(AccountTransaction.Type.TRANSFER_OUT)); + assertThat(target.getTransactions().get(0).getType(), is(AccountTransaction.Type.TRANSFER_IN)); + assertSame(target.getTransactions().get(0), + source.getTransactions().get(0).getCrossEntry() + .getCrossTransaction(source.getTransactions().get(0))); + } + + private void assertDescriptorMaterializesSecurityTransfer() + { + var client = new Client(); + var source = portfolio(); + var target = portfolio(); + var entry = creator(client).createPortfolioTransfer(metadata(), + LedgerPortfolioTransferSecurity.of(security(), Values.Share.factorize(5)), + LedgerPortfolioTransferLeg.of(source, money(100)), + LedgerPortfolioTransferLeg.of(target, money(100))).getEntry(); + + removeProjectionRefs(entry); + moveFirstPostingToEnd(entry); + LedgerProjectionService.materialize(client); + + assertTrue(entry.getProjectionRefs().isEmpty()); + assertThat(source.getTransactions().get(0).getType(), is(PortfolioTransaction.Type.TRANSFER_OUT)); + assertThat(target.getTransactions().get(0).getType(), is(PortfolioTransaction.Type.TRANSFER_IN)); + assertSame(target.getTransactions().get(0), + source.getTransactions().get(0).getCrossEntry() + .getCrossTransaction(source.getTransactions().get(0))); + } + + private void removeProjectionRefs(LedgerEntry entry) + { + List.copyOf(entry.getProjectionRefs()).forEach(entry::removeProjectionRef); + } + + private void moveFirstPostingToEnd(LedgerEntry entry) + { + var posting = entry.getPostings().get(0); + + entry.removePosting(posting); + entry.addPosting(posting); + } } diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffScenarioTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffScenarioTest.java index 4482737d2f..268d835941 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffScenarioTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffScenarioTest.java @@ -276,11 +276,11 @@ public void testShareAdjustmentHelperRejectsTargetedProjectionWithoutPrimaryPost entry.addProjectionRef(projection); client.getLedger().addEntry(entry); - var transaction = LedgerProjectionService.createProjection(entry, projection); + var exception = assertThrows(IllegalArgumentException.class, + () -> LedgerProjectionService.createProjection(entry, projection)); - assertThrows(IllegalArgumentException.class, - () -> LedgerShareAdjustmentHelper.plan(client, security, List.of(transaction), - shares -> shares * 2)); + assertTrue(exception.getMessage().contains("role=OLD_SECURITY_LEG Missing semantic primary posting")); + assertTrue(exception.getMessage().contains("role=NEW_SECURITY_LEG Missing semantic primary posting")); assertThat(posting.getShares(), is(Values.Share.factorize(10))); assertThat(client.getLedger().getEntries().size(), is(1)); assertThat(portfolio.getTransactions().size(), is(0)); diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorServiceTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorServiceTest.java index 338b61963e..9a030e7b1f 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorServiceTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorServiceTest.java @@ -238,6 +238,7 @@ public void testMissingSemanticPrimaryIsReported() var posting = primaryCash("posting-1", account); posting.setUnitRole(null); + posting.setAccount(null); entry.setType(LedgerEntryType.DEPOSIT); entry.setDateTime(DATE_TIME); entry.addPosting(posting); diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerRuntimeProjectionRestorerTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerRuntimeProjectionRestorerTest.java index 8fcb43822d..749c84a44e 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerRuntimeProjectionRestorerTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerRuntimeProjectionRestorerTest.java @@ -26,6 +26,9 @@ import name.abuchen.portfolio.model.Security; import name.abuchen.portfolio.model.Transaction; import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerPostingDirection; +import name.abuchen.portfolio.model.ledger.LedgerPostingSemanticRole; +import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; import name.abuchen.portfolio.model.ledger.LedgerPosting; import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; @@ -40,6 +43,7 @@ import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferSecurity; import name.abuchen.portfolio.model.ledger.compatibility.LedgerSecurityQuantity; import name.abuchen.portfolio.model.ledger.compatibility.LedgerTransactionCreator; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; import name.abuchen.portfolio.money.CurrencyUnit; @@ -85,9 +89,9 @@ public void testMissingOwnerListProjectionIsRestoredWithSameProjectionUUID() } /** - * Checks the projection rebuild scenario: primary projection membership is preferred over scalar fallback. - * Account and portfolio lists must be derived from ledger membership targeting when present. - * This protects Ledger-V6 from loose scalar projection targeting. + * Checks the projection rebuild scenario: semantic primary targeting is preferred over projection membership drift. + * Account and portfolio lists must be derived from descriptor targeting when projection refs drift. + * This protects Ledger-V6 from stale projection targeting. */ @Test public void testPrimaryMembershipMaterializesAccountProjection() @@ -98,6 +102,7 @@ public void testPrimaryMembershipMaterializesAccountProjection() var projectionRef = entry.getProjectionRefs().get(0); var alternatePosting = cashPosting("membership-primary-posting", account, 200); + alternatePosting.setType(LedgerPostingType.FEE); entry.addPosting(alternatePosting); projectionRef.setPrimaryPostingTargetUUID(null); projectionRef.addMembership(alternatePosting.getUUID(), ProjectionMembershipRole.PRIMARY); @@ -106,7 +111,7 @@ public void testPrimaryMembershipMaterializesAccountProjection() assertTrue(result.isOK()); assertThat(account.getTransactions().size(), is(1)); - assertThat(account.getTransactions().get(0).getAmount(), is(Values.Amount.factorize(200))); + assertThat(account.getTransactions().get(0).getAmount(), is(Values.Amount.factorize(100))); } /** @@ -119,7 +124,14 @@ public void testGroupAnchorMembershipMaterializesNativeTargetedUnits() { var client = new Client(); var account = register(client, account()); + var portfolio = register(client, portfolio()); + var sourceSecurity = security(); + var targetSecurity = security(); var entry = new LedgerEntry("entry-1"); + var sourceLeg = securityPosting("old-security", portfolio, sourceSecurity, LedgerProjectionRole.OLD_SECURITY_LEG, + CorporateActionLeg.SOURCE_SECURITY); + var targetLeg = securityPosting("new-security", portfolio, targetSecurity, LedgerProjectionRole.NEW_SECURITY_LEG, + CorporateActionLeg.TARGET_SECURITY); var compensation = cashPosting("cash-compensation", account, 5); var fee = unitPosting("fee-posting", LedgerPostingType.FEE, account, 2); var tax = unitPosting("tax-posting", LedgerPostingType.TAX, account, 1); @@ -127,10 +139,26 @@ public void testGroupAnchorMembershipMaterializesNativeTargetedUnits() entry.setType(LedgerEntryType.SPIN_OFF); entry.setDateTime(DATE_TIME); + entry.addPosting(sourceLeg); + entry.addPosting(targetLeg); compensation.setType(LedgerPostingType.CASH_COMPENSATION); + compensation.setSemanticRole(LedgerPostingSemanticRole.CASH_COMPENSATION); + compensation.setDirection(LedgerPostingDirection.NEUTRAL); + compensation.setCorporateActionLeg(CorporateActionLeg.CASH_COMPENSATION); + compensation.setUnitRole(LedgerPostingUnitRole.PRIMARY); + compensation.setGroupKey(LedgerProjectionRole.CASH_COMPENSATION.name()); + compensation.setLocalKey(LedgerProjectionRole.CASH_COMPENSATION.name()); + fee.setSemanticRole(LedgerPostingSemanticRole.FEE); + fee.setUnitRole(LedgerPostingUnitRole.FEE); + fee.setGroupKey(LedgerProjectionRole.CASH_COMPENSATION.name()); + tax.setSemanticRole(LedgerPostingSemanticRole.TAX); + tax.setUnitRole(LedgerPostingUnitRole.TAX); + tax.setGroupKey(LedgerProjectionRole.CASH_COMPENSATION.name()); entry.addPosting(compensation); entry.addPosting(fee); entry.addPosting(tax); + entry.addProjectionRef(portfolioProjection(LedgerProjectionRole.OLD_SECURITY_LEG, portfolio, sourceLeg)); + entry.addProjectionRef(portfolioProjection(LedgerProjectionRole.NEW_SECURITY_LEG, portfolio, targetLeg)); projectionRef.setRole(LedgerProjectionRole.CASH_COMPENSATION); projectionRef.setAccount(account); projectionRef.addMembership(compensation.getUUID(), ProjectionMembershipRole.PRIMARY); @@ -624,6 +652,41 @@ private LedgerPosting unitPosting(String uuid, LedgerPostingType type, Account a return posting; } + private LedgerPosting securityPosting(String uuid, Portfolio portfolio, Security security, LedgerProjectionRole role, + CorporateActionLeg leg) + { + var posting = new LedgerPosting(uuid); + + posting.setType(LedgerPostingType.SECURITY); + posting.setPortfolio(portfolio); + posting.setSecurity(security); + posting.setShares(Values.Share.factorize(5)); + posting.setAmount(Values.Amount.factorize(100)); + posting.setCurrency(CurrencyUnit.EUR); + posting.setSemanticRole(LedgerPostingSemanticRole.SECURITY); + posting.setDirection(role == LedgerProjectionRole.OLD_SECURITY_LEG + ? LedgerPostingDirection.OUTBOUND + : LedgerPostingDirection.INBOUND); + posting.setCorporateActionLeg(leg); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + posting.setGroupKey(role.name()); + posting.setLocalKey(role.name()); + + return posting; + } + + private LedgerProjectionRef portfolioProjection(LedgerProjectionRole role, Portfolio portfolio, + LedgerPosting posting) + { + var projection = new LedgerProjectionRef(); + + projection.setRole(role); + projection.setPortfolio(portfolio); + projection.setPrimaryPosting(posting); + + return projection; + } + private AccountTransaction legacyDeposit() { var transaction = new AccountTransaction(); diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java index a877be4c27..ed14be2dbb 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java @@ -9,6 +9,8 @@ import name.abuchen.portfolio.model.Account; import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.ledger.LedgerParameter; import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerPosting; import name.abuchen.portfolio.model.ledger.LedgerPostingDirection; @@ -17,11 +19,13 @@ import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; /** * Derives runtime projection descriptors from Ledger entry type and posting - * semantics. The descriptors are comparison metadata only; current projection - * refs remain the active materialization source. + * semantics. The descriptors are runtime-only compatibility view metadata; + * they are not persisted Ledger facts. */ public final class DerivedProjectionDescriptorService { @@ -41,22 +45,28 @@ public Result derive(LedgerEntry entry) switch (entry.getType()) { case DEPOSIT, REMOVAL, INTEREST, INTEREST_CHARGE, FEES, FEES_REFUND, TAXES, TAX_REFUND, DIVIDENDS -> - account(entry, LedgerProjectionRole.ACCOUNT, primary(), diagnostics).ifPresent(descriptors::add); + account(entry, LedgerProjectionRole.ACCOUNT, + primary().or(legacyAccountPrimary(entry.getType())), diagnostics) + .ifPresent(descriptors::add); case BUY, SELL -> { account(entry, LedgerProjectionRole.ACCOUNT, - primary().and(semantic(LedgerPostingSemanticRole.CASH)), diagnostics) + primary().and(semantic(LedgerPostingSemanticRole.CASH)) + .or(legacyPrimary(LedgerPostingType.CASH)), diagnostics) .ifPresent(descriptors::add); portfolio(entry, LedgerProjectionRole.PORTFOLIO, - primary().and(semantic(LedgerPostingSemanticRole.SECURITY)), diagnostics) + primary().and(semantic(LedgerPostingSemanticRole.SECURITY)) + .or(legacyPrimary(LedgerPostingType.SECURITY)), diagnostics) .ifPresent(descriptors::add); } case DELIVERY_INBOUND -> portfolio(entry, LedgerProjectionRole.DELIVERY_INBOUND, primary().and(semantic(LedgerPostingSemanticRole.SECURITY)) - .and(direction(LedgerPostingDirection.INBOUND)), + .and(direction(LedgerPostingDirection.INBOUND)) + .or(legacyPrimary(LedgerPostingType.SECURITY)), diagnostics).ifPresent(descriptors::add); case DELIVERY_OUTBOUND -> portfolio(entry, LedgerProjectionRole.DELIVERY_OUTBOUND, primary().and(semantic(LedgerPostingSemanticRole.SECURITY)) - .and(direction(LedgerPostingDirection.OUTBOUND)), + .and(direction(LedgerPostingDirection.OUTBOUND)) + .or(legacyPrimary(LedgerPostingType.SECURITY)), diagnostics).ifPresent(descriptors::add); case CASH_TRANSFER -> { account(entry, LedgerProjectionRole.SOURCE_ACCOUNT, @@ -76,26 +86,40 @@ public Result derive(LedgerEntry entry) } case SPIN_OFF -> { portfolio(entry, LedgerProjectionRole.OLD_SECURITY_LEG, - primary().and(corporateLeg(CorporateActionLeg.SOURCE_SECURITY)), diagnostics) + primary().and(corporateLeg(CorporateActionLeg.SOURCE_SECURITY)) + .or(legacyCorporateLeg(LedgerPostingType.SECURITY, + CorporateActionLeg.SOURCE_SECURITY)), + diagnostics) .ifPresent(descriptors::add); - portfolio(entry, LedgerProjectionRole.DELIVERY_INBOUND, + optionalPortfolio(entry, LedgerProjectionRole.DELIVERY_INBOUND, primary().and(corporateLeg(CorporateActionLeg.TARGET_SECURITY)) - .and(localKey(LedgerProjectionRole.DELIVERY_INBOUND)), + .and(localKey(LedgerProjectionRole.DELIVERY_INBOUND)) + .or(legacyRetainedSpinOffTarget()), diagnostics).ifPresent(descriptors::add); portfolio(entry, LedgerProjectionRole.NEW_SECURITY_LEG, primary().and(corporateLeg(CorporateActionLeg.TARGET_SECURITY)) - .and(localKey(LedgerProjectionRole.NEW_SECURITY_LEG)), + .and(localKey(LedgerProjectionRole.NEW_SECURITY_LEG)) + .or(legacyNewSpinOffTarget()), diagnostics).ifPresent(descriptors::add); optionalAccount(entry, LedgerProjectionRole.CASH_COMPENSATION, - primary().and(corporateLeg(CorporateActionLeg.CASH_COMPENSATION)), diagnostics) + primary().and(corporateLeg(CorporateActionLeg.CASH_COMPENSATION)) + .or(legacyCorporateLeg(LedgerPostingType.CASH_COMPENSATION, + CorporateActionLeg.CASH_COMPENSATION)), + diagnostics) .ifPresent(descriptors::add); } case STOCK_DIVIDEND, BONUS_ISSUE -> { portfolio(entry, LedgerProjectionRole.DELIVERY_INBOUND, - primary().and(corporateLeg(CorporateActionLeg.TARGET_SECURITY)), diagnostics) + primary().and(corporateLeg(CorporateActionLeg.TARGET_SECURITY)) + .or(legacyCorporateLeg(LedgerPostingType.SECURITY, + CorporateActionLeg.TARGET_SECURITY)), + diagnostics) .ifPresent(descriptors::add); optionalAccount(entry, LedgerProjectionRole.CASH_COMPENSATION, - primary().and(corporateLeg(CorporateActionLeg.CASH_COMPENSATION)), diagnostics) + primary().and(corporateLeg(CorporateActionLeg.CASH_COMPENSATION)) + .or(legacyCorporateLeg(LedgerPostingType.CASH_COMPENSATION, + CorporateActionLeg.CASH_COMPENSATION)), + diagnostics) .ifPresent(descriptors::add); } case RIGHTS_DISTRIBUTION -> { @@ -104,21 +128,36 @@ public Result derive(LedgerEntry entry) || posting.getCorporateActionLeg() == CorporateActionLeg.DISTRIBUTED_SECURITY), diagnostics).ifPresent(descriptors::add); optionalPortfolio(entry, LedgerProjectionRole.OLD_SECURITY_LEG, - primary().and(corporateLeg(CorporateActionLeg.SOURCE_SECURITY)), diagnostics) + primary().and(corporateLeg(CorporateActionLeg.SOURCE_SECURITY)) + .or(legacyCorporateLeg(LedgerPostingType.SECURITY, + CorporateActionLeg.SOURCE_SECURITY)), + diagnostics) .ifPresent(descriptors::add); optionalAccount(entry, LedgerProjectionRole.CASH_COMPENSATION, - primary().and(corporateLeg(CorporateActionLeg.CASH_COMPENSATION)), diagnostics) + primary().and(corporateLeg(CorporateActionLeg.CASH_COMPENSATION)) + .or(legacyCorporateLeg(LedgerPostingType.CASH_COMPENSATION, + CorporateActionLeg.CASH_COMPENSATION)), + diagnostics) .ifPresent(descriptors::add); } case BOND_CONVERSION -> { portfolio(entry, LedgerProjectionRole.OLD_SECURITY_LEG, - primary().and(corporateLeg(CorporateActionLeg.CONVERSION_SOURCE)), diagnostics) + primary().and(corporateLeg(CorporateActionLeg.CONVERSION_SOURCE)) + .or(legacyCorporateLeg(LedgerPostingType.BOND, + CorporateActionLeg.CONVERSION_SOURCE)), + diagnostics) .ifPresent(descriptors::add); portfolio(entry, LedgerProjectionRole.NEW_SECURITY_LEG, - primary().and(corporateLeg(CorporateActionLeg.CONVERSION_TARGET)), diagnostics) + primary().and(corporateLeg(CorporateActionLeg.CONVERSION_TARGET)) + .or(legacyCorporateLeg(LedgerPostingType.BOND, + CorporateActionLeg.CONVERSION_TARGET)), + diagnostics) .ifPresent(descriptors::add); optionalAccount(entry, LedgerProjectionRole.CASH_COMPENSATION, - primary().and(corporateLeg(CorporateActionLeg.CASH_COMPENSATION)), diagnostics) + primary().and(corporateLeg(CorporateActionLeg.CASH_COMPENSATION)) + .or(legacyCorporateLeg(LedgerPostingType.CASH_COMPENSATION, + CorporateActionLeg.CASH_COMPENSATION)), + diagnostics) .ifPresent(descriptors::add); } default -> diagnostics.add(Diagnostic.missing(entry, null, @@ -195,8 +234,7 @@ private List unitPostings(LedgerEntry entry, LedgerPosting primar { return entry.getPostings().stream() // .filter(posting -> posting != primary) // - .filter(posting -> posting.getUnitRole() != null) // - .filter(posting -> posting.getUnitRole() != LedgerPostingUnitRole.PRIMARY) // + .filter(this::unitPosting) // .filter(posting -> primary.getGroupKey() == null || Objects.equals(primary.getGroupKey(), posting.getGroupKey())) .toList(); @@ -238,6 +276,91 @@ private Predicate localKey(LedgerProjectionRole role) return posting -> role.name().equals(posting.getLocalKey()); } + private Predicate legacyAccountPrimary(LedgerEntryType entryType) + { + return legacyPrimary(switch (entryType) + { + case FEES, FEES_REFUND -> LedgerPostingType.FEE; + case TAXES, TAX_REFUND -> LedgerPostingType.TAX; + default -> LedgerPostingType.CASH; + }); + } + + private Predicate legacyPrimary(LedgerPostingType type) + { + return posting -> posting.getUnitRole() == null && posting.getType() == type + && (posting.getAccount() != null || posting.getPortfolio() != null); + } + + private Predicate legacyCorporateLeg(LedgerPostingType type, CorporateActionLeg leg) + { + return posting -> legacyPrimary(type).test(posting) && corporateActionLeg(posting) == leg; + } + + private Predicate legacyRetainedSpinOffTarget() + { + return legacyCorporateLeg(LedgerPostingType.SECURITY, CorporateActionLeg.TARGET_SECURITY) + .and(posting -> posting.getSecurity() != null + && posting.getSecurity() == parameterSecurity(posting, + LedgerParameterType.SOURCE_SECURITY)); + } + + private Predicate legacyNewSpinOffTarget() + { + return legacyCorporateLeg(LedgerPostingType.SECURITY, CorporateActionLeg.TARGET_SECURITY) + .and(posting -> posting.getSecurity() != null + && posting.getSecurity() != parameterSecurity(posting, + LedgerParameterType.SOURCE_SECURITY)); + } + + private boolean unitPosting(LedgerPosting posting) + { + if (posting.getUnitRole() != null) + return posting.getUnitRole() != LedgerPostingUnitRole.PRIMARY; + + return switch (posting.getType()) + { + case FEE, TAX, GROSS_VALUE, FOREX -> true; + default -> false; + }; + } + + private CorporateActionLeg corporateActionLeg(LedgerPosting posting) + { + if (posting.getCorporateActionLeg() != null) + return posting.getCorporateActionLeg(); + + var code = parameterString(posting, LedgerParameterType.CORPORATE_ACTION_LEG); + if (code == null) + return null; + + for (var leg : CorporateActionLeg.values()) + if (leg.getCode().equals(code)) + return leg; + + return null; + } + + private String parameterString(LedgerPosting posting, LedgerParameterType type) + { + return posting.getParameters().stream() // + .filter(parameter -> parameter.getType() == type) // + .filter(parameter -> parameter.getValueKind() == LedgerParameter.ValueKind.STRING) // + .map(LedgerParameter::getValue) // + .map(String.class::cast) // + .findFirst().orElse(null); + } + + private Security parameterSecurity(LedgerPosting posting, LedgerParameterType type) + { + return posting.getParameters().stream() // + .filter(parameter -> parameter.getType() == type) // + .filter(parameter -> parameter.getValueKind() == LedgerParameter.ValueKind.SECURITY) // + .map(LedgerParameter::getValue) // + .map(Security.class::cast) // + .findFirst().orElse(null); + } + public static final class Result { private final List descriptors; diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionFactory.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionFactory.java index 23506a98d6..ee3aff3058 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionFactory.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionFactory.java @@ -12,8 +12,11 @@ import name.abuchen.portfolio.model.PortfolioTransferEntry; import name.abuchen.portfolio.model.Transaction; import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.ProjectionMembershipRole; import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; /** @@ -23,14 +26,30 @@ */ final class LedgerProjectionFactory { + private final DerivedProjectionDescriptorService descriptorService; + + LedgerProjectionFactory() + { + this(new DerivedProjectionDescriptorService()); + } + + LedgerProjectionFactory(DerivedProjectionDescriptorService descriptorService) + { + this.descriptorService = Objects.requireNonNull(descriptorService); + } + Transaction createProjection(LedgerEntry entry, LedgerProjectionRef projectionRef) { + Objects.requireNonNull(entry); Objects.requireNonNull(projectionRef); - return createProjections(entry).stream() // - .filter(transaction -> transaction.getUUID().equals(projectionRef.getUUID())) // + return createDescriptors(entry).stream() // + .filter(descriptor -> descriptor.getRole() == projectionRef.getRole()) // + .map(descriptor -> create(entry, descriptor, + compatibilityProjectionRef(entry, descriptor, projectionRef.getUUID()))) // .findFirst().orElseThrow(() -> new IllegalArgumentException( - "Projection ref does not belong to entry: " + projectionRef.getUUID())); //$NON-NLS-1$ + "Projection descriptor does not belong to entry role: " //$NON-NLS-1$ + + projectionRef.getRole())); } List createProjections(LedgerEntry entry) @@ -39,26 +58,111 @@ List createProjections(LedgerEntry entry) var transactions = new ArrayList(); - for (var projectionRef : entry.getProjectionRefs()) - transactions.add(create(entry, projectionRef)); + for (var descriptor : createDescriptors(entry)) + transactions.add(create(entry, descriptor, compatibilityProjectionRef(entry, descriptor))); attachCrossEntry(entry, transactions); return List.copyOf(transactions); } - private Transaction create(LedgerEntry entry, LedgerProjectionRef projectionRef) + private List createDescriptors(LedgerEntry entry) { - if (LedgerProjectionSupport.isAccountProjection(projectionRef)) + var result = descriptorService.derive(entry); + + if (!result.isOK()) + throw new IllegalArgumentException(result.formatDiagnostics()); + + return result.getDescriptors(); + } + + private Transaction create(LedgerEntry entry, DerivedProjectionDescriptor descriptor, + LedgerProjectionRef projectionRef) + { + if (descriptor.getViewKind() == DerivedProjectionViewKind.ACCOUNT) return new LedgerBackedAccountTransaction(entry, projectionRef); - if (LedgerProjectionSupport.isPortfolioProjection(projectionRef)) + if (descriptor.getViewKind() == DerivedProjectionViewKind.PORTFOLIO) return new LedgerBackedPortfolioTransaction(entry, projectionRef); throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PROJ_071 .message("Unsupported ledger projection role " + projectionRef.getRole())); //$NON-NLS-1$ } + private LedgerProjectionRef compatibilityProjectionRef(LedgerEntry entry, DerivedProjectionDescriptor descriptor) + { + var uuid = persistedProjectionRef(entry, descriptor.getRole()) // + .map(LedgerProjectionRef::getUUID) // + .orElse(entry.getUUID() + ":" + descriptor.getRole()); //$NON-NLS-1$ + + return compatibilityProjectionRef(entry, descriptor, uuid); + } + + private LedgerProjectionRef compatibilityProjectionRef(LedgerEntry entry, DerivedProjectionDescriptor descriptor, + String uuid) + { + var projectionRef = new LedgerProjectionRef(uuid); + + projectionRef.setRole(descriptor.getRole()); + + if (descriptor.getAccount() != null) + projectionRef.setAccount(descriptor.getAccount()); + + if (descriptor.getPortfolio() != null) + projectionRef.setPortfolio(descriptor.getPortfolio()); + + projectionRef.setPrimaryPosting(descriptor.getPrimaryPosting()); + projectionRef.setPostingGroup(descriptor.getPrimaryPosting()); + addUnitMemberships(projectionRef, descriptor.getUnitPostings()); + + return projectionRef; + } + + private java.util.Optional persistedProjectionRef(LedgerEntry entry, LedgerProjectionRole role) + { + return entry.getProjectionRefs().stream().filter(ref -> ref.getRole() == role).findFirst(); + } + + private void addUnitMemberships(LedgerProjectionRef projectionRef, List unitPostings) + { + for (var posting : unitPostings) + { + var role = membershipRole(posting); + + if (role == null) + continue; + + projectionRef.addMembership(posting.getUUID(), role); + } + } + + private ProjectionMembershipRole membershipRole(LedgerPosting posting) + { + if (posting.getUnitRole() != null) + return membershipRole(posting.getUnitRole()); + + return switch (posting.getType()) + { + case FEE -> ProjectionMembershipRole.FEE_UNIT; + case TAX -> ProjectionMembershipRole.TAX_UNIT; + case GROSS_VALUE -> ProjectionMembershipRole.GROSS_VALUE_UNIT; + case FOREX -> ProjectionMembershipRole.FOREX_CONTEXT; + default -> null; + }; + } + + private ProjectionMembershipRole membershipRole(LedgerPostingUnitRole role) + { + return switch (role) + { + case FEE -> ProjectionMembershipRole.FEE_UNIT; + case TAX -> ProjectionMembershipRole.TAX_UNIT; + case GROSS_VALUE -> ProjectionMembershipRole.GROSS_VALUE_UNIT; + case FOREX_CONTEXT -> ProjectionMembershipRole.FOREX_CONTEXT; + case PRIMARY -> ProjectionMembershipRole.PRIMARY; + }; + } + private void attachCrossEntry(LedgerEntry entry, List transactions) { if (transactions.size() != 2) @@ -139,13 +243,11 @@ private void attachBuySellCrossEntry(LedgerEntry entry, List transa private Transaction transaction(LedgerEntry entry, List transactions, LedgerProjectionRole role) { - var projection = entry.getProjectionRefs().stream() // - .filter(ref -> ref.getRole() == role) // - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("Projection not found: " + role)); //$NON-NLS-1$ - return transactions.stream() // - .filter(transaction -> transaction.getUUID().equals(projection.getUUID())) // + .filter(LedgerBackedTransaction.class::isInstance) // + .map(LedgerBackedTransaction.class::cast) // + .filter(transaction -> transaction.getLedgerProjectionRef().getRole() == role) // + .map(Transaction.class::cast) // .findFirst().orElseThrow(); } } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerRuntimeProjectionRestorer.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerRuntimeProjectionRestorer.java index f5aec0193c..e74ba2fe99 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerRuntimeProjectionRestorer.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerRuntimeProjectionRestorer.java @@ -93,6 +93,7 @@ private boolean hasUnexpectedOwnerListState() } private final LedgerProjectionMaterializer materializer; + private final LedgerProjectionFactory factory; private final Logger logger; LedgerRuntimeProjectionRestorer() @@ -108,6 +109,7 @@ private boolean hasUnexpectedOwnerListState() LedgerRuntimeProjectionRestorer(LedgerProjectionMaterializer materializer, Logger logger) { this.materializer = Objects.requireNonNull(materializer); + this.factory = new LedgerProjectionFactory(); this.logger = Objects.requireNonNull(logger); } @@ -307,14 +309,15 @@ private Map expectedProjectionCounts(Client client) for (var entry : client.getLedger().getEntries()) { - for (var projection : entry.getProjectionRefs()) + for (var projection : factory.createProjections(entry)) { - if (projection.getAccount() != null) - addProjection(projections, - new ProjectionKey("account", projection.getAccount(), projection.getUUID())); //$NON-NLS-1$ - else if (projection.getPortfolio() != null) - addProjection(projections, - new ProjectionKey("portfolio", projection.getPortfolio(), projection.getUUID())); //$NON-NLS-1$ + if (projection instanceof LedgerBackedAccountTransaction accountTransaction) + addProjection(projections, new ProjectionKey("account", //$NON-NLS-1$ + accountTransaction.getLedgerProjectionRef().getAccount(), projection.getUUID())); + else if (projection instanceof LedgerBackedPortfolioTransaction portfolioTransaction) + addProjection(projections, new ProjectionKey("portfolio", //$NON-NLS-1$ + portfolioTransaction.getLedgerProjectionRef().getPortfolio(), + projection.getUUID())); } } From bd9d2be5ba62e6f4fe7b85d41f78236712c82267 Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:49:45 +0200 Subject: [PATCH 17/68] Link InvestmentPlan executions by plan key Add plan keys to InvestmentPlan and generated execution metadata to LedgerEntry, and resolve generated plan rows through ledger entry metadata instead of LedgerExecutionRef identity. This lets ledger-backed plan executions survive descriptor-based materialization without copying transaction, projection, or ledger entry UUIDs into the plan relation. Legacy ledger execution refs remain readable only as migration input; projection refs, memberships, ledger entry/posting UUID fields, corporate actions, and descriptor materialization are otherwise left unchanged. --- .../portfolio/model/InvestmentPlanTest.java | 114 ++-- .../model/LedgerProtobufPersistenceTest.java | 51 +- .../LedgerInvestmentPlanRefSupportTest.java | 145 +---- .../model/proto/v1/ClientProtos.java | 423 +++++++------- .../model/proto/v1/PInvestmentPlan.java | 157 +++++ .../proto/v1/PInvestmentPlanOrBuilder.java | 17 + .../model/proto/v1/PLedgerEntry.java | 544 +++++++++++++++++- .../model/proto/v1/PLedgerEntryOrBuilder.java | 68 ++- .../model/proto/v1/PLedgerParameter.java | 41 +- .../proto/v1/PLedgerParameterValueKind.java | 1 + .../model/proto/v1/PLedgerPosting.java | 55 +- .../proto/v1/PLedgerPostingOrBuilder.java | 4 +- .../proto/v1/PLedgerProjectionMembership.java | 7 +- .../v1/PLedgerProjectionMembershipRole.java | 1 + .../model/proto/v1/PLedgerProjectionRef.java | 29 +- .../v1/PLedgerProjectionRefOrBuilder.java | 4 +- .../portfolio/model/ClientFactory.java | 78 +-- .../portfolio/model/InvestmentPlan.java | 176 +++++- .../model/LedgerModelLoadSupport.java | 21 + .../model/LedgerPlanReferenceSupport.java | 48 +- .../portfolio/model/ProtobufWriter.java | 97 ++-- .../name/abuchen/portfolio/model/client.proto | 5 + .../portfolio/model/ledger/LedgerEntry.java | 49 ++ .../LedgerInvestmentPlanRefSupport.java | 167 +----- 24 files changed, 1483 insertions(+), 819 deletions(-) diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/InvestmentPlanTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/InvestmentPlanTest.java index b05db17b10..4a52bede42 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/InvestmentPlanTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/InvestmentPlanTest.java @@ -5,6 +5,7 @@ import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.instanceOf; import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; @@ -27,7 +28,6 @@ import name.abuchen.portfolio.junit.SecurityBuilder; import name.abuchen.portfolio.junit.TestCurrencyConverter; import name.abuchen.portfolio.model.Transaction.Unit; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; import name.abuchen.portfolio.money.CurrencyUnit; @@ -435,12 +435,12 @@ public void testWeeklyFrom18March2024() throws IOException } /** - * Checks the generated booking scenario: ledger generation of buy stores portfolio execution ref. + * Checks the generated booking scenario: ledger generation of buy stores portfolio execution metadata. * The plan reference must resolve to the intended transaction after the operation. * This prevents stale or ambiguous generated booking references. */ @Test - public void testLedgerGenerationOfBuyStoresPortfolioExecutionRef() throws IOException + public void testLedgerGenerationOfBuyStoresPortfolioExecutionMetadata() throws IOException { investmentPlan.setName("Buy Plan"); investmentPlan.setType(InvestmentPlan.Type.PURCHASE_OR_DELIVERY); @@ -456,7 +456,7 @@ public void testLedgerGenerationOfBuyStoresPortfolioExecutionRef() throws IOExce assertThat(generated, hasSize(1)); assertThat(investmentPlan.getTransactions(), hasSize(0)); - assertThat(investmentPlan.getLedgerExecutionRefs(), hasSize(1)); + assertThat(investmentPlan.getLedgerExecutionRefs(), hasSize(0)); assertThat(client.getLedger().getEntries(), hasSize(1)); assertThat(account.getTransactions(), hasSize(1)); assertThat(portfolio.getTransactions(), hasSize(1)); @@ -470,21 +470,18 @@ public void testLedgerGenerationOfBuyStoresPortfolioExecutionRef() throws IOExce assertThat(transaction.getUnitSum(Unit.Type.TAX), is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(2)))); var ledgerBacked = (LedgerBackedTransaction) transaction; - var ref = investmentPlan.getLedgerExecutionRefs().get(0); - assertThat(ref.getLedgerEntryUUID(), is(ledgerBacked.getLedgerEntry().getUUID())); - assertThat(ref.getProjectionUUID(), is(ledgerBacked.getLedgerProjectionRef().getUUID())); - assertThat(ref.getProjectionRole(), is(LedgerProjectionRole.PORTFOLIO)); + assertPlanExecutionMetadata(ledgerBacked, InvestmentPlan.LedgerExecutionViewKind.PORTFOLIO); assertThat(investmentPlan.getTransactions(client).get(0).getTransaction(), is(transaction)); assertValidLedger(); } /** - * Checks the generated booking scenario: ledger generation of delivery stores portfolio execution ref. + * Checks the generated booking scenario: ledger generation of delivery stores portfolio execution metadata. * The plan reference must resolve to the intended transaction after the operation. * This prevents stale or ambiguous generated booking references. */ @Test - public void testLedgerGenerationOfDeliveryStoresPortfolioExecutionRef() throws IOException + public void testLedgerGenerationOfDeliveryStoresPortfolioExecutionMetadata() throws IOException { investmentPlan.setName("Delivery Plan"); investmentPlan.setType(InvestmentPlan.Type.PURCHASE_OR_DELIVERY); @@ -497,7 +494,7 @@ public void testLedgerGenerationOfDeliveryStoresPortfolioExecutionRef() throws I assertThat(generated, hasSize(1)); assertThat(investmentPlan.getTransactions(), hasSize(0)); - assertThat(investmentPlan.getLedgerExecutionRefs(), hasSize(1)); + assertThat(investmentPlan.getLedgerExecutionRefs(), hasSize(0)); assertThat(client.getLedger().getEntries(), hasSize(1)); assertThat(portfolio.getTransactions(), hasSize(1)); assertThat(client.getAllTransactions(), hasSize(1)); @@ -508,21 +505,18 @@ public void testLedgerGenerationOfDeliveryStoresPortfolioExecutionRef() throws I assertThat(((PortfolioTransaction) transaction).getType(), is(PortfolioTransaction.Type.DELIVERY_INBOUND)); var ledgerBacked = (LedgerBackedTransaction) transaction; - var ref = investmentPlan.getLedgerExecutionRefs().get(0); - assertThat(ref.getLedgerEntryUUID(), is(ledgerBacked.getLedgerEntry().getUUID())); - assertThat(ref.getProjectionUUID(), is(ledgerBacked.getLedgerProjectionRef().getUUID())); - assertThat(ref.getProjectionRole(), is(LedgerProjectionRole.DELIVERY_INBOUND)); + assertPlanExecutionMetadata(ledgerBacked, InvestmentPlan.LedgerExecutionViewKind.PORTFOLIO); assertThat(investmentPlan.getTransactions(client).get(0).getTransaction(), is(transaction)); assertValidLedger(); } /** - * Checks the generated booking scenario: ledger generation of account only stores account execution ref. + * Checks the generated booking scenario: ledger generation of account only stores account execution metadata. * The plan reference must resolve to the intended transaction after the operation. * This prevents stale or ambiguous generated booking references. */ @Test - public void testLedgerGenerationOfAccountOnlyStoresAccountExecutionRef() throws IOException + public void testLedgerGenerationOfAccountOnlyStoresAccountExecutionMetadata() throws IOException { investmentPlan.setName("Interest Plan"); investmentPlan.setType(InvestmentPlan.Type.INTEREST); @@ -536,7 +530,7 @@ public void testLedgerGenerationOfAccountOnlyStoresAccountExecutionRef() throws assertThat(generated, hasSize(1)); assertThat(investmentPlan.getTransactions(), hasSize(0)); - assertThat(investmentPlan.getLedgerExecutionRefs(), hasSize(1)); + assertThat(investmentPlan.getLedgerExecutionRefs(), hasSize(0)); assertThat(client.getLedger().getEntries(), hasSize(1)); assertThat(account.getTransactions(), hasSize(1)); assertThat(client.getAllTransactions(), hasSize(1)); @@ -549,21 +543,18 @@ public void testLedgerGenerationOfAccountOnlyStoresAccountExecutionRef() throws assertThat(transaction.getUnitSum(Unit.Type.TAX), is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(10)))); var ledgerBacked = (LedgerBackedTransaction) transaction; - var ref = investmentPlan.getLedgerExecutionRefs().get(0); - assertThat(ref.getLedgerEntryUUID(), is(ledgerBacked.getLedgerEntry().getUUID())); - assertThat(ref.getProjectionUUID(), is(ledgerBacked.getLedgerProjectionRef().getUUID())); - assertThat(ref.getProjectionRole(), is(LedgerProjectionRole.ACCOUNT)); + assertPlanExecutionMetadata(ledgerBacked, InvestmentPlan.LedgerExecutionViewKind.ACCOUNT); assertThat(investmentPlan.getTransactions(client).get(0).getTransaction(), is(transaction)); assertValidLedger(); } /** - * Checks the generated booking scenario: ledger generation of deposit without units stores account execution ref. + * Checks the generated booking scenario: ledger generation of deposit without units stores account execution metadata. * The plan reference must resolve to the intended transaction after the operation. * This prevents stale or ambiguous generated booking references. */ @Test - public void testLedgerGenerationOfDepositWithoutUnitsStoresAccountExecutionRef() throws IOException + public void testLedgerGenerationOfDepositWithoutUnitsStoresAccountExecutionMetadata() throws IOException { investmentPlan.setName("Deposit Plan"); investmentPlan.setType(InvestmentPlan.Type.DEPOSIT); @@ -576,7 +567,7 @@ public void testLedgerGenerationOfDepositWithoutUnitsStoresAccountExecutionRef() assertThat(generated, hasSize(1)); assertThat(investmentPlan.getTransactions(), hasSize(0)); - assertThat(investmentPlan.getLedgerExecutionRefs(), hasSize(1)); + assertThat(investmentPlan.getLedgerExecutionRefs(), hasSize(0)); assertThat(client.getLedger().getEntries(), hasSize(1)); assertThat(account.getTransactions(), hasSize(1)); assertThat(client.getAllTransactions(), hasSize(1)); @@ -588,10 +579,8 @@ public void testLedgerGenerationOfDepositWithoutUnitsStoresAccountExecutionRef() assertThat(transaction.getMonetaryAmount(), is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(200)))); var ledgerBacked = (LedgerBackedTransaction) transaction; - var ref = investmentPlan.getLedgerExecutionRefs().get(0); - assertThat(ref.getLedgerEntryUUID(), is(ledgerBacked.getLedgerEntry().getUUID())); - assertThat(ref.getProjectionUUID(), is(ledgerBacked.getLedgerProjectionRef().getUUID())); - assertThat(ref.getProjectionRole(), is(LedgerProjectionRole.ACCOUNT)); + assertPlanExecutionMetadata(ledgerBacked, InvestmentPlan.LedgerExecutionViewKind.ACCOUNT); + assertThat(investmentPlan.getTransactions(client).get(0).getTransaction(), is(transaction)); assertValidLedger(); } @@ -618,7 +607,7 @@ public void testLedgerGenerationRejectsUnsupportedRemovalUnitsBeforeMutation() } /** - * Checks the generated booking scenario: ledger generation is idempotent and removal clears execution ref. + * Checks the generated booking scenario: ledger generation is idempotent and removal clears execution metadata. * The plan reference must resolve to the intended transaction after the operation. * This prevents stale or ambiguous generated booking references. */ @@ -640,7 +629,7 @@ public void testLedgerGenerationIsIdempotentAndRemovalClearsExecutionRef() throw assertThat(client.getLedger().getEntries(), hasSize(1)); assertThat(account.getTransactions(), hasSize(1)); assertThat(portfolio.getTransactions(), hasSize(1)); - assertThat(investmentPlan.getLedgerExecutionRefs(), hasSize(1)); + assertThat(investmentPlan.getLedgerExecutionRefs(), hasSize(0)); assertTrue(investmentPlan.getLastDate().isEmpty()); assertThat(investmentPlan.getLastDate(client).orElseThrow(), is(generatedDate)); assertTrue(investmentPlan.getDateOfNextTransactionToBeGenerated(client).isAfter(generatedDate)); @@ -648,17 +637,19 @@ public void testLedgerGenerationIsIdempotentAndRemovalClearsExecutionRef() throw investmentPlan.removeTransaction((PortfolioTransaction) generated.get(0).getTransaction()); assertThat(investmentPlan.getLedgerExecutionRefs(), hasSize(0)); + assertThat(((LedgerBackedTransaction) generated.get(0).getTransaction()).getLedgerEntry() + .getGeneratedByPlanKey(), nullValue()); assertThat(client.getLedger().getEntries(), hasSize(1)); assertThat(portfolio.getTransactions(), hasSize(1)); } /** - * Checks the generated booking scenario: ledger deletion clears execution refs before protobuf roundtrip. + * Checks the generated booking scenario: ledger deletion clears execution metadata before protobuf roundtrip. * The plan reference must resolve to the intended transaction after the operation. * This prevents stale or ambiguous generated booking references. */ @Test - public void testLedgerDeletionClearsExecutionRefsBeforeProtobufRoundtrip() throws IOException + public void testLedgerDeletionClearsExecutionMetadataBeforeProtobufRoundtrip() throws IOException { investmentPlan.setName("Deleted Ledger Plan"); investmentPlan.setType(InvestmentPlan.Type.PURCHASE_OR_DELIVERY); @@ -703,7 +694,7 @@ public void testLedgerDeletionClearsExecutionRefsBeforeProtobufRoundtrip() throw .anyMatch(transaction -> transaction.getUUID().equals(deletedPortfolioProjection.getUUID()))); assertThat(investmentPlan.getLedgerExecutionRefs(), hasSize(0)); assertThat(investmentPlan.getTransactions(client), hasSize(0)); - assertThat(unrelatedPlan.getLedgerExecutionRefs(), hasSize(1)); + assertThat(unrelatedPlan.getLedgerExecutionRefs(), hasSize(0)); assertThat(unrelatedPlan.getTransactions(client), hasSize(1)); String xml = ClientTestUtilities.toString(client); @@ -711,7 +702,8 @@ public void testLedgerDeletionClearsExecutionRefsBeforeProtobufRoundtrip() throw Client xmlLoaded = ClientFactory.load(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); assertThat(xmlLoaded.getPlans().get(0).getLedgerExecutionRefs(), hasSize(0)); assertThat(xmlLoaded.getPlans().get(0).getTransactions(xmlLoaded), hasSize(0)); - assertThat(xmlLoaded.getPlans().get(1).getLedgerExecutionRefs(), hasSize(1)); + assertThat(xmlLoaded.getPlans().get(1).getLedgerExecutionRefs(), hasSize(0)); + assertThat(xmlLoaded.getPlans().get(1).getTransactions(xmlLoaded), hasSize(1)); assertFalse(xmlLoaded.getAccounts().get(0).getTransactions().stream() .anyMatch(transaction -> transaction.getUUID().equals(accountProjection.getUUID()))); assertFalse(xmlLoaded.getPortfolios().get(0).getTransactions().stream() @@ -721,7 +713,8 @@ public void testLedgerDeletionClearsExecutionRefsBeforeProtobufRoundtrip() throw Client loaded = loadProtobuf(client); assertThat(loaded.getPlans().get(0).getLedgerExecutionRefs(), hasSize(0)); assertThat(loaded.getPlans().get(0).getTransactions(loaded), hasSize(0)); - assertThat(loaded.getPlans().get(1).getLedgerExecutionRefs(), hasSize(1)); + assertThat(loaded.getPlans().get(1).getLedgerExecutionRefs(), hasSize(0)); + assertThat(loaded.getPlans().get(1).getTransactions(loaded), hasSize(1)); assertFalse(loaded.getLedger().getEntries().stream().anyMatch(entry -> entry.getUUID().equals(deletedEntryUUID))); } @@ -765,12 +758,12 @@ public void testOwnerDeleteLedgerBackedAccountOnlyTransactionRemovesLedgerTruthB } /** - * Checks the generated booking scenario: generated ledger execution refs survive xml and protobuf roundtrip. + * Checks the generated booking scenario: generated ledger execution metadata survives xml and protobuf roundtrip. * The plan reference must resolve to the intended transaction after the operation. * This prevents stale or ambiguous generated booking references. */ @Test - public void testGeneratedLedgerExecutionRefsSurviveXmlAndProtobufRoundtrip() throws IOException + public void testGeneratedLedgerExecutionMetadataSurvivesXmlAndProtobufRoundtrip() throws IOException { investmentPlan.setName("Roundtrip Plan"); investmentPlan.setType(InvestmentPlan.Type.PURCHASE_OR_DELIVERY); @@ -785,24 +778,27 @@ public void testGeneratedLedgerExecutionRefsSurviveXmlAndProtobufRoundtrip() thr var expected = (LedgerBackedTransaction) generated.get(0).getTransaction(); String xml = ClientTestUtilities.toString(client); - assertThat(xml, containsString("")); + assertFalse(xml.contains("")); + assertThat(xml, containsString("" + investmentPlan.getPlanKey() + "")); + assertThat(xml, containsString("" + investmentPlan.getPlanKey() + + "")); assertFalse(xml.contains(" investmentPlan.getTransactions(client)); + assertThat(investmentPlan.getLedgerExecutionRefs(), hasSize(0)); + assertThat(ledgerBacked.getLedgerEntry().getGeneratedByPlanKey(), is(investmentPlan.getPlanKey())); + assertThat(investmentPlan.getTransactions(client).get(0).getTransaction(), is(generated.get(0).getTransaction())); } - private void assertGeneratedPlanRefRoundtrip(Client loaded, LedgerBackedTransaction expected) + private void assertGeneratedPlanMetadataRoundtrip(Client loaded, LedgerBackedTransaction expected) { var loadedPlan = loaded.getPlans().get(0); assertThat(loadedPlan.getTransactions(), hasSize(0)); - assertThat(loadedPlan.getLedgerExecutionRefs(), hasSize(1)); - - var ref = loadedPlan.getLedgerExecutionRefs().get(0); - assertThat(ref.getLedgerEntryUUID(), is(expected.getLedgerEntry().getUUID())); - assertThat(ref.getProjectionUUID(), is(expected.getLedgerProjectionRef().getUUID())); - assertThat(ref.getProjectionRole(), is(LedgerProjectionRole.PORTFOLIO)); + assertThat(loadedPlan.getLedgerExecutionRefs(), hasSize(0)); + assertThat(loadedPlan.getPlanKey(), is(investmentPlan.getPlanKey())); var resolved = loadedPlan.getTransactions(loaded).get(0).getTransaction(); assertThat(resolved, instanceOf(LedgerBackedTransaction.class)); - assertThat(resolved.getUUID(), is(expected.getLedgerProjectionRef().getUUID())); assertThat(((PortfolioTransaction) resolved).getType(), is(PortfolioTransaction.Type.BUY)); + var entry = ((LedgerBackedTransaction) resolved).getLedgerEntry(); + assertThat(entry.getGeneratedByPlanKey(), is(loadedPlan.getPlanKey())); + assertThat(entry.getPlanExecutionDate(), is(expected.getLedgerEntry().getDateTime().toLocalDate())); + assertThat(entry.getPreferredViewKind(), is(InvestmentPlan.LedgerExecutionViewKind.PORTFOLIO.name())); assertThat(loaded.getLedger().getEntries(), hasSize(1)); assertThat(loaded.getAllTransactions(), hasSize(1)); assertTrue(LedgerStructuralValidator.validate(loaded.getLedger()).isOK()); } + private void assertPlanExecutionMetadata(LedgerBackedTransaction transaction, + InvestmentPlan.LedgerExecutionViewKind preferredViewKind) + { + var entry = transaction.getLedgerEntry(); + assertThat(entry.getGeneratedByPlanKey(), is(investmentPlan.getPlanKey())); + assertThat(entry.getPlanExecutionDate(), is(transaction.getLedgerEntry().getDateTime().toLocalDate())); + assertThat(entry.getPlanExecutionSequence(), nullValue()); + assertThat(entry.getPreferredViewKind(), is(preferredViewKind.name())); + } + private void assertUnsupportedAccountOnlyUnitsRejectedBeforeMutation(InvestmentPlan.Type type) { investmentPlan.setName("Unsupported Account Plan"); diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceTest.java index 4530e31b3c..bf2cc63d0f 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceTest.java @@ -48,6 +48,7 @@ import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionService; import name.abuchen.portfolio.model.proto.v1.PClient; +import name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef; import name.abuchen.portfolio.model.proto.v1.PLedgerEntry; import name.abuchen.portfolio.model.proto.v1.PLedgerParameter; import name.abuchen.portfolio.model.proto.v1.PLedgerParameterValueKind; @@ -717,11 +718,11 @@ public void testLocalDateLedgerParameterProtobufUsesTypePolicy() throws IOExcept } /** - * Verifies that plan execution refs roundtrip through protobuf. - * The saved refs must still identify the generated ledger-backed booking after load. + * Verifies that plan execution metadata roundtrips through protobuf. + * The saved plan key must still identify the generated ledger-backed booking after load. */ @Test - public void testInvestmentPlanLedgerExecutionRefsRoundtrip() throws IOException + public void testInvestmentPlanExecutionMetadataRoundtrip() throws IOException { var fixture = fixture(); var buy = new LedgerTransactionCreator(fixture.client()).createBuy(metadata(), cashLeg(fixture.account(), 100), @@ -738,42 +739,58 @@ public void testInvestmentPlanLedgerExecutionRefsRoundtrip() throws IOException var proto = saveProto(fixture.client()); assertThat(proto.getPlans(0).getTransactionsCount(), is(0)); - assertThat(proto.getPlans(0).getLedgerExecutionRefsCount(), is(1)); - assertThat(proto.getPlans(0).getLedgerExecutionRefs(0).getLedgerEntryUUID(), is(buy.getUUID())); - assertThat(proto.getPlans(0).getLedgerExecutionRefs(0).getProjectionUUID(), is(portfolioProjection.getUUID())); - assertThat(proto.getPlans(0).getLedgerExecutionRefs(0).getProjectionRole(), - is(PLedgerProjectionRole.LEDGER_PROJECTION_ROLE_PORTFOLIO)); + assertThat(proto.getPlans(0).getLedgerExecutionRefsCount(), is(0)); + assertThat(proto.getPlans(0).hasPlanKey(), is(true)); + assertThat(proto.getLedger().getEntries(0).getGeneratedByPlanKey(), is(proto.getPlans(0).getPlanKey())); + assertThat(proto.getLedger().getEntries(0).getPlanExecutionDate(), + is(portfolioProjection.getDateTime().toLocalDate().toEpochDay())); + assertThat(proto.getLedger().getEntries(0).getPreferredViewKind(), + is(InvestmentPlan.LedgerExecutionViewKind.PORTFOLIO.name())); var loaded = load(wrap(proto)); var loadedPlan = loaded.getPlans().get(0); assertThat(loadedPlan.getTransactions().size(), is(0)); - assertThat(loadedPlan.getLedgerExecutionRefs().size(), is(1)); + assertThat(loadedPlan.getLedgerExecutionRefs().size(), is(0)); assertThat(loadedPlan.getTransactions(loaded).size(), is(1)); assertThat(loadedPlan.getTransactions(loaded).get(0).getOwner(), is(loaded.getPortfolios().get(0))); - assertThat(loadedPlan.getTransactions(loaded).get(0).getTransaction().getUUID(), - is(portfolioProjection.getUUID())); } /** - * Verifies that ambiguous plan execution refs are rejected during protobuf load. - * A plan must not silently pick one of several possible projections. + * Verifies that legacy protobuf plan refs are consumed as migration input. + * The legacy ledger/projection UUIDs must not become the new plan relation. */ @Test - public void testAmbiguousInvestmentPlanLedgerExecutionRefIsRejected() throws IOException + public void testLegacyInvestmentPlanLedgerExecutionRefMigratesToPlanMetadata() throws IOException { var fixture = fixture(); var buy = new LedgerTransactionCreator(fixture.client()).createBuy(metadata(), cashLeg(fixture.account(), 100), securityLeg(fixture.portfolio(), fixture.security(), 5, 100), LedgerCreationUnits.none()) .getEntry(); + var portfolioProjection = buy.getProjectionRefs().stream() + .filter(projection -> projection.getRole() == LedgerProjectionRole.PORTFOLIO).findFirst() + .orElseThrow(); var plan = plan(fixture); - plan.addLedgerExecutionRef(new InvestmentPlan.LedgerExecutionRef(buy.getUUID(), null, null)); fixture.client().addPlan(plan); - var loaded = load(saveBytes(fixture.client())); + var proto = saveProto(fixture.client()).toBuilder(); + proto.getPlansBuilder(0).clearPlanKey() + .addLedgerExecutionRefs(PInvestmentPlanLedgerExecutionRef.newBuilder() + .setLedgerEntryUUID(buy.getUUID()) + .setProjectionUUID(portfolioProjection.getUUID()) + .setProjectionRole(PLedgerProjectionRole.LEDGER_PROJECTION_ROLE_PORTFOLIO)); - assertThrows(IllegalArgumentException.class, () -> loaded.getPlans().get(0).getTransactions(loaded)); + var loaded = load(wrap(proto.build())); + var loadedPlan = loaded.getPlans().get(0); + var loadedEntry = loaded.getLedger().getEntries().get(0); + + assertThat(loadedPlan.getLedgerExecutionRefs().size(), is(0)); + assertThat(loadedPlan.getTransactions(loaded).size(), is(1)); + assertThat(loadedEntry.getGeneratedByPlanKey(), is(loadedPlan.getPlanKey())); + assertFalse(loadedEntry.getGeneratedByPlanKey().equals(buy.getUUID())); + assertFalse(loadedEntry.getGeneratedByPlanKey().equals(portfolioProjection.getUUID())); + assertThat(loadedEntry.getPreferredViewKind(), is(InvestmentPlan.LedgerExecutionViewKind.PORTFOLIO.name())); } /** diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerInvestmentPlanRefSupportTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerInvestmentPlanRefSupportTest.java index df50ed2ef2..b9a461d711 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerInvestmentPlanRefSupportTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerInvestmentPlanRefSupportTest.java @@ -3,152 +3,63 @@ import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.nullValue; import static org.hamcrest.MatcherAssert.assertThat; -import static org.junit.Assert.assertThrows; import org.junit.Test; import name.abuchen.portfolio.model.Client; import name.abuchen.portfolio.model.InvestmentPlan; -import name.abuchen.portfolio.model.LedgerDiagnosticCode; import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; /** - * Tests helper rules used when generated transfer bookings are split. - * These tests make sure plan references are moved only when the source or target side is unambiguous. + * Tests helper rules used when generated bookings are converted. + * Plan executions are linked by plan key and LedgerEntry metadata, so projection-ref + * conversion helpers no longer rewrite or reject InvestmentPlan refs. */ @SuppressWarnings("nls") public class LedgerInvestmentPlanRefSupportTest { /** - * Verifies that a plan reference known as the transfer source follows the new removal booking. - * The planned update must point to the removal side before the converter applies the split. + * Verifies that splitting a transfer does not rewrite legacy execution refs. + * New plan linkage follows entry metadata, not projection UUID side mappings. */ @Test - public void testSourceRoleOnlyExecutionRefMapsToRemovalProjectionUpdate() + public void testSplitUpdatesDoNotRewriteExecutionRefs() { var fixture = fixture(); var plan = planWithRef(fixture.client(), new InvestmentPlan.LedgerExecutionRef(fixture.transferEntry().getUUID(), null, LedgerProjectionRole.SOURCE_ACCOUNT)); - var updates = prepareSplitUpdates(fixture); + LedgerInvestmentPlanRefSupport.prepareAccountTransferSplitExecutionRefUpdates(fixture.client(), + fixture.transferEntry(), fixture.sourceProjection(), fixture.targetProjection(), + fixture.removalEntry(), fixture.depositEntry()).apply(); assertExecutionRef(plan, fixture.transferEntry().getUUID(), null, LedgerProjectionRole.SOURCE_ACCOUNT); - - updates.apply(); - - assertExecutionRef(plan, fixture.removalEntry().getUUID(), fixture.removalProjection().getUUID(), - LedgerProjectionRole.ACCOUNT); - } - - /** - * Verifies that a plan reference known as the transfer target follows the new deposit booking. - * The planned update must point to the deposit side before the converter applies the split. - */ - @Test - public void testTargetRoleOnlyExecutionRefMapsToDepositProjectionUpdate() - { - var fixture = fixture(); - var plan = planWithRef(fixture.client(), - new InvestmentPlan.LedgerExecutionRef(fixture.transferEntry().getUUID(), null, - LedgerProjectionRole.TARGET_ACCOUNT)); - - var updates = prepareSplitUpdates(fixture); - - assertExecutionRef(plan, fixture.transferEntry().getUUID(), null, LedgerProjectionRole.TARGET_ACCOUNT); - - updates.apply(); - - assertExecutionRef(plan, fixture.depositEntry().getUUID(), fixture.depositProjection().getUUID(), - LedgerProjectionRole.ACCOUNT); - } - - /** - * Verifies that a plan reference to the old source-side booking continues as a removal. - * The prepared update must replace the old transfer projection with the new removal projection. - */ - @Test - public void testSourceProjectionExecutionRefMapsToRemovalProjectionUpdate() - { - var fixture = fixture(); - var plan = planWithRef(fixture.client(), - new InvestmentPlan.LedgerExecutionRef(fixture.transferEntry().getUUID(), - fixture.sourceProjection().getUUID(), LedgerProjectionRole.SOURCE_ACCOUNT)); - - prepareSplitUpdates(fixture).apply(); - - assertExecutionRef(plan, fixture.removalEntry().getUUID(), fixture.removalProjection().getUUID(), - LedgerProjectionRole.ACCOUNT); } /** - * Verifies that an unspecific plan reference blocks a transfer split. - * Without a source or target side, the generated booking cannot be continued as either removal or deposit. + * Verifies that role-change validation no longer blocks conversions based on + * projection-scoped plan identity. */ @Test - public void testEntryOnlyExecutionRefRejectsSplitMapping() + public void testRoleChangeHelpersIgnoreProjectionScopedPlanRefs() { var fixture = fixture(); var plan = planWithRef(fixture.client(), new InvestmentPlan.LedgerExecutionRef(fixture.transferEntry().getUUID(), null, null)); - var exception = assertThrows(UnsupportedOperationException.class, () -> prepareSplitUpdates(fixture)); + var roleChange = LedgerInvestmentPlanRefSupport.roleChange(fixture.sourceProjection().getUUID(), + LedgerProjectionRole.SOURCE_ACCOUNT, LedgerProjectionRole.TARGET_ACCOUNT); - assertThat(exception.getMessage(), is(LedgerDiagnosticCode.LEDGER_CONVERT_045 - .message("Ledger plan reference cannot be mapped to a split transfer side"))); - assertExecutionRef(plan, fixture.transferEntry().getUUID(), null, null); - } + LedgerInvestmentPlanRefSupport.requireCurrentRefsResolveUniquely(fixture.client(), fixture.transferEntry()); + LedgerInvestmentPlanRefSupport.requireRefsFollowRoleChanges(fixture.client(), fixture.transferEntry(), + roleChange); + LedgerInvestmentPlanRefSupport.updateProjectionRoles(fixture.client(), fixture.transferEntry(), roleChange); - /** - * Verifies that a contradictory plan reference is rejected before the split can proceed. - * A reference may not point to the source-side booking and be treated as the target side. - */ - @Test - public void testConflictingProjectionRoleRejectsSplitMapping() - { - var fixture = fixture(); - var plan = planWithRef(fixture.client(), - new InvestmentPlan.LedgerExecutionRef(fixture.transferEntry().getUUID(), - fixture.sourceProjection().getUUID(), LedgerProjectionRole.TARGET_ACCOUNT)); - - var exception = assertThrows(UnsupportedOperationException.class, () -> prepareSplitUpdates(fixture)); - - assertThat(exception.getMessage(), is(LedgerDiagnosticCode.LEDGER_CONVERT_045 - .message("Ledger plan reference cannot be mapped to a split transfer side"))); - assertExecutionRef(plan, fixture.transferEntry().getUUID(), fixture.sourceProjection().getUUID(), - LedgerProjectionRole.TARGET_ACCOUNT); - } - - /** - * Verifies that an ambiguous split target is rejected before any plan reference is changed. - * The code must not choose between multiple possible removal bookings. - */ - @Test - public void testAmbiguousSplitTargetProjectionRejectsBeforeExecutionRefUpdate() - { - var fixture = fixture(); - var plan = planWithRef(fixture.client(), - new InvestmentPlan.LedgerExecutionRef(fixture.transferEntry().getUUID(), - fixture.sourceProjection().getUUID(), LedgerProjectionRole.SOURCE_ACCOUNT)); - - fixture.removalEntry().addProjectionRef(accountProjection("second-removal-projection")); - - var exception = assertThrows(UnsupportedOperationException.class, () -> prepareSplitUpdates(fixture)); - - assertThat(exception.getMessage(), is(LedgerDiagnosticCode.LEDGER_PROJ_041 - .message("Ledger plan reference cannot be mapped to a split transfer side"))); - assertExecutionRef(plan, fixture.transferEntry().getUUID(), fixture.sourceProjection().getUUID(), - LedgerProjectionRole.SOURCE_ACCOUNT); - } - - private LedgerInvestmentPlanRefSupport.SplitExecutionRefUpdates prepareSplitUpdates(Fixture fixture) - { - return LedgerInvestmentPlanRefSupport.prepareAccountTransferSplitExecutionRefUpdates(fixture.client(), - fixture.transferEntry(), fixture.sourceProjection(), fixture.targetProjection(), - fixture.removalEntry(), fixture.depositEntry()); + assertExecutionRef(plan, fixture.transferEntry().getUUID(), null, null); } private InvestmentPlan planWithRef(Client client, InvestmentPlan.LedgerExecutionRef ref) @@ -179,16 +90,13 @@ private Fixture fixture() var depositEntry = entry("deposit-entry", LedgerEntryType.DEPOSIT); var sourceProjection = projection("source-projection", LedgerProjectionRole.SOURCE_ACCOUNT); var targetProjection = projection("target-projection", LedgerProjectionRole.TARGET_ACCOUNT); - var removalProjection = accountProjection("removal-projection"); - var depositProjection = accountProjection("deposit-projection"); transferEntry.addProjectionRef(sourceProjection); transferEntry.addProjectionRef(targetProjection); - removalEntry.addProjectionRef(removalProjection); - depositEntry.addProjectionRef(depositProjection); + removalEntry.addProjectionRef(projection("removal-projection", LedgerProjectionRole.ACCOUNT)); + depositEntry.addProjectionRef(projection("deposit-projection", LedgerProjectionRole.ACCOUNT)); - return new Fixture(client, transferEntry, sourceProjection, targetProjection, removalEntry, removalProjection, - depositEntry, depositProjection); + return new Fixture(client, transferEntry, sourceProjection, targetProjection, removalEntry, depositEntry); } private LedgerEntry entry(String uuid, LedgerEntryType type) @@ -200,11 +108,6 @@ private LedgerEntry entry(String uuid, LedgerEntryType type) return entry; } - private LedgerProjectionRef accountProjection(String uuid) - { - return projection(uuid, LedgerProjectionRole.ACCOUNT); - } - private LedgerProjectionRef projection(String uuid, LedgerProjectionRole role) { var projection = new LedgerProjectionRef(uuid); @@ -215,9 +118,7 @@ private LedgerProjectionRef projection(String uuid, LedgerProjectionRole role) } private record Fixture(Client client, LedgerEntry transferEntry, LedgerProjectionRef sourceProjection, - LedgerProjectionRef targetProjection, LedgerEntry removalEntry, - LedgerProjectionRef removalProjection, LedgerEntry depositEntry, - LedgerProjectionRef depositProjection) + LedgerProjectionRef targetProjection, LedgerEntry removalEntry, LedgerEntry depositEntry) { } } diff --git a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/ClientProtos.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/ClientProtos.java index 3d65528092..75ca61e266 100644 --- a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/ClientProtos.java +++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/ClientProtos.java @@ -16,197 +16,197 @@ public static void registerAllExtensions( } static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PDecimalValue_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PDecimalValue_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PLocalDateTime_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PLocalDateTime_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PAnyValue_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PAnyValue_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PKeyValue_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PKeyValue_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PMap_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PMap_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PHistoricalPrice_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PHistoricalPrice_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PFullHistoricalPrice_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PFullHistoricalPrice_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PSecurityEvent_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PSecurityEvent_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PSecurity_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PSecurity_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PWatchlist_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PWatchlist_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PAccount_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PAccount_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PPortfolio_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PPortfolio_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PTransactionUnit_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PTransactionUnit_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PTransaction_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PTransaction_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PInvestmentPlan_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PInvestmentPlan_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PInvestmentPlanLedgerExecutionRef_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PInvestmentPlanLedgerExecutionRef_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PLedger_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PLedger_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PLedgerEntry_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PLedgerEntry_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PLedgerPosting_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PLedgerPosting_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PLedgerProjectionRef_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PLedgerProjectionRef_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PLedgerProjectionMembership_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PLedgerProjectionMembership_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PLedgerParameter_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PLedgerParameter_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PTaxonomy_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PTaxonomy_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PTaxonomy_Assignment_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PTaxonomy_Assignment_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PTaxonomy_Classification_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PTaxonomy_Classification_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PDashboard_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PDashboard_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PDashboard_Widget_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PDashboard_Widget_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PDashboard_Widget_ConfigurationEntry_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PDashboard_Widget_ConfigurationEntry_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PDashboard_Column_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PDashboard_Column_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PDashboard_ConfigurationEntry_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PDashboard_ConfigurationEntry_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PBookmark_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PBookmark_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PAttributeType_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PAttributeType_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PConfigurationSet_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PConfigurationSet_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PSettings_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PSettings_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PClient_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PClient_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PClient_PropertiesEntry_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PClient_PropertiesEntry_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PExchangeRate_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PExchangeRate_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PExchangeRateTimeSeries_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PExchangeRateTimeSeries_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PECBData_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PECBData_fieldAccessorTable; @@ -309,7 +309,7 @@ public static void registerAllExtensions( "olioB\017\n\r_otherAccountB\021\n\017_otherPortfolio" + "B\014\n\n_otherUuidB\021\n\017_otherUpdatedAtB\t\n\007_sh" + "aresB\007\n\005_noteB\013\n\t_securityB\t\n\007_sourceB\t\n" + - "\007_exDate\"\265\004\n\017PInvestmentPlan\022\014\n\004name\030\001 \001" + + "\007_exDate\"\327\004\n\017PInvestmentPlan\022\014\n\004name\030\001 \001" + "(\t\022\021\n\004note\030\002 \001(\tH\000\210\001\001\022\025\n\010security\030\003 \001(\tH" + "\001\210\001\001\022\026\n\tportfolio\030\004 \001(\tH\002\210\001\001\022\024\n\007account\030" + "\005 \001(\tH\003\210\001\001\0225\n\nattributes\030\006 \003(\0132!.name.ab" + @@ -320,173 +320,180 @@ public static void registerAllExtensions( "2,.name.abuchen.portfolio.PInvestmentPla" + "n.Type\022V\n\023ledgerExecutionRefs\030\017 \003(\01329.na" + "me.abuchen.portfolio.PInvestmentPlanLedg" + - "erExecutionRef\"H\n\004Type\022\030\n\024PURCHASE_OR_DE" + - "LIVERY\020\000\022\013\n\007DEPOSIT\020\001\022\013\n\007REMOVAL\020\002\022\014\n\010IN" + - "TEREST\020\003B\007\n\005_noteB\013\n\t_securityB\014\n\n_portf" + - "olioB\n\n\010_account\"\313\001\n!PInvestmentPlanLedg" + - "erExecutionRef\022\027\n\017ledgerEntryUUID\030\001 \001(\t\022" + - "\033\n\016projectionUUID\030\002 \001(\tH\000\210\001\001\022J\n\016projecti" + - "onRole\030\003 \001(\0162-.name.abuchen.portfolio.PL" + - "edgerProjectionRoleH\001\210\001\001B\021\n\017_projectionU" + - "UIDB\021\n\017_projectionRole\"@\n\007PLedger\0225\n\007ent" + - "ries\030\001 \003(\0132$.name.abuchen.portfolio.PLed" + - "gerEntry\"\231\003\n\014PLedgerEntry\022\014\n\004uuid\030\001 \001(\t\022" + - ",\n\010dateTime\030\003 \001(\0132\032.google.protobuf.Time" + - "stamp\022\021\n\004note\030\004 \001(\tH\000\210\001\001\022\023\n\006source\030\005 \001(\t" + - "H\001\210\001\001\022-\n\tupdatedAt\030\006 \001(\0132\032.google.protob" + - "uf.Timestamp\0228\n\010postings\030\007 \003(\0132&.name.ab" + - "uchen.portfolio.PLedgerPosting\022D\n\016projec" + - "tionRefs\030\010 \003(\0132,.name.abuchen.portfolio." + - "PLedgerProjectionRef\022\023\n\006typeId\030\t \001(\rH\002\210\001" + - "\001\022<\n\nparameters\030\n \003(\0132(.name.abuchen.por" + - "tfolio.PLedgerParameterB\007\n\005_noteB\t\n\007_sou" + - "rceB\t\n\007_typeIdJ\004\010\002\020\003\"\341\003\n\016PLedgerPosting\022" + - "\014\n\004uuid\030\001 \001(\t\022\016\n\006amount\030\003 \001(\003\022\025\n\010currenc" + - "y\030\004 \001(\tH\000\210\001\001\022\030\n\013forexAmount\030\005 \001(\003H\001\210\001\001\022\032" + - "\n\rforexCurrency\030\006 \001(\tH\002\210\001\001\022@\n\014exchangeRa" + - "te\030\007 \001(\0132%.name.abuchen.portfolio.PDecim" + - "alValueH\003\210\001\001\022\025\n\010security\030\010 \001(\tH\004\210\001\001\022\016\n\006s" + - "hares\030\t \001(\003\022\024\n\007account\030\n \001(\tH\005\210\001\001\022\026\n\tpor" + - "tfolio\030\013 \001(\tH\006\210\001\001\022<\n\nparameters\030\014 \003(\0132(." + - "name.abuchen.portfolio.PLedgerParameter\022" + - "\025\n\010typeCode\030\r \001(\tH\007\210\001\001B\013\n\t_currencyB\016\n\014_" + - "forexAmountB\020\n\016_forexCurrencyB\017\n\r_exchan" + - "geRateB\013\n\t_securityB\n\n\010_accountB\014\n\n_port" + - "folioB\013\n\t_typeCodeJ\004\010\002\020\003\"\363\001\n\024PLedgerProj" + - "ectionRef\022\014\n\004uuid\030\001 \001(\t\022;\n\004role\030\002 \001(\0162-." + - "name.abuchen.portfolio.PLedgerProjection" + - "Role\022\024\n\007account\030\003 \001(\tH\000\210\001\001\022\026\n\tportfolio\030" + - "\004 \001(\tH\001\210\001\001\022H\n\013memberships\030\005 \003(\01323.name.a" + - "buchen.portfolio.PLedgerProjectionMember" + - "shipB\n\n\010_accountB\014\n\n_portfolio\"y\n\033PLedge" + - "rProjectionMembership\022\023\n\013postingUUID\030\001 \001" + - "(\t\022E\n\004role\030\002 \001(\01627.name.abuchen.portfoli" + - "o.PLedgerProjectionMembershipRole\"\266\005\n\020PL" + - "edgerParameter\022D\n\tvalueKind\030\002 \001(\01621.name" + - ".abuchen.portfolio.PLedgerParameterValue" + - "Kind\022\030\n\013stringValue\030\003 \001(\tH\000\210\001\001\022@\n\014decima" + - "lValue\030\004 \001(\0132%.name.abuchen.portfolio.PD" + - "ecimalValueH\001\210\001\001\022\026\n\tlongValue\030\005 \001(\003H\002\210\001\001" + - "\022\030\n\013moneyAmount\030\006 \001(\003H\003\210\001\001\022\032\n\rmoneyCurre" + - "ncy\030\007 \001(\tH\004\210\001\001\022\025\n\010security\030\010 \001(\tH\005\210\001\001\022\024\n" + - "\007account\030\t \001(\tH\006\210\001\001\022\026\n\tportfolio\030\n \001(\tH\007" + - "\210\001\001\022G\n\022localDateTimeValue\030\014 \001(\0132&.name.a" + - "buchen.portfolio.PLocalDateTimeH\010\210\001\001\022\025\n\010" + - "typeCode\030\r \001(\tH\t\210\001\001\022\031\n\014booleanValue\030\016 \001(" + - "\010H\n\210\001\001\022\033\n\016localDateValue\030\017 \001(\003H\013\210\001\001B\016\n\014_" + - "stringValueB\017\n\r_decimalValueB\014\n\n_longVal" + - "ueB\016\n\014_moneyAmountB\020\n\016_moneyCurrencyB\013\n\t" + - "_securityB\n\n\010_accountB\014\n\n_portfolioB\025\n\023_" + - "localDateTimeValueB\013\n\t_typeCodeB\017\n\r_bool" + - "eanValueB\021\n\017_localDateValueJ\004\010\001\020\002J\004\010\013\020\014R" + - "\tenumValue\"\252\004\n\tPTaxonomy\022\n\n\002id\030\001 \001(\t\022\014\n\004" + - "name\030\002 \001(\t\022\023\n\006source\030\003 \001(\tH\000\210\001\001\022\022\n\ndimen" + - "sions\030\004 \003(\t\022I\n\017classifications\030\005 \003(\01320.n" + - "ame.abuchen.portfolio.PTaxonomy.Classifi" + - "cation\032v\n\nAssignment\022\031\n\021investmentVehicl" + - "e\030\001 \001(\t\022\016\n\006weight\030\002 \001(\005\022\014\n\004rank\030\003 \001(\005\022/\n" + - "\004data\030\004 \003(\0132!.name.abuchen.portfolio.PKe" + - "yValue\032\213\002\n\016Classification\022\n\n\002id\030\001 \001(\t\022\025\n" + - "\010parentId\030\002 \001(\tH\000\210\001\001\022\014\n\004name\030\003 \001(\t\022\021\n\004no" + - "te\030\004 \001(\tH\001\210\001\001\022\r\n\005color\030\005 \001(\t\022\016\n\006weight\030\006" + - " \001(\005\022\014\n\004rank\030\007 \001(\005\022/\n\004data\030\010 \003(\0132!.name." + - "abuchen.portfolio.PKeyValue\022A\n\013assignmen" + - "ts\030\t \003(\0132,.name.abuchen.portfolio.PTaxon" + - "omy.AssignmentB\013\n\t_parentIdB\007\n\005_noteB\t\n\007" + - "_source\"\357\003\n\nPDashboard\022\014\n\004name\030\001 \001(\t\022L\n\r" + - "configuration\030\002 \003(\01325.name.abuchen.portf" + - "olio.PDashboard.ConfigurationEntry\022:\n\007co" + - "lumns\030\003 \003(\0132).name.abuchen.portfolio.PDa" + - "shboard.Column\022\n\n\002id\030\004 \001(\t\032\260\001\n\006Widget\022\014\n" + - "\004type\030\001 \001(\t\022\r\n\005label\030\002 \001(\t\022S\n\rconfigurat" + - "ion\030\003 \003(\0132<.name.abuchen.portfolio.PDash" + - "board.Widget.ConfigurationEntry\0324\n\022Confi" + + "erExecutionRef\022\024\n\007planKey\030\020 \001(\tH\004\210\001\001\"H\n\004" + + "Type\022\030\n\024PURCHASE_OR_DELIVERY\020\000\022\013\n\007DEPOSI" + + "T\020\001\022\013\n\007REMOVAL\020\002\022\014\n\010INTEREST\020\003B\007\n\005_noteB" + + "\013\n\t_securityB\014\n\n_portfolioB\n\n\010_accountB\n" + + "\n\010_planKey\"\313\001\n!PInvestmentPlanLedgerExec" + + "utionRef\022\027\n\017ledgerEntryUUID\030\001 \001(\t\022\033\n\016pro" + + "jectionUUID\030\002 \001(\tH\000\210\001\001\022J\n\016projectionRole" + + "\030\003 \001(\0162-.name.abuchen.portfolio.PLedgerP" + + "rojectionRoleH\001\210\001\001B\021\n\017_projectionUUIDB\021\n" + + "\017_projectionRole\"@\n\007PLedger\0225\n\007entries\030\001" + + " \003(\0132$.name.abuchen.portfolio.PLedgerEnt" + + "ry\"\373\004\n\014PLedgerEntry\022\014\n\004uuid\030\001 \001(\t\022,\n\010dat" + + "eTime\030\003 \001(\0132\032.google.protobuf.Timestamp\022" + + "\021\n\004note\030\004 \001(\tH\000\210\001\001\022\023\n\006source\030\005 \001(\tH\001\210\001\001\022" + + "-\n\tupdatedAt\030\006 \001(\0132\032.google.protobuf.Tim" + + "estamp\0228\n\010postings\030\007 \003(\0132&.name.abuchen." + + "portfolio.PLedgerPosting\022D\n\016projectionRe" + + "fs\030\010 \003(\0132,.name.abuchen.portfolio.PLedge" + + "rProjectionRef\022\023\n\006typeId\030\t \001(\rH\002\210\001\001\022<\n\np" + + "arameters\030\n \003(\0132(.name.abuchen.portfolio" + + ".PLedgerParameter\022\037\n\022generatedByPlanKey\030" + + "\013 \001(\tH\003\210\001\001\022\036\n\021planExecutionDate\030\014 \001(\003H\004\210" + + "\001\001\022\"\n\025planExecutionSequence\030\r \001(\005H\005\210\001\001\022\036" + + "\n\021preferredViewKind\030\016 \001(\tH\006\210\001\001B\007\n\005_noteB" + + "\t\n\007_sourceB\t\n\007_typeIdB\025\n\023_generatedByPla" + + "nKeyB\024\n\022_planExecutionDateB\030\n\026_planExecu" + + "tionSequenceB\024\n\022_preferredViewKindJ\004\010\002\020\003" + + "\"\341\003\n\016PLedgerPosting\022\014\n\004uuid\030\001 \001(\t\022\016\n\006amo" + + "unt\030\003 \001(\003\022\025\n\010currency\030\004 \001(\tH\000\210\001\001\022\030\n\013fore" + + "xAmount\030\005 \001(\003H\001\210\001\001\022\032\n\rforexCurrency\030\006 \001(" + + "\tH\002\210\001\001\022@\n\014exchangeRate\030\007 \001(\0132%.name.abuc" + + "hen.portfolio.PDecimalValueH\003\210\001\001\022\025\n\010secu" + + "rity\030\010 \001(\tH\004\210\001\001\022\016\n\006shares\030\t \001(\003\022\024\n\007accou" + + "nt\030\n \001(\tH\005\210\001\001\022\026\n\tportfolio\030\013 \001(\tH\006\210\001\001\022<\n" + + "\nparameters\030\014 \003(\0132(.name.abuchen.portfol" + + "io.PLedgerParameter\022\025\n\010typeCode\030\r \001(\tH\007\210" + + "\001\001B\013\n\t_currencyB\016\n\014_forexAmountB\020\n\016_fore" + + "xCurrencyB\017\n\r_exchangeRateB\013\n\t_securityB" + + "\n\n\010_accountB\014\n\n_portfolioB\013\n\t_typeCodeJ\004" + + "\010\002\020\003\"\363\001\n\024PLedgerProjectionRef\022\014\n\004uuid\030\001 " + + "\001(\t\022;\n\004role\030\002 \001(\0162-.name.abuchen.portfol" + + "io.PLedgerProjectionRole\022\024\n\007account\030\003 \001(" + + "\tH\000\210\001\001\022\026\n\tportfolio\030\004 \001(\tH\001\210\001\001\022H\n\013member" + + "ships\030\005 \003(\01323.name.abuchen.portfolio.PLe" + + "dgerProjectionMembershipB\n\n\010_accountB\014\n\n" + + "_portfolio\"y\n\033PLedgerProjectionMembershi" + + "p\022\023\n\013postingUUID\030\001 \001(\t\022E\n\004role\030\002 \001(\01627.n" + + "ame.abuchen.portfolio.PLedgerProjectionM" + + "embershipRole\"\266\005\n\020PLedgerParameter\022D\n\tva" + + "lueKind\030\002 \001(\01621.name.abuchen.portfolio.P" + + "LedgerParameterValueKind\022\030\n\013stringValue\030" + + "\003 \001(\tH\000\210\001\001\022@\n\014decimalValue\030\004 \001(\0132%.name." + + "abuchen.portfolio.PDecimalValueH\001\210\001\001\022\026\n\t" + + "longValue\030\005 \001(\003H\002\210\001\001\022\030\n\013moneyAmount\030\006 \001(" + + "\003H\003\210\001\001\022\032\n\rmoneyCurrency\030\007 \001(\tH\004\210\001\001\022\025\n\010se" + + "curity\030\010 \001(\tH\005\210\001\001\022\024\n\007account\030\t \001(\tH\006\210\001\001\022" + + "\026\n\tportfolio\030\n \001(\tH\007\210\001\001\022G\n\022localDateTime" + + "Value\030\014 \001(\0132&.name.abuchen.portfolio.PLo" + + "calDateTimeH\010\210\001\001\022\025\n\010typeCode\030\r \001(\tH\t\210\001\001\022" + + "\031\n\014booleanValue\030\016 \001(\010H\n\210\001\001\022\033\n\016localDateV" + + "alue\030\017 \001(\003H\013\210\001\001B\016\n\014_stringValueB\017\n\r_deci" + + "malValueB\014\n\n_longValueB\016\n\014_moneyAmountB\020" + + "\n\016_moneyCurrencyB\013\n\t_securityB\n\n\010_accoun" + + "tB\014\n\n_portfolioB\025\n\023_localDateTimeValueB\013" + + "\n\t_typeCodeB\017\n\r_booleanValueB\021\n\017_localDa" + + "teValueJ\004\010\001\020\002J\004\010\013\020\014R\tenumValue\"\252\004\n\tPTaxo" + + "nomy\022\n\n\002id\030\001 \001(\t\022\014\n\004name\030\002 \001(\t\022\023\n\006source" + + "\030\003 \001(\tH\000\210\001\001\022\022\n\ndimensions\030\004 \003(\t\022I\n\017class" + + "ifications\030\005 \003(\01320.name.abuchen.portfoli" + + "o.PTaxonomy.Classification\032v\n\nAssignment" + + "\022\031\n\021investmentVehicle\030\001 \001(\t\022\016\n\006weight\030\002 " + + "\001(\005\022\014\n\004rank\030\003 \001(\005\022/\n\004data\030\004 \003(\0132!.name.a" + + "buchen.portfolio.PKeyValue\032\213\002\n\016Classific" + + "ation\022\n\n\002id\030\001 \001(\t\022\025\n\010parentId\030\002 \001(\tH\000\210\001\001" + + "\022\014\n\004name\030\003 \001(\t\022\021\n\004note\030\004 \001(\tH\001\210\001\001\022\r\n\005col" + + "or\030\005 \001(\t\022\016\n\006weight\030\006 \001(\005\022\014\n\004rank\030\007 \001(\005\022/" + + "\n\004data\030\010 \003(\0132!.name.abuchen.portfolio.PK" + + "eyValue\022A\n\013assignments\030\t \003(\0132,.name.abuc" + + "hen.portfolio.PTaxonomy.AssignmentB\013\n\t_p" + + "arentIdB\007\n\005_noteB\t\n\007_source\"\357\003\n\nPDashboa" + + "rd\022\014\n\004name\030\001 \001(\t\022L\n\rconfiguration\030\002 \003(\0132" + + "5.name.abuchen.portfolio.PDashboard.Conf" + + "igurationEntry\022:\n\007columns\030\003 \003(\0132).name.a" + + "buchen.portfolio.PDashboard.Column\022\n\n\002id" + + "\030\004 \001(\t\032\260\001\n\006Widget\022\014\n\004type\030\001 \001(\t\022\r\n\005label" + + "\030\002 \001(\t\022S\n\rconfiguration\030\003 \003(\0132<.name.abu" + + "chen.portfolio.PDashboard.Widget.Configu" + + "rationEntry\0324\n\022ConfigurationEntry\022\013\n\003key" + + "\030\001 \001(\t\022\r\n\005value\030\002 \001(\t:\0028\001\032T\n\006Column\022\016\n\006w" + + "eight\030\001 \001(\005\022:\n\007widgets\030\002 \003(\0132).name.abuc" + + "hen.portfolio.PDashboard.Widget\0324\n\022Confi" + "gurationEntry\022\013\n\003key\030\001 \001(\t\022\r\n\005value\030\002 \001(" + - "\t:\0028\001\032T\n\006Column\022\016\n\006weight\030\001 \001(\005\022:\n\007widge" + - "ts\030\002 \003(\0132).name.abuchen.portfolio.PDashb" + - "oard.Widget\0324\n\022ConfigurationEntry\022\013\n\003key" + - "\030\001 \001(\t\022\r\n\005value\030\002 \001(\t:\0028\001\"+\n\tPBookmark\022\r" + - "\n\005label\030\001 \001(\t\022\017\n\007pattern\030\002 \001(\t\"\307\001\n\016PAttr" + - "ibuteType\022\n\n\002id\030\001 \001(\t\022\014\n\004name\030\002 \001(\t\022\023\n\013c" + - "olumnLabel\030\003 \001(\t\022\023\n\006source\030\004 \001(\tH\000\210\001\001\022\016\n" + - "\006target\030\005 \001(\t\022\014\n\004type\030\006 \001(\t\022\026\n\016converter" + - "Class\030\007 \001(\t\0220\n\nproperties\030\010 \001(\0132\034.name.a" + - "buchen.portfolio.PMapB\t\n\007_source\"J\n\021PCon" + - "figurationSet\022\013\n\003key\030\001 \001(\t\022\014\n\004uuid\030\002 \001(\t" + - "\022\014\n\004name\030\003 \001(\t\022\014\n\004data\030\004 \001(\t\"\307\001\n\tPSettin" + - "gs\0224\n\tbookmarks\030\001 \003(\0132!.name.abuchen.por" + - "tfolio.PBookmark\022>\n\016attributeTypes\030\002 \003(\013" + - "2&.name.abuchen.portfolio.PAttributeType" + - "\022D\n\021configurationSets\030\003 \003(\0132).name.abuch" + - "en.portfolio.PConfigurationSet\"\366\005\n\007PClie" + - "nt\022\017\n\007version\030\001 \001(\005\0225\n\nsecurities\030\002 \003(\0132" + - "!.name.abuchen.portfolio.PSecurity\0222\n\010ac" + - "counts\030\003 \003(\0132 .name.abuchen.portfolio.PA" + - "ccount\0226\n\nportfolios\030\004 \003(\0132\".name.abuche" + - "n.portfolio.PPortfolio\022:\n\014transactions\030\005" + - " \003(\0132$.name.abuchen.portfolio.PTransacti" + - "on\0226\n\005plans\030\006 \003(\0132\'.name.abuchen.portfol" + - "io.PInvestmentPlan\0226\n\nwatchlists\030\007 \003(\0132\"" + - ".name.abuchen.portfolio.PWatchlist\0225\n\nta" + - "xonomies\030\010 \003(\0132!.name.abuchen.portfolio." + - "PTaxonomy\0226\n\ndashboards\030\t \003(\0132\".name.abu" + - "chen.portfolio.PDashboard\022C\n\nproperties\030" + - "\n \003(\0132/.name.abuchen.portfolio.PClient.P" + - "ropertiesEntry\0223\n\010settings\030\013 \001(\0132!.name." + - "abuchen.portfolio.PSettings\022\024\n\014baseCurre" + - "ncy\030\014 \001(\t\022/\n\006ledger\030\r \001(\0132\037.name.abuchen" + - ".portfolio.PLedger\022(\n\nextensions\030c \003(\0132\024" + - ".google.protobuf.Any\0321\n\017PropertiesEntry\022" + - "\013\n\003key\030\001 \001(\t\022\r\n\005value\030\002 \001(\t:\0028\001\"S\n\rPExch" + - "angeRate\022\014\n\004date\030\001 \001(\003\0224\n\005value\030\002 \001(\0132%." + - "name.abuchen.portfolio.PDecimalValue\"\203\001\n" + - "\027PExchangeRateTimeSeries\022\024\n\014baseCurrency" + - "\030\001 \001(\t\022\024\n\014termCurrency\030\002 \001(\t\022<\n\rexchange" + - "Rates\030\003 \003(\0132%.name.abuchen.portfolio.PEx" + - "changeRate\"a\n\010PECBData\022\024\n\014lastModified\030\001" + - " \001(\003\022?\n\006series\030\002 \003(\0132/.name.abuchen.port" + - "folio.PExchangeRateTimeSeries*\301\004\n\025PLedge" + - "rProjectionRole\022&\n\"LEDGER_PROJECTION_ROL" + - "E_UNSPECIFIED\020\000\022\"\n\036LEDGER_PROJECTION_ROL" + - "E_ACCOUNT\020\001\022$\n LEDGER_PROJECTION_ROLE_PO" + - "RTFOLIO\020\002\022)\n%LEDGER_PROJECTION_ROLE_SOUR" + - "CE_ACCOUNT\020\003\022)\n%LEDGER_PROJECTION_ROLE_T" + - "ARGET_ACCOUNT\020\004\022+\n\'LEDGER_PROJECTION_ROL" + - "E_SOURCE_PORTFOLIO\020\005\022+\n\'LEDGER_PROJECTIO" + - "N_ROLE_TARGET_PORTFOLIO\020\006\022#\n\037LEDGER_PROJ" + - "ECTION_ROLE_DELIVERY\020\007\022+\n\'LEDGER_PROJECT" + - "ION_ROLE_DELIVERY_INBOUND\020\010\022,\n(LEDGER_PR" + - "OJECTION_ROLE_DELIVERY_OUTBOUND\020\t\022,\n(LED" + - "GER_PROJECTION_ROLE_CASH_COMPENSATION\020\n\022" + - "+\n\'LEDGER_PROJECTION_ROLE_OLD_SECURITY_L" + - "EG\020\013\022+\n\'LEDGER_PROJECTION_ROLE_NEW_SECUR" + - "ITY_LEG\020\014*\204\003\n\037PLedgerProjectionMembershi" + - "pRole\0221\n-LEDGER_PROJECTION_MEMBERSHIP_RO" + - "LE_UNSPECIFIED\020\000\022-\n)LEDGER_PROJECTION_ME" + - "MBERSHIP_ROLE_PRIMARY\020\001\0222\n.LEDGER_PROJEC" + - "TION_MEMBERSHIP_ROLE_GROUP_ANCHOR\020\002\022.\n*L" + - "EDGER_PROJECTION_MEMBERSHIP_ROLE_FEE_UNI" + - "T\020\003\022.\n*LEDGER_PROJECTION_MEMBERSHIP_ROLE" + - "_TAX_UNIT\020\004\0226\n2LEDGER_PROJECTION_MEMBERS" + - "HIP_ROLE_GROSS_VALUE_UNIT\020\005\0223\n/LEDGER_PR" + - "OJECTION_MEMBERSHIP_ROLE_FOREX_CONTEXT\020\006" + - "*\327\004\n\031PLedgerParameterValueKind\022+\n\'LEDGER" + - "_PARAMETER_VALUE_KIND_UNSPECIFIED\020\000\022&\n\"L" + - "EDGER_PARAMETER_VALUE_KIND_STRING\020\001\022\'\n#L" + - "EDGER_PARAMETER_VALUE_KIND_DECIMAL\020\002\022$\n " + - "LEDGER_PARAMETER_VALUE_KIND_LONG\020\003\022%\n!LE" + - "DGER_PARAMETER_VALUE_KIND_MONEY\020\004\022(\n$LED" + - "GER_PARAMETER_VALUE_KIND_SECURITY\020\005\022\'\n#L" + - "EDGER_PARAMETER_VALUE_KIND_ACCOUNT\020\006\022)\n%" + - "LEDGER_PARAMETER_VALUE_KIND_PORTFOLIO\020\007\022" + - "/\n+LEDGER_PARAMETER_VALUE_KIND_LOCAL_DAT" + - "E_TIME\020\n\022\'\n#LEDGER_PARAMETER_VALUE_KIND_" + - "BOOLEAN\020\013\022*\n&LEDGER_PARAMETER_VALUE_KIND" + - "_LOCAL_DATE\020\014\"\004\010\010\020\010\"\004\010\t\020\t*0LEDGER_PARAME" + - "TER_VALUE_KIND_CORPORATE_ACTION_LEG*-LED" + - "GER_PARAMETER_VALUE_KIND_COMPENSATION_KI" + - "NDB7\n%name.abuchen.portfolio.model.proto" + - ".v1B\014ClientProtosP\001b\006proto3" + "\t:\0028\001\"+\n\tPBookmark\022\r\n\005label\030\001 \001(\t\022\017\n\007pat" + + "tern\030\002 \001(\t\"\307\001\n\016PAttributeType\022\n\n\002id\030\001 \001(" + + "\t\022\014\n\004name\030\002 \001(\t\022\023\n\013columnLabel\030\003 \001(\t\022\023\n\006" + + "source\030\004 \001(\tH\000\210\001\001\022\016\n\006target\030\005 \001(\t\022\014\n\004typ" + + "e\030\006 \001(\t\022\026\n\016converterClass\030\007 \001(\t\0220\n\nprope" + + "rties\030\010 \001(\0132\034.name.abuchen.portfolio.PMa" + + "pB\t\n\007_source\"J\n\021PConfigurationSet\022\013\n\003key" + + "\030\001 \001(\t\022\014\n\004uuid\030\002 \001(\t\022\014\n\004name\030\003 \001(\t\022\014\n\004da" + + "ta\030\004 \001(\t\"\307\001\n\tPSettings\0224\n\tbookmarks\030\001 \003(" + + "\0132!.name.abuchen.portfolio.PBookmark\022>\n\016" + + "attributeTypes\030\002 \003(\0132&.name.abuchen.port" + + "folio.PAttributeType\022D\n\021configurationSet" + + "s\030\003 \003(\0132).name.abuchen.portfolio.PConfig" + + "urationSet\"\366\005\n\007PClient\022\017\n\007version\030\001 \001(\005\022" + + "5\n\nsecurities\030\002 \003(\0132!.name.abuchen.portf" + + "olio.PSecurity\0222\n\010accounts\030\003 \003(\0132 .name." + + "abuchen.portfolio.PAccount\0226\n\nportfolios" + + "\030\004 \003(\0132\".name.abuchen.portfolio.PPortfol" + + "io\022:\n\014transactions\030\005 \003(\0132$.name.abuchen." + + "portfolio.PTransaction\0226\n\005plans\030\006 \003(\0132\'." + + "name.abuchen.portfolio.PInvestmentPlan\0226" + + "\n\nwatchlists\030\007 \003(\0132\".name.abuchen.portfo" + + "lio.PWatchlist\0225\n\ntaxonomies\030\010 \003(\0132!.nam" + + "e.abuchen.portfolio.PTaxonomy\0226\n\ndashboa" + + "rds\030\t \003(\0132\".name.abuchen.portfolio.PDash" + + "board\022C\n\nproperties\030\n \003(\0132/.name.abuchen" + + ".portfolio.PClient.PropertiesEntry\0223\n\010se" + + "ttings\030\013 \001(\0132!.name.abuchen.portfolio.PS" + + "ettings\022\024\n\014baseCurrency\030\014 \001(\t\022/\n\006ledger\030" + + "\r \001(\0132\037.name.abuchen.portfolio.PLedger\022(" + + "\n\nextensions\030c \003(\0132\024.google.protobuf.Any" + + "\0321\n\017PropertiesEntry\022\013\n\003key\030\001 \001(\t\022\r\n\005valu" + + "e\030\002 \001(\t:\0028\001\"S\n\rPExchangeRate\022\014\n\004date\030\001 \001" + + "(\003\0224\n\005value\030\002 \001(\0132%.name.abuchen.portfol" + + "io.PDecimalValue\"\203\001\n\027PExchangeRateTimeSe" + + "ries\022\024\n\014baseCurrency\030\001 \001(\t\022\024\n\014termCurren" + + "cy\030\002 \001(\t\022<\n\rexchangeRates\030\003 \003(\0132%.name.a" + + "buchen.portfolio.PExchangeRate\"a\n\010PECBDa" + + "ta\022\024\n\014lastModified\030\001 \001(\003\022?\n\006series\030\002 \003(\013" + + "2/.name.abuchen.portfolio.PExchangeRateT" + + "imeSeries*\301\004\n\025PLedgerProjectionRole\022&\n\"L" + + "EDGER_PROJECTION_ROLE_UNSPECIFIED\020\000\022\"\n\036L" + + "EDGER_PROJECTION_ROLE_ACCOUNT\020\001\022$\n LEDGE" + + "R_PROJECTION_ROLE_PORTFOLIO\020\002\022)\n%LEDGER_" + + "PROJECTION_ROLE_SOURCE_ACCOUNT\020\003\022)\n%LEDG" + + "ER_PROJECTION_ROLE_TARGET_ACCOUNT\020\004\022+\n\'L" + + "EDGER_PROJECTION_ROLE_SOURCE_PORTFOLIO\020\005" + + "\022+\n\'LEDGER_PROJECTION_ROLE_TARGET_PORTFO" + + "LIO\020\006\022#\n\037LEDGER_PROJECTION_ROLE_DELIVERY" + + "\020\007\022+\n\'LEDGER_PROJECTION_ROLE_DELIVERY_IN" + + "BOUND\020\010\022,\n(LEDGER_PROJECTION_ROLE_DELIVE" + + "RY_OUTBOUND\020\t\022,\n(LEDGER_PROJECTION_ROLE_" + + "CASH_COMPENSATION\020\n\022+\n\'LEDGER_PROJECTION" + + "_ROLE_OLD_SECURITY_LEG\020\013\022+\n\'LEDGER_PROJE" + + "CTION_ROLE_NEW_SECURITY_LEG\020\014*\204\003\n\037PLedge" + + "rProjectionMembershipRole\0221\n-LEDGER_PROJ" + + "ECTION_MEMBERSHIP_ROLE_UNSPECIFIED\020\000\022-\n)" + + "LEDGER_PROJECTION_MEMBERSHIP_ROLE_PRIMAR" + + "Y\020\001\0222\n.LEDGER_PROJECTION_MEMBERSHIP_ROLE" + + "_GROUP_ANCHOR\020\002\022.\n*LEDGER_PROJECTION_MEM" + + "BERSHIP_ROLE_FEE_UNIT\020\003\022.\n*LEDGER_PROJEC" + + "TION_MEMBERSHIP_ROLE_TAX_UNIT\020\004\0226\n2LEDGE" + + "R_PROJECTION_MEMBERSHIP_ROLE_GROSS_VALUE" + + "_UNIT\020\005\0223\n/LEDGER_PROJECTION_MEMBERSHIP_" + + "ROLE_FOREX_CONTEXT\020\006*\327\004\n\031PLedgerParamete" + + "rValueKind\022+\n\'LEDGER_PARAMETER_VALUE_KIN" + + "D_UNSPECIFIED\020\000\022&\n\"LEDGER_PARAMETER_VALU" + + "E_KIND_STRING\020\001\022\'\n#LEDGER_PARAMETER_VALU" + + "E_KIND_DECIMAL\020\002\022$\n LEDGER_PARAMETER_VAL" + + "UE_KIND_LONG\020\003\022%\n!LEDGER_PARAMETER_VALUE" + + "_KIND_MONEY\020\004\022(\n$LEDGER_PARAMETER_VALUE_" + + "KIND_SECURITY\020\005\022\'\n#LEDGER_PARAMETER_VALU" + + "E_KIND_ACCOUNT\020\006\022)\n%LEDGER_PARAMETER_VAL" + + "UE_KIND_PORTFOLIO\020\007\022/\n+LEDGER_PARAMETER_" + + "VALUE_KIND_LOCAL_DATE_TIME\020\n\022\'\n#LEDGER_P" + + "ARAMETER_VALUE_KIND_BOOLEAN\020\013\022*\n&LEDGER_" + + "PARAMETER_VALUE_KIND_LOCAL_DATE\020\014\"\004\010\010\020\010\"" + + "\004\010\t\020\t*0LEDGER_PARAMETER_VALUE_KIND_CORPO" + + "RATE_ACTION_LEG*-LEDGER_PARAMETER_VALUE_" + + "KIND_COMPENSATION_KINDB7\n%name.abuchen.p" + + "ortfolio.model.proto.v1B\014ClientProtosP\001b" + + "\006proto3" }; descriptor = com.google.protobuf.Descriptors.FileDescriptor .internalBuildGeneratedFileFrom(descriptorData, @@ -584,7 +591,7 @@ public static void registerAllExtensions( internal_static_name_abuchen_portfolio_PInvestmentPlan_fieldAccessorTable = new com.google.protobuf.GeneratedMessageV3.FieldAccessorTable( internal_static_name_abuchen_portfolio_PInvestmentPlan_descriptor, - new java.lang.String[] { "Name", "Note", "Security", "Portfolio", "Account", "Attributes", "AutoGenerate", "Date", "Interval", "Amount", "Fees", "Transactions", "Taxes", "Type", "LedgerExecutionRefs", "Note", "Security", "Portfolio", "Account", }); + new java.lang.String[] { "Name", "Note", "Security", "Portfolio", "Account", "Attributes", "AutoGenerate", "Date", "Interval", "Amount", "Fees", "Transactions", "Taxes", "Type", "LedgerExecutionRefs", "PlanKey", "Note", "Security", "Portfolio", "Account", "PlanKey", }); internal_static_name_abuchen_portfolio_PInvestmentPlanLedgerExecutionRef_descriptor = getDescriptor().getMessageTypes().get(15); internal_static_name_abuchen_portfolio_PInvestmentPlanLedgerExecutionRef_fieldAccessorTable = new @@ -602,7 +609,7 @@ public static void registerAllExtensions( internal_static_name_abuchen_portfolio_PLedgerEntry_fieldAccessorTable = new com.google.protobuf.GeneratedMessageV3.FieldAccessorTable( internal_static_name_abuchen_portfolio_PLedgerEntry_descriptor, - new java.lang.String[] { "Uuid", "DateTime", "Note", "Source", "UpdatedAt", "Postings", "ProjectionRefs", "TypeId", "Parameters", "Note", "Source", "TypeId", }); + new java.lang.String[] { "Uuid", "DateTime", "Note", "Source", "UpdatedAt", "Postings", "ProjectionRefs", "TypeId", "Parameters", "GeneratedByPlanKey", "PlanExecutionDate", "PlanExecutionSequence", "PreferredViewKind", "Note", "Source", "TypeId", "GeneratedByPlanKey", "PlanExecutionDate", "PlanExecutionSequence", "PreferredViewKind", }); internal_static_name_abuchen_portfolio_PLedgerPosting_descriptor = getDescriptor().getMessageTypes().get(18); internal_static_name_abuchen_portfolio_PLedgerPosting_fieldAccessorTable = new diff --git a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PInvestmentPlan.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PInvestmentPlan.java index e378c0f308..d241b54550 100644 --- a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PInvestmentPlan.java +++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PInvestmentPlan.java @@ -26,6 +26,7 @@ private PInvestmentPlan() { com.google.protobuf.LazyStringArrayList.emptyList(); type_ = 0; ledgerExecutionRefs_ = java.util.Collections.emptyList(); + planKey_ = ""; } @java.lang.Override @@ -621,6 +622,53 @@ public name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRefOr return ledgerExecutionRefs_.get(index); } + public static final int PLANKEY_FIELD_NUMBER = 16; + @SuppressWarnings("serial") + private volatile java.lang.Object planKey_ = ""; + /** + * optional string planKey = 16; + * @return Whether the planKey field is set. + */ + @java.lang.Override + public boolean hasPlanKey() { + return ((bitField0_ & 0x00000010) != 0); + } + /** + * optional string planKey = 16; + * @return The planKey. + */ + @java.lang.Override + public java.lang.String getPlanKey() { + java.lang.Object ref = planKey_; + if (ref instanceof java.lang.String) { + return (java.lang.String) ref; + } else { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + planKey_ = s; + return s; + } + } + /** + * optional string planKey = 16; + * @return The bytes for planKey. + */ + @java.lang.Override + public com.google.protobuf.ByteString + getPlanKeyBytes() { + java.lang.Object ref = planKey_; + if (ref instanceof java.lang.String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + planKey_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + private byte memoizedIsInitialized = -1; @java.lang.Override public final boolean isInitialized() { @@ -680,6 +728,9 @@ public void writeTo(com.google.protobuf.CodedOutputStream output) for (int i = 0; i < ledgerExecutionRefs_.size(); i++) { output.writeMessage(15, ledgerExecutionRefs_.get(i)); } + if (((bitField0_ & 0x00000010) != 0)) { + com.google.protobuf.GeneratedMessageV3.writeString(output, 16, planKey_); + } getUnknownFields().writeTo(output); } @@ -748,6 +799,9 @@ public int getSerializedSize() { size += com.google.protobuf.CodedOutputStream .computeMessageSize(15, ledgerExecutionRefs_.get(i)); } + if (((bitField0_ & 0x00000010) != 0)) { + size += com.google.protobuf.GeneratedMessageV3.computeStringSize(16, planKey_); + } size += getUnknownFields().getSerializedSize(); memoizedSize = size; return size; @@ -804,6 +858,11 @@ public boolean equals(final java.lang.Object obj) { if (type_ != other.type_) return false; if (!getLedgerExecutionRefsList() .equals(other.getLedgerExecutionRefsList())) return false; + if (hasPlanKey() != other.hasPlanKey()) return false; + if (hasPlanKey()) { + if (!getPlanKey() + .equals(other.getPlanKey())) return false; + } if (!getUnknownFields().equals(other.getUnknownFields())) return false; return true; } @@ -864,6 +923,10 @@ public int hashCode() { hash = (37 * hash) + LEDGEREXECUTIONREFS_FIELD_NUMBER; hash = (53 * hash) + getLedgerExecutionRefsList().hashCode(); } + if (hasPlanKey()) { + hash = (37 * hash) + PLANKEY_FIELD_NUMBER; + hash = (53 * hash) + getPlanKey().hashCode(); + } hash = (29 * hash) + getUnknownFields().hashCode(); memoizedHashCode = hash; return hash; @@ -1021,6 +1084,7 @@ public Builder clear() { ledgerExecutionRefsBuilder_.clear(); } bitField0_ = (bitField0_ & ~0x00004000); + planKey_ = ""; return this; } @@ -1121,6 +1185,10 @@ private void buildPartial0(name.abuchen.portfolio.model.proto.v1.PInvestmentPlan if (((from_bitField0_ & 0x00002000) != 0)) { result.type_ = type_; } + if (((from_bitField0_ & 0x00008000) != 0)) { + result.planKey_ = planKey_; + to_bitField0_ |= 0x00000010; + } result.bitField0_ |= to_bitField0_; } @@ -1244,6 +1312,11 @@ public Builder mergeFrom(name.abuchen.portfolio.model.proto.v1.PInvestmentPlan o } } } + if (other.hasPlanKey()) { + planKey_ = other.planKey_; + bitField0_ |= 0x00008000; + onChanged(); + } this.mergeUnknownFields(other.getUnknownFields()); onChanged(); return this; @@ -1362,6 +1435,11 @@ public Builder mergeFrom( } break; } // case 122 + case 130: { + planKey_ = input.readStringRequireUtf8(); + bitField0_ |= 0x00008000; + break; + } // case 130 default: { if (!super.parseUnknownField(input, extensionRegistry, tag)) { done = true; // was an endgroup tag @@ -2638,6 +2716,85 @@ public name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef.B } return ledgerExecutionRefsBuilder_; } + + private java.lang.Object planKey_ = ""; + /** + * optional string planKey = 16; + * @return Whether the planKey field is set. + */ + public boolean hasPlanKey() { + return ((bitField0_ & 0x00008000) != 0); + } + /** + * optional string planKey = 16; + * @return The planKey. + */ + public java.lang.String getPlanKey() { + java.lang.Object ref = planKey_; + if (!(ref instanceof java.lang.String)) { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + planKey_ = s; + return s; + } else { + return (java.lang.String) ref; + } + } + /** + * optional string planKey = 16; + * @return The bytes for planKey. + */ + public com.google.protobuf.ByteString + getPlanKeyBytes() { + java.lang.Object ref = planKey_; + if (ref instanceof String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + planKey_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + /** + * optional string planKey = 16; + * @param value The planKey to set. + * @return This builder for chaining. + */ + public Builder setPlanKey( + java.lang.String value) { + if (value == null) { throw new NullPointerException(); } + planKey_ = value; + bitField0_ |= 0x00008000; + onChanged(); + return this; + } + /** + * optional string planKey = 16; + * @return This builder for chaining. + */ + public Builder clearPlanKey() { + planKey_ = getDefaultInstance().getPlanKey(); + bitField0_ = (bitField0_ & ~0x00008000); + onChanged(); + return this; + } + /** + * optional string planKey = 16; + * @param value The bytes for planKey to set. + * @return This builder for chaining. + */ + public Builder setPlanKeyBytes( + com.google.protobuf.ByteString value) { + if (value == null) { throw new NullPointerException(); } + checkByteStringIsUtf8(value); + planKey_ = value; + bitField0_ |= 0x00008000; + onChanged(); + return this; + } @java.lang.Override public final Builder setUnknownFields( final com.google.protobuf.UnknownFieldSet unknownFields) { diff --git a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PInvestmentPlanOrBuilder.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PInvestmentPlanOrBuilder.java index 1faace4133..fbb994956f 100644 --- a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PInvestmentPlanOrBuilder.java +++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PInvestmentPlanOrBuilder.java @@ -222,4 +222,21 @@ name.abuchen.portfolio.model.proto.v1.PKeyValueOrBuilder getAttributesOrBuilder( */ name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRefOrBuilder getLedgerExecutionRefsOrBuilder( int index); + + /** + * optional string planKey = 16; + * @return Whether the planKey field is set. + */ + boolean hasPlanKey(); + /** + * optional string planKey = 16; + * @return The planKey. + */ + java.lang.String getPlanKey(); + /** + * optional string planKey = 16; + * @return The bytes for planKey. + */ + com.google.protobuf.ByteString + getPlanKeyBytes(); } diff --git a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerEntry.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerEntry.java index 4a53d4e904..f2fa711500 100644 --- a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerEntry.java +++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerEntry.java @@ -22,6 +22,8 @@ private PLedgerEntry() { postings_ = java.util.Collections.emptyList(); projectionRefs_ = java.util.Collections.emptyList(); parameters_ = java.util.Collections.emptyList(); + generatedByPlanKey_ = ""; + preferredViewKind_ = ""; } @java.lang.Override @@ -58,7 +60,7 @@ public java.lang.String getUuid() { if (ref instanceof java.lang.String) { return (java.lang.String) ref; } else { - com.google.protobuf.ByteString bs = + com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; java.lang.String s = bs.toStringUtf8(); uuid_ = s; @@ -74,7 +76,7 @@ public java.lang.String getUuid() { getUuidBytes() { java.lang.Object ref = uuid_; if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); uuid_ = b; @@ -131,7 +133,7 @@ public java.lang.String getNote() { if (ref instanceof java.lang.String) { return (java.lang.String) ref; } else { - com.google.protobuf.ByteString bs = + com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; java.lang.String s = bs.toStringUtf8(); note_ = s; @@ -147,7 +149,7 @@ public java.lang.String getNote() { getNoteBytes() { java.lang.Object ref = note_; if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); note_ = b; @@ -178,7 +180,7 @@ public java.lang.String getSource() { if (ref instanceof java.lang.String) { return (java.lang.String) ref; } else { - com.google.protobuf.ByteString bs = + com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; java.lang.String s = bs.toStringUtf8(); source_ = s; @@ -194,7 +196,7 @@ public java.lang.String getSource() { getSourceBytes() { java.lang.Object ref = source_; if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); source_ = b; @@ -244,7 +246,7 @@ public java.util.List getP * repeated .name.abuchen.portfolio.PLedgerPosting postings = 7; */ @java.lang.Override - public java.util.List + public java.util.List getPostingsOrBuilderList() { return postings_; } @@ -285,7 +287,7 @@ public java.util.Listrepeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8; */ @java.lang.Override - public java.util.List + public java.util.List getProjectionRefsOrBuilderList() { return projectionRefs_; } @@ -345,7 +347,7 @@ public java.util.List ge * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 10; */ @java.lang.Override - public java.util.List + public java.util.List getParametersOrBuilderList() { return parameters_; } @@ -372,6 +374,138 @@ public name.abuchen.portfolio.model.proto.v1.PLedgerParameterOrBuilder getParame return parameters_.get(index); } + public static final int GENERATEDBYPLANKEY_FIELD_NUMBER = 11; + @SuppressWarnings("serial") + private volatile java.lang.Object generatedByPlanKey_ = ""; + /** + * optional string generatedByPlanKey = 11; + * @return Whether the generatedByPlanKey field is set. + */ + @java.lang.Override + public boolean hasGeneratedByPlanKey() { + return ((bitField0_ & 0x00000008) != 0); + } + /** + * optional string generatedByPlanKey = 11; + * @return The generatedByPlanKey. + */ + @java.lang.Override + public java.lang.String getGeneratedByPlanKey() { + java.lang.Object ref = generatedByPlanKey_; + if (ref instanceof java.lang.String) { + return (java.lang.String) ref; + } else { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + generatedByPlanKey_ = s; + return s; + } + } + /** + * optional string generatedByPlanKey = 11; + * @return The bytes for generatedByPlanKey. + */ + @java.lang.Override + public com.google.protobuf.ByteString + getGeneratedByPlanKeyBytes() { + java.lang.Object ref = generatedByPlanKey_; + if (ref instanceof java.lang.String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + generatedByPlanKey_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + + public static final int PLANEXECUTIONDATE_FIELD_NUMBER = 12; + private long planExecutionDate_ = 0L; + /** + * optional int64 planExecutionDate = 12; + * @return Whether the planExecutionDate field is set. + */ + @java.lang.Override + public boolean hasPlanExecutionDate() { + return ((bitField0_ & 0x00000010) != 0); + } + /** + * optional int64 planExecutionDate = 12; + * @return The planExecutionDate. + */ + @java.lang.Override + public long getPlanExecutionDate() { + return planExecutionDate_; + } + + public static final int PLANEXECUTIONSEQUENCE_FIELD_NUMBER = 13; + private int planExecutionSequence_ = 0; + /** + * optional int32 planExecutionSequence = 13; + * @return Whether the planExecutionSequence field is set. + */ + @java.lang.Override + public boolean hasPlanExecutionSequence() { + return ((bitField0_ & 0x00000020) != 0); + } + /** + * optional int32 planExecutionSequence = 13; + * @return The planExecutionSequence. + */ + @java.lang.Override + public int getPlanExecutionSequence() { + return planExecutionSequence_; + } + + public static final int PREFERREDVIEWKIND_FIELD_NUMBER = 14; + @SuppressWarnings("serial") + private volatile java.lang.Object preferredViewKind_ = ""; + /** + * optional string preferredViewKind = 14; + * @return Whether the preferredViewKind field is set. + */ + @java.lang.Override + public boolean hasPreferredViewKind() { + return ((bitField0_ & 0x00000040) != 0); + } + /** + * optional string preferredViewKind = 14; + * @return The preferredViewKind. + */ + @java.lang.Override + public java.lang.String getPreferredViewKind() { + java.lang.Object ref = preferredViewKind_; + if (ref instanceof java.lang.String) { + return (java.lang.String) ref; + } else { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + preferredViewKind_ = s; + return s; + } + } + /** + * optional string preferredViewKind = 14; + * @return The bytes for preferredViewKind. + */ + @java.lang.Override + public com.google.protobuf.ByteString + getPreferredViewKindBytes() { + java.lang.Object ref = preferredViewKind_; + if (ref instanceof java.lang.String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + preferredViewKind_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + private byte memoizedIsInitialized = -1; @java.lang.Override public final boolean isInitialized() { @@ -413,6 +547,18 @@ public void writeTo(com.google.protobuf.CodedOutputStream output) for (int i = 0; i < parameters_.size(); i++) { output.writeMessage(10, parameters_.get(i)); } + if (((bitField0_ & 0x00000008) != 0)) { + com.google.protobuf.GeneratedMessageV3.writeString(output, 11, generatedByPlanKey_); + } + if (((bitField0_ & 0x00000010) != 0)) { + output.writeInt64(12, planExecutionDate_); + } + if (((bitField0_ & 0x00000020) != 0)) { + output.writeInt32(13, planExecutionSequence_); + } + if (((bitField0_ & 0x00000040) != 0)) { + com.google.protobuf.GeneratedMessageV3.writeString(output, 14, preferredViewKind_); + } getUnknownFields().writeTo(output); } @@ -455,6 +601,20 @@ public int getSerializedSize() { size += com.google.protobuf.CodedOutputStream .computeMessageSize(10, parameters_.get(i)); } + if (((bitField0_ & 0x00000008) != 0)) { + size += com.google.protobuf.GeneratedMessageV3.computeStringSize(11, generatedByPlanKey_); + } + if (((bitField0_ & 0x00000010) != 0)) { + size += com.google.protobuf.CodedOutputStream + .computeInt64Size(12, planExecutionDate_); + } + if (((bitField0_ & 0x00000020) != 0)) { + size += com.google.protobuf.CodedOutputStream + .computeInt32Size(13, planExecutionSequence_); + } + if (((bitField0_ & 0x00000040) != 0)) { + size += com.google.protobuf.GeneratedMessageV3.computeStringSize(14, preferredViewKind_); + } size += getUnknownFields().getSerializedSize(); memoizedSize = size; return size; @@ -503,6 +663,26 @@ public boolean equals(final java.lang.Object obj) { } if (!getParametersList() .equals(other.getParametersList())) return false; + if (hasGeneratedByPlanKey() != other.hasGeneratedByPlanKey()) return false; + if (hasGeneratedByPlanKey()) { + if (!getGeneratedByPlanKey() + .equals(other.getGeneratedByPlanKey())) return false; + } + if (hasPlanExecutionDate() != other.hasPlanExecutionDate()) return false; + if (hasPlanExecutionDate()) { + if (getPlanExecutionDate() + != other.getPlanExecutionDate()) return false; + } + if (hasPlanExecutionSequence() != other.hasPlanExecutionSequence()) return false; + if (hasPlanExecutionSequence()) { + if (getPlanExecutionSequence() + != other.getPlanExecutionSequence()) return false; + } + if (hasPreferredViewKind() != other.hasPreferredViewKind()) return false; + if (hasPreferredViewKind()) { + if (!getPreferredViewKind() + .equals(other.getPreferredViewKind())) return false; + } if (!getUnknownFields().equals(other.getUnknownFields())) return false; return true; } @@ -548,6 +728,23 @@ public int hashCode() { hash = (37 * hash) + PARAMETERS_FIELD_NUMBER; hash = (53 * hash) + getParametersList().hashCode(); } + if (hasGeneratedByPlanKey()) { + hash = (37 * hash) + GENERATEDBYPLANKEY_FIELD_NUMBER; + hash = (53 * hash) + getGeneratedByPlanKey().hashCode(); + } + if (hasPlanExecutionDate()) { + hash = (37 * hash) + PLANEXECUTIONDATE_FIELD_NUMBER; + hash = (53 * hash) + com.google.protobuf.Internal.hashLong( + getPlanExecutionDate()); + } + if (hasPlanExecutionSequence()) { + hash = (37 * hash) + PLANEXECUTIONSEQUENCE_FIELD_NUMBER; + hash = (53 * hash) + getPlanExecutionSequence(); + } + if (hasPreferredViewKind()) { + hash = (37 * hash) + PREFERREDVIEWKIND_FIELD_NUMBER; + hash = (53 * hash) + getPreferredViewKind().hashCode(); + } hash = (29 * hash) + getUnknownFields().hashCode(); memoizedHashCode = hash; return hash; @@ -712,6 +909,10 @@ public Builder clear() { parametersBuilder_.clear(); } bitField0_ = (bitField0_ & ~0x00000100); + generatedByPlanKey_ = ""; + planExecutionDate_ = 0L; + planExecutionSequence_ = 0; + preferredViewKind_ = ""; return this; } @@ -802,6 +1003,22 @@ private void buildPartial0(name.abuchen.portfolio.model.proto.v1.PLedgerEntry re result.typeId_ = typeId_; to_bitField0_ |= 0x00000004; } + if (((from_bitField0_ & 0x00000200) != 0)) { + result.generatedByPlanKey_ = generatedByPlanKey_; + to_bitField0_ |= 0x00000008; + } + if (((from_bitField0_ & 0x00000400) != 0)) { + result.planExecutionDate_ = planExecutionDate_; + to_bitField0_ |= 0x00000010; + } + if (((from_bitField0_ & 0x00000800) != 0)) { + result.planExecutionSequence_ = planExecutionSequence_; + to_bitField0_ |= 0x00000020; + } + if (((from_bitField0_ & 0x00001000) != 0)) { + result.preferredViewKind_ = preferredViewKind_; + to_bitField0_ |= 0x00000040; + } result.bitField0_ |= to_bitField0_; } @@ -856,7 +1073,7 @@ public Builder mergeFrom(name.abuchen.portfolio.model.proto.v1.PLedgerEntry othe postingsBuilder_ = null; postings_ = other.postings_; bitField0_ = (bitField0_ & ~0x00000020); - postingsBuilder_ = + postingsBuilder_ = com.google.protobuf.GeneratedMessageV3.alwaysUseFieldBuilders ? getPostingsFieldBuilder() : null; } else { @@ -882,7 +1099,7 @@ public Builder mergeFrom(name.abuchen.portfolio.model.proto.v1.PLedgerEntry othe projectionRefsBuilder_ = null; projectionRefs_ = other.projectionRefs_; bitField0_ = (bitField0_ & ~0x00000040); - projectionRefsBuilder_ = + projectionRefsBuilder_ = com.google.protobuf.GeneratedMessageV3.alwaysUseFieldBuilders ? getProjectionRefsFieldBuilder() : null; } else { @@ -911,7 +1128,7 @@ public Builder mergeFrom(name.abuchen.portfolio.model.proto.v1.PLedgerEntry othe parametersBuilder_ = null; parameters_ = other.parameters_; bitField0_ = (bitField0_ & ~0x00000100); - parametersBuilder_ = + parametersBuilder_ = com.google.protobuf.GeneratedMessageV3.alwaysUseFieldBuilders ? getParametersFieldBuilder() : null; } else { @@ -919,6 +1136,22 @@ public Builder mergeFrom(name.abuchen.portfolio.model.proto.v1.PLedgerEntry othe } } } + if (other.hasGeneratedByPlanKey()) { + generatedByPlanKey_ = other.generatedByPlanKey_; + bitField0_ |= 0x00000200; + onChanged(); + } + if (other.hasPlanExecutionDate()) { + setPlanExecutionDate(other.getPlanExecutionDate()); + } + if (other.hasPlanExecutionSequence()) { + setPlanExecutionSequence(other.getPlanExecutionSequence()); + } + if (other.hasPreferredViewKind()) { + preferredViewKind_ = other.preferredViewKind_; + bitField0_ |= 0x00001000; + onChanged(); + } this.mergeUnknownFields(other.getUnknownFields()); onChanged(); return this; @@ -1018,6 +1251,26 @@ public Builder mergeFrom( } break; } // case 82 + case 90: { + generatedByPlanKey_ = input.readStringRequireUtf8(); + bitField0_ |= 0x00000200; + break; + } // case 90 + case 96: { + planExecutionDate_ = input.readInt64(); + bitField0_ |= 0x00000400; + break; + } // case 96 + case 104: { + planExecutionSequence_ = input.readInt32(); + bitField0_ |= 0x00000800; + break; + } // case 104 + case 114: { + preferredViewKind_ = input.readStringRequireUtf8(); + bitField0_ |= 0x00001000; + break; + } // case 114 default: { if (!super.parseUnknownField(input, extensionRegistry, tag)) { done = true; // was an endgroup tag @@ -1060,7 +1313,7 @@ public java.lang.String getUuid() { getUuidBytes() { java.lang.Object ref = uuid_; if (ref instanceof String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); uuid_ = b; @@ -1213,7 +1466,7 @@ public com.google.protobuf.TimestampOrBuilder getDateTimeOrBuilder() { * .google.protobuf.Timestamp dateTime = 3; */ private com.google.protobuf.SingleFieldBuilderV3< - com.google.protobuf.Timestamp, com.google.protobuf.Timestamp.Builder, com.google.protobuf.TimestampOrBuilder> + com.google.protobuf.Timestamp, com.google.protobuf.Timestamp.Builder, com.google.protobuf.TimestampOrBuilder> getDateTimeFieldBuilder() { if (dateTimeBuilder_ == null) { dateTimeBuilder_ = new com.google.protobuf.SingleFieldBuilderV3< @@ -1258,7 +1511,7 @@ public java.lang.String getNote() { getNoteBytes() { java.lang.Object ref = note_; if (ref instanceof String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); note_ = b; @@ -1337,7 +1590,7 @@ public java.lang.String getSource() { getSourceBytes() { java.lang.Object ref = source_; if (ref instanceof String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); source_ = b; @@ -1490,7 +1743,7 @@ public com.google.protobuf.TimestampOrBuilder getUpdatedAtOrBuilder() { * .google.protobuf.Timestamp updatedAt = 6; */ private com.google.protobuf.SingleFieldBuilderV3< - com.google.protobuf.Timestamp, com.google.protobuf.Timestamp.Builder, com.google.protobuf.TimestampOrBuilder> + com.google.protobuf.Timestamp, com.google.protobuf.Timestamp.Builder, com.google.protobuf.TimestampOrBuilder> getUpdatedAtFieldBuilder() { if (updatedAtBuilder_ == null) { updatedAtBuilder_ = new com.google.protobuf.SingleFieldBuilderV3< @@ -1698,7 +1951,7 @@ public name.abuchen.portfolio.model.proto.v1.PLedgerPostingOrBuilder getPostings /** * repeated .name.abuchen.portfolio.PLedgerPosting postings = 7; */ - public java.util.List + public java.util.List getPostingsOrBuilderList() { if (postingsBuilder_ != null) { return postingsBuilder_.getMessageOrBuilderList(); @@ -1724,12 +1977,12 @@ public name.abuchen.portfolio.model.proto.v1.PLedgerPosting.Builder addPostingsB /** * repeated .name.abuchen.portfolio.PLedgerPosting postings = 7; */ - public java.util.List + public java.util.List getPostingsBuilderList() { return getPostingsFieldBuilder().getBuilderList(); } private com.google.protobuf.RepeatedFieldBuilderV3< - name.abuchen.portfolio.model.proto.v1.PLedgerPosting, name.abuchen.portfolio.model.proto.v1.PLedgerPosting.Builder, name.abuchen.portfolio.model.proto.v1.PLedgerPostingOrBuilder> + name.abuchen.portfolio.model.proto.v1.PLedgerPosting, name.abuchen.portfolio.model.proto.v1.PLedgerPosting.Builder, name.abuchen.portfolio.model.proto.v1.PLedgerPostingOrBuilder> getPostingsFieldBuilder() { if (postingsBuilder_ == null) { postingsBuilder_ = new com.google.protobuf.RepeatedFieldBuilderV3< @@ -1938,7 +2191,7 @@ public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRefOrBuilder getPr /** * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8; */ - public java.util.List + public java.util.List getProjectionRefsOrBuilderList() { if (projectionRefsBuilder_ != null) { return projectionRefsBuilder_.getMessageOrBuilderList(); @@ -1964,12 +2217,12 @@ public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef.Builder addPro /** * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8; */ - public java.util.List + public java.util.List getProjectionRefsBuilderList() { return getProjectionRefsFieldBuilder().getBuilderList(); } private com.google.protobuf.RepeatedFieldBuilderV3< - name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef.Builder, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRefOrBuilder> + name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef.Builder, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRefOrBuilder> getProjectionRefsFieldBuilder() { if (projectionRefsBuilder_ == null) { projectionRefsBuilder_ = new com.google.protobuf.RepeatedFieldBuilderV3< @@ -2218,7 +2471,7 @@ public name.abuchen.portfolio.model.proto.v1.PLedgerParameterOrBuilder getParame /** * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 10; */ - public java.util.List + public java.util.List getParametersOrBuilderList() { if (parametersBuilder_ != null) { return parametersBuilder_.getMessageOrBuilderList(); @@ -2244,12 +2497,12 @@ public name.abuchen.portfolio.model.proto.v1.PLedgerParameter.Builder addParamet /** * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 10; */ - public java.util.List + public java.util.List getParametersBuilderList() { return getParametersFieldBuilder().getBuilderList(); } private com.google.protobuf.RepeatedFieldBuilderV3< - name.abuchen.portfolio.model.proto.v1.PLedgerParameter, name.abuchen.portfolio.model.proto.v1.PLedgerParameter.Builder, name.abuchen.portfolio.model.proto.v1.PLedgerParameterOrBuilder> + name.abuchen.portfolio.model.proto.v1.PLedgerParameter, name.abuchen.portfolio.model.proto.v1.PLedgerParameter.Builder, name.abuchen.portfolio.model.proto.v1.PLedgerParameterOrBuilder> getParametersFieldBuilder() { if (parametersBuilder_ == null) { parametersBuilder_ = new com.google.protobuf.RepeatedFieldBuilderV3< @@ -2262,6 +2515,244 @@ public name.abuchen.portfolio.model.proto.v1.PLedgerParameter.Builder addParamet } return parametersBuilder_; } + + private java.lang.Object generatedByPlanKey_ = ""; + /** + * optional string generatedByPlanKey = 11; + * @return Whether the generatedByPlanKey field is set. + */ + public boolean hasGeneratedByPlanKey() { + return ((bitField0_ & 0x00000200) != 0); + } + /** + * optional string generatedByPlanKey = 11; + * @return The generatedByPlanKey. + */ + public java.lang.String getGeneratedByPlanKey() { + java.lang.Object ref = generatedByPlanKey_; + if (!(ref instanceof java.lang.String)) { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + generatedByPlanKey_ = s; + return s; + } else { + return (java.lang.String) ref; + } + } + /** + * optional string generatedByPlanKey = 11; + * @return The bytes for generatedByPlanKey. + */ + public com.google.protobuf.ByteString + getGeneratedByPlanKeyBytes() { + java.lang.Object ref = generatedByPlanKey_; + if (ref instanceof String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + generatedByPlanKey_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + /** + * optional string generatedByPlanKey = 11; + * @param value The generatedByPlanKey to set. + * @return This builder for chaining. + */ + public Builder setGeneratedByPlanKey( + java.lang.String value) { + if (value == null) { throw new NullPointerException(); } + generatedByPlanKey_ = value; + bitField0_ |= 0x00000200; + onChanged(); + return this; + } + /** + * optional string generatedByPlanKey = 11; + * @return This builder for chaining. + */ + public Builder clearGeneratedByPlanKey() { + generatedByPlanKey_ = getDefaultInstance().getGeneratedByPlanKey(); + bitField0_ = (bitField0_ & ~0x00000200); + onChanged(); + return this; + } + /** + * optional string generatedByPlanKey = 11; + * @param value The bytes for generatedByPlanKey to set. + * @return This builder for chaining. + */ + public Builder setGeneratedByPlanKeyBytes( + com.google.protobuf.ByteString value) { + if (value == null) { throw new NullPointerException(); } + checkByteStringIsUtf8(value); + generatedByPlanKey_ = value; + bitField0_ |= 0x00000200; + onChanged(); + return this; + } + + private long planExecutionDate_ ; + /** + * optional int64 planExecutionDate = 12; + * @return Whether the planExecutionDate field is set. + */ + @java.lang.Override + public boolean hasPlanExecutionDate() { + return ((bitField0_ & 0x00000400) != 0); + } + /** + * optional int64 planExecutionDate = 12; + * @return The planExecutionDate. + */ + @java.lang.Override + public long getPlanExecutionDate() { + return planExecutionDate_; + } + /** + * optional int64 planExecutionDate = 12; + * @param value The planExecutionDate to set. + * @return This builder for chaining. + */ + public Builder setPlanExecutionDate(long value) { + + planExecutionDate_ = value; + bitField0_ |= 0x00000400; + onChanged(); + return this; + } + /** + * optional int64 planExecutionDate = 12; + * @return This builder for chaining. + */ + public Builder clearPlanExecutionDate() { + bitField0_ = (bitField0_ & ~0x00000400); + planExecutionDate_ = 0L; + onChanged(); + return this; + } + + private int planExecutionSequence_ ; + /** + * optional int32 planExecutionSequence = 13; + * @return Whether the planExecutionSequence field is set. + */ + @java.lang.Override + public boolean hasPlanExecutionSequence() { + return ((bitField0_ & 0x00000800) != 0); + } + /** + * optional int32 planExecutionSequence = 13; + * @return The planExecutionSequence. + */ + @java.lang.Override + public int getPlanExecutionSequence() { + return planExecutionSequence_; + } + /** + * optional int32 planExecutionSequence = 13; + * @param value The planExecutionSequence to set. + * @return This builder for chaining. + */ + public Builder setPlanExecutionSequence(int value) { + + planExecutionSequence_ = value; + bitField0_ |= 0x00000800; + onChanged(); + return this; + } + /** + * optional int32 planExecutionSequence = 13; + * @return This builder for chaining. + */ + public Builder clearPlanExecutionSequence() { + bitField0_ = (bitField0_ & ~0x00000800); + planExecutionSequence_ = 0; + onChanged(); + return this; + } + + private java.lang.Object preferredViewKind_ = ""; + /** + * optional string preferredViewKind = 14; + * @return Whether the preferredViewKind field is set. + */ + public boolean hasPreferredViewKind() { + return ((bitField0_ & 0x00001000) != 0); + } + /** + * optional string preferredViewKind = 14; + * @return The preferredViewKind. + */ + public java.lang.String getPreferredViewKind() { + java.lang.Object ref = preferredViewKind_; + if (!(ref instanceof java.lang.String)) { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + preferredViewKind_ = s; + return s; + } else { + return (java.lang.String) ref; + } + } + /** + * optional string preferredViewKind = 14; + * @return The bytes for preferredViewKind. + */ + public com.google.protobuf.ByteString + getPreferredViewKindBytes() { + java.lang.Object ref = preferredViewKind_; + if (ref instanceof String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + preferredViewKind_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + /** + * optional string preferredViewKind = 14; + * @param value The preferredViewKind to set. + * @return This builder for chaining. + */ + public Builder setPreferredViewKind( + java.lang.String value) { + if (value == null) { throw new NullPointerException(); } + preferredViewKind_ = value; + bitField0_ |= 0x00001000; + onChanged(); + return this; + } + /** + * optional string preferredViewKind = 14; + * @return This builder for chaining. + */ + public Builder clearPreferredViewKind() { + preferredViewKind_ = getDefaultInstance().getPreferredViewKind(); + bitField0_ = (bitField0_ & ~0x00001000); + onChanged(); + return this; + } + /** + * optional string preferredViewKind = 14; + * @param value The bytes for preferredViewKind to set. + * @return This builder for chaining. + */ + public Builder setPreferredViewKindBytes( + com.google.protobuf.ByteString value) { + if (value == null) { throw new NullPointerException(); } + checkByteStringIsUtf8(value); + preferredViewKind_ = value; + bitField0_ |= 0x00001000; + onChanged(); + return this; + } @java.lang.Override public final Builder setUnknownFields( final com.google.protobuf.UnknownFieldSet unknownFields) { @@ -2325,3 +2816,4 @@ public name.abuchen.portfolio.model.proto.v1.PLedgerEntry getDefaultInstanceForT } } + diff --git a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerEntryOrBuilder.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerEntryOrBuilder.java index f2b95c856d..03ac47f19f 100644 --- a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerEntryOrBuilder.java +++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerEntryOrBuilder.java @@ -86,7 +86,7 @@ public interface PLedgerEntryOrBuilder extends /** * repeated .name.abuchen.portfolio.PLedgerPosting postings = 7; */ - java.util.List + java.util.List getPostingsList(); /** * repeated .name.abuchen.portfolio.PLedgerPosting postings = 7; @@ -99,7 +99,7 @@ public interface PLedgerEntryOrBuilder extends /** * repeated .name.abuchen.portfolio.PLedgerPosting postings = 7; */ - java.util.List + java.util.List getPostingsOrBuilderList(); /** * repeated .name.abuchen.portfolio.PLedgerPosting postings = 7; @@ -110,7 +110,7 @@ name.abuchen.portfolio.model.proto.v1.PLedgerPostingOrBuilder getPostingsOrBuild /** * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8; */ - java.util.List + java.util.List getProjectionRefsList(); /** * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8; @@ -123,7 +123,7 @@ name.abuchen.portfolio.model.proto.v1.PLedgerPostingOrBuilder getPostingsOrBuild /** * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8; */ - java.util.List + java.util.List getProjectionRefsOrBuilderList(); /** * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8; @@ -145,7 +145,7 @@ name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRefOrBuilder getProjectio /** * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 10; */ - java.util.List + java.util.List getParametersList(); /** * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 10; @@ -158,11 +158,67 @@ name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRefOrBuilder getProjectio /** * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 10; */ - java.util.List + java.util.List getParametersOrBuilderList(); /** * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 10; */ name.abuchen.portfolio.model.proto.v1.PLedgerParameterOrBuilder getParametersOrBuilder( int index); + + /** + * optional string generatedByPlanKey = 11; + * @return Whether the generatedByPlanKey field is set. + */ + boolean hasGeneratedByPlanKey(); + /** + * optional string generatedByPlanKey = 11; + * @return The generatedByPlanKey. + */ + java.lang.String getGeneratedByPlanKey(); + /** + * optional string generatedByPlanKey = 11; + * @return The bytes for generatedByPlanKey. + */ + com.google.protobuf.ByteString + getGeneratedByPlanKeyBytes(); + + /** + * optional int64 planExecutionDate = 12; + * @return Whether the planExecutionDate field is set. + */ + boolean hasPlanExecutionDate(); + /** + * optional int64 planExecutionDate = 12; + * @return The planExecutionDate. + */ + long getPlanExecutionDate(); + + /** + * optional int32 planExecutionSequence = 13; + * @return Whether the planExecutionSequence field is set. + */ + boolean hasPlanExecutionSequence(); + /** + * optional int32 planExecutionSequence = 13; + * @return The planExecutionSequence. + */ + int getPlanExecutionSequence(); + + /** + * optional string preferredViewKind = 14; + * @return Whether the preferredViewKind field is set. + */ + boolean hasPreferredViewKind(); + /** + * optional string preferredViewKind = 14; + * @return The preferredViewKind. + */ + java.lang.String getPreferredViewKind(); + /** + * optional string preferredViewKind = 14; + * @return The bytes for preferredViewKind. + */ + com.google.protobuf.ByteString + getPreferredViewKindBytes(); } diff --git a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerParameter.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerParameter.java index 610e66baac..d3a4c3ec3d 100644 --- a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerParameter.java +++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerParameter.java @@ -85,7 +85,7 @@ public java.lang.String getStringValue() { if (ref instanceof java.lang.String) { return (java.lang.String) ref; } else { - com.google.protobuf.ByteString bs = + com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; java.lang.String s = bs.toStringUtf8(); stringValue_ = s; @@ -101,7 +101,7 @@ public java.lang.String getStringValue() { getStringValueBytes() { java.lang.Object ref = stringValue_; if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); stringValue_ = b; @@ -196,7 +196,7 @@ public java.lang.String getMoneyCurrency() { if (ref instanceof java.lang.String) { return (java.lang.String) ref; } else { - com.google.protobuf.ByteString bs = + com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; java.lang.String s = bs.toStringUtf8(); moneyCurrency_ = s; @@ -212,7 +212,7 @@ public java.lang.String getMoneyCurrency() { getMoneyCurrencyBytes() { java.lang.Object ref = moneyCurrency_; if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); moneyCurrency_ = b; @@ -243,7 +243,7 @@ public java.lang.String getSecurity() { if (ref instanceof java.lang.String) { return (java.lang.String) ref; } else { - com.google.protobuf.ByteString bs = + com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; java.lang.String s = bs.toStringUtf8(); security_ = s; @@ -259,7 +259,7 @@ public java.lang.String getSecurity() { getSecurityBytes() { java.lang.Object ref = security_; if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); security_ = b; @@ -290,7 +290,7 @@ public java.lang.String getAccount() { if (ref instanceof java.lang.String) { return (java.lang.String) ref; } else { - com.google.protobuf.ByteString bs = + com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; java.lang.String s = bs.toStringUtf8(); account_ = s; @@ -306,7 +306,7 @@ public java.lang.String getAccount() { getAccountBytes() { java.lang.Object ref = account_; if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); account_ = b; @@ -337,7 +337,7 @@ public java.lang.String getPortfolio() { if (ref instanceof java.lang.String) { return (java.lang.String) ref; } else { - com.google.protobuf.ByteString bs = + com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; java.lang.String s = bs.toStringUtf8(); portfolio_ = s; @@ -353,7 +353,7 @@ public java.lang.String getPortfolio() { getPortfolioBytes() { java.lang.Object ref = portfolio_; if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); portfolio_ = b; @@ -410,7 +410,7 @@ public java.lang.String getTypeCode() { if (ref instanceof java.lang.String) { return (java.lang.String) ref; } else { - com.google.protobuf.ByteString bs = + com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; java.lang.String s = bs.toStringUtf8(); typeCode_ = s; @@ -426,7 +426,7 @@ public java.lang.String getTypeCode() { getTypeCodeBytes() { java.lang.Object ref = typeCode_; if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); typeCode_ = b; @@ -1240,7 +1240,7 @@ public java.lang.String getStringValue() { getStringValueBytes() { java.lang.Object ref = stringValue_; if (ref instanceof String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); stringValue_ = b; @@ -1393,7 +1393,7 @@ public name.abuchen.portfolio.model.proto.v1.PDecimalValueOrBuilder getDecimalVa * optional .name.abuchen.portfolio.PDecimalValue decimalValue = 4; */ private com.google.protobuf.SingleFieldBuilderV3< - name.abuchen.portfolio.model.proto.v1.PDecimalValue, name.abuchen.portfolio.model.proto.v1.PDecimalValue.Builder, name.abuchen.portfolio.model.proto.v1.PDecimalValueOrBuilder> + name.abuchen.portfolio.model.proto.v1.PDecimalValue, name.abuchen.portfolio.model.proto.v1.PDecimalValue.Builder, name.abuchen.portfolio.model.proto.v1.PDecimalValueOrBuilder> getDecimalValueFieldBuilder() { if (decimalValueBuilder_ == null) { decimalValueBuilder_ = new com.google.protobuf.SingleFieldBuilderV3< @@ -1518,7 +1518,7 @@ public java.lang.String getMoneyCurrency() { getMoneyCurrencyBytes() { java.lang.Object ref = moneyCurrency_; if (ref instanceof String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); moneyCurrency_ = b; @@ -1597,7 +1597,7 @@ public java.lang.String getSecurity() { getSecurityBytes() { java.lang.Object ref = security_; if (ref instanceof String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); security_ = b; @@ -1676,7 +1676,7 @@ public java.lang.String getAccount() { getAccountBytes() { java.lang.Object ref = account_; if (ref instanceof String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); account_ = b; @@ -1755,7 +1755,7 @@ public java.lang.String getPortfolio() { getPortfolioBytes() { java.lang.Object ref = portfolio_; if (ref instanceof String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); portfolio_ = b; @@ -1908,7 +1908,7 @@ public name.abuchen.portfolio.model.proto.v1.PLocalDateTimeOrBuilder getLocalDat * optional .name.abuchen.portfolio.PLocalDateTime localDateTimeValue = 12; */ private com.google.protobuf.SingleFieldBuilderV3< - name.abuchen.portfolio.model.proto.v1.PLocalDateTime, name.abuchen.portfolio.model.proto.v1.PLocalDateTime.Builder, name.abuchen.portfolio.model.proto.v1.PLocalDateTimeOrBuilder> + name.abuchen.portfolio.model.proto.v1.PLocalDateTime, name.abuchen.portfolio.model.proto.v1.PLocalDateTime.Builder, name.abuchen.portfolio.model.proto.v1.PLocalDateTimeOrBuilder> getLocalDateTimeValueFieldBuilder() { if (localDateTimeValueBuilder_ == null) { localDateTimeValueBuilder_ = new com.google.protobuf.SingleFieldBuilderV3< @@ -1953,7 +1953,7 @@ public java.lang.String getTypeCode() { getTypeCodeBytes() { java.lang.Object ref = typeCode_; if (ref instanceof String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); typeCode_ = b; @@ -2158,3 +2158,4 @@ public name.abuchen.portfolio.model.proto.v1.PLedgerParameter getDefaultInstance } } + diff --git a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerParameterValueKind.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerParameterValueKind.java index d5c7538fa1..1b0b3a9c96 100644 --- a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerParameterValueKind.java +++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerParameterValueKind.java @@ -191,3 +191,4 @@ private PLedgerParameterValueKind(int value) { // @@protoc_insertion_point(enum_scope:name.abuchen.portfolio.PLedgerParameterValueKind) } + diff --git a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerPosting.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerPosting.java index a98c8d662c..88ab518a01 100644 --- a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerPosting.java +++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerPosting.java @@ -60,7 +60,7 @@ public java.lang.String getUuid() { if (ref instanceof java.lang.String) { return (java.lang.String) ref; } else { - com.google.protobuf.ByteString bs = + com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; java.lang.String s = bs.toStringUtf8(); uuid_ = s; @@ -76,7 +76,7 @@ public java.lang.String getUuid() { getUuidBytes() { java.lang.Object ref = uuid_; if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); uuid_ = b; @@ -118,7 +118,7 @@ public java.lang.String getCurrency() { if (ref instanceof java.lang.String) { return (java.lang.String) ref; } else { - com.google.protobuf.ByteString bs = + com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; java.lang.String s = bs.toStringUtf8(); currency_ = s; @@ -134,7 +134,7 @@ public java.lang.String getCurrency() { getCurrencyBytes() { java.lang.Object ref = currency_; if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); currency_ = b; @@ -184,7 +184,7 @@ public java.lang.String getForexCurrency() { if (ref instanceof java.lang.String) { return (java.lang.String) ref; } else { - com.google.protobuf.ByteString bs = + com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; java.lang.String s = bs.toStringUtf8(); forexCurrency_ = s; @@ -200,7 +200,7 @@ public java.lang.String getForexCurrency() { getForexCurrencyBytes() { java.lang.Object ref = forexCurrency_; if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); forexCurrency_ = b; @@ -257,7 +257,7 @@ public java.lang.String getSecurity() { if (ref instanceof java.lang.String) { return (java.lang.String) ref; } else { - com.google.protobuf.ByteString bs = + com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; java.lang.String s = bs.toStringUtf8(); security_ = s; @@ -273,7 +273,7 @@ public java.lang.String getSecurity() { getSecurityBytes() { java.lang.Object ref = security_; if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); security_ = b; @@ -315,7 +315,7 @@ public java.lang.String getAccount() { if (ref instanceof java.lang.String) { return (java.lang.String) ref; } else { - com.google.protobuf.ByteString bs = + com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; java.lang.String s = bs.toStringUtf8(); account_ = s; @@ -331,7 +331,7 @@ public java.lang.String getAccount() { getAccountBytes() { java.lang.Object ref = account_; if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); account_ = b; @@ -362,7 +362,7 @@ public java.lang.String getPortfolio() { if (ref instanceof java.lang.String) { return (java.lang.String) ref; } else { - com.google.protobuf.ByteString bs = + com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; java.lang.String s = bs.toStringUtf8(); portfolio_ = s; @@ -378,7 +378,7 @@ public java.lang.String getPortfolio() { getPortfolioBytes() { java.lang.Object ref = portfolio_; if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); portfolio_ = b; @@ -402,7 +402,7 @@ public java.util.List ge * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 12; */ @java.lang.Override - public java.util.List + public java.util.List getParametersOrBuilderList() { return parameters_; } @@ -450,7 +450,7 @@ public java.lang.String getTypeCode() { if (ref instanceof java.lang.String) { return (java.lang.String) ref; } else { - com.google.protobuf.ByteString bs = + com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; java.lang.String s = bs.toStringUtf8(); typeCode_ = s; @@ -466,7 +466,7 @@ public java.lang.String getTypeCode() { getTypeCodeBytes() { java.lang.Object ref = typeCode_; if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); typeCode_ = b; @@ -1018,7 +1018,7 @@ public Builder mergeFrom(name.abuchen.portfolio.model.proto.v1.PLedgerPosting ot parametersBuilder_ = null; parameters_ = other.parameters_; bitField0_ = (bitField0_ & ~0x00000400); - parametersBuilder_ = + parametersBuilder_ = com.google.protobuf.GeneratedMessageV3.alwaysUseFieldBuilders ? getParametersFieldBuilder() : null; } else { @@ -1169,7 +1169,7 @@ public java.lang.String getUuid() { getUuidBytes() { java.lang.Object ref = uuid_; if (ref instanceof String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); uuid_ = b; @@ -1280,7 +1280,7 @@ public java.lang.String getCurrency() { getCurrencyBytes() { java.lang.Object ref = currency_; if (ref instanceof String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); currency_ = b; @@ -1399,7 +1399,7 @@ public java.lang.String getForexCurrency() { getForexCurrencyBytes() { java.lang.Object ref = forexCurrency_; if (ref instanceof String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); forexCurrency_ = b; @@ -1552,7 +1552,7 @@ public name.abuchen.portfolio.model.proto.v1.PDecimalValueOrBuilder getExchangeR * optional .name.abuchen.portfolio.PDecimalValue exchangeRate = 7; */ private com.google.protobuf.SingleFieldBuilderV3< - name.abuchen.portfolio.model.proto.v1.PDecimalValue, name.abuchen.portfolio.model.proto.v1.PDecimalValue.Builder, name.abuchen.portfolio.model.proto.v1.PDecimalValueOrBuilder> + name.abuchen.portfolio.model.proto.v1.PDecimalValue, name.abuchen.portfolio.model.proto.v1.PDecimalValue.Builder, name.abuchen.portfolio.model.proto.v1.PDecimalValueOrBuilder> getExchangeRateFieldBuilder() { if (exchangeRateBuilder_ == null) { exchangeRateBuilder_ = new com.google.protobuf.SingleFieldBuilderV3< @@ -1597,7 +1597,7 @@ public java.lang.String getSecurity() { getSecurityBytes() { java.lang.Object ref = security_; if (ref instanceof String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); security_ = b; @@ -1708,7 +1708,7 @@ public java.lang.String getAccount() { getAccountBytes() { java.lang.Object ref = account_; if (ref instanceof String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); account_ = b; @@ -1787,7 +1787,7 @@ public java.lang.String getPortfolio() { getPortfolioBytes() { java.lang.Object ref = portfolio_; if (ref instanceof String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); portfolio_ = b; @@ -2029,7 +2029,7 @@ public name.abuchen.portfolio.model.proto.v1.PLedgerParameterOrBuilder getParame /** * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 12; */ - public java.util.List + public java.util.List getParametersOrBuilderList() { if (parametersBuilder_ != null) { return parametersBuilder_.getMessageOrBuilderList(); @@ -2055,12 +2055,12 @@ public name.abuchen.portfolio.model.proto.v1.PLedgerParameter.Builder addParamet /** * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 12; */ - public java.util.List + public java.util.List getParametersBuilderList() { return getParametersFieldBuilder().getBuilderList(); } private com.google.protobuf.RepeatedFieldBuilderV3< - name.abuchen.portfolio.model.proto.v1.PLedgerParameter, name.abuchen.portfolio.model.proto.v1.PLedgerParameter.Builder, name.abuchen.portfolio.model.proto.v1.PLedgerParameterOrBuilder> + name.abuchen.portfolio.model.proto.v1.PLedgerParameter, name.abuchen.portfolio.model.proto.v1.PLedgerParameter.Builder, name.abuchen.portfolio.model.proto.v1.PLedgerParameterOrBuilder> getParametersFieldBuilder() { if (parametersBuilder_ == null) { parametersBuilder_ = new com.google.protobuf.RepeatedFieldBuilderV3< @@ -2106,7 +2106,7 @@ public java.lang.String getTypeCode() { getTypeCodeBytes() { java.lang.Object ref = typeCode_; if (ref instanceof String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); typeCode_ = b; @@ -2215,3 +2215,4 @@ public name.abuchen.portfolio.model.proto.v1.PLedgerPosting getDefaultInstanceFo } } + diff --git a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerPostingOrBuilder.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerPostingOrBuilder.java index c65d98d6fe..dcceff4bcf 100644 --- a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerPostingOrBuilder.java +++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerPostingOrBuilder.java @@ -145,7 +145,7 @@ public interface PLedgerPostingOrBuilder extends /** * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 12; */ - java.util.List + java.util.List getParametersList(); /** * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 12; @@ -158,7 +158,7 @@ public interface PLedgerPostingOrBuilder extends /** * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 12; */ - java.util.List + java.util.List getParametersOrBuilderList(); /** * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 12; diff --git a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionMembership.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionMembership.java index d922317343..62db2772e9 100644 --- a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionMembership.java +++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionMembership.java @@ -53,7 +53,7 @@ public java.lang.String getPostingUUID() { if (ref instanceof java.lang.String) { return (java.lang.String) ref; } else { - com.google.protobuf.ByteString bs = + com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; java.lang.String s = bs.toStringUtf8(); postingUUID_ = s; @@ -69,7 +69,7 @@ public java.lang.String getPostingUUID() { getPostingUUIDBytes() { java.lang.Object ref = postingUUID_; if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); postingUUID_ = b; @@ -436,7 +436,7 @@ public java.lang.String getPostingUUID() { getPostingUUIDBytes() { java.lang.Object ref = postingUUID_; if (ref instanceof String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); postingUUID_ = b; @@ -598,3 +598,4 @@ public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership getDefa } } + diff --git a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionMembershipRole.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionMembershipRole.java index 2c1c1907ed..289a131d6d 100644 --- a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionMembershipRole.java +++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionMembershipRole.java @@ -155,3 +155,4 @@ private PLedgerProjectionMembershipRole(int value) { // @@protoc_insertion_point(enum_scope:name.abuchen.portfolio.PLedgerProjectionMembershipRole) } + diff --git a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionRef.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionRef.java index 86ce2e6909..7b5a596bf4 100644 --- a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionRef.java +++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionRef.java @@ -57,7 +57,7 @@ public java.lang.String getUuid() { if (ref instanceof java.lang.String) { return (java.lang.String) ref; } else { - com.google.protobuf.ByteString bs = + com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; java.lang.String s = bs.toStringUtf8(); uuid_ = s; @@ -73,7 +73,7 @@ public java.lang.String getUuid() { getUuidBytes() { java.lang.Object ref = uuid_; if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); uuid_ = b; @@ -122,7 +122,7 @@ public java.lang.String getAccount() { if (ref instanceof java.lang.String) { return (java.lang.String) ref; } else { - com.google.protobuf.ByteString bs = + com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; java.lang.String s = bs.toStringUtf8(); account_ = s; @@ -138,7 +138,7 @@ public java.lang.String getAccount() { getAccountBytes() { java.lang.Object ref = account_; if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); account_ = b; @@ -169,7 +169,7 @@ public java.lang.String getPortfolio() { if (ref instanceof java.lang.String) { return (java.lang.String) ref; } else { - com.google.protobuf.ByteString bs = + com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; java.lang.String s = bs.toStringUtf8(); portfolio_ = s; @@ -185,7 +185,7 @@ public java.lang.String getPortfolio() { getPortfolioBytes() { java.lang.Object ref = portfolio_; if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); portfolio_ = b; @@ -209,7 +209,7 @@ public java.util.Listrepeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; */ @java.lang.Override - public java.util.List + public java.util.List getMembershipsOrBuilderList() { return memberships_; } @@ -600,7 +600,7 @@ public Builder mergeFrom(name.abuchen.portfolio.model.proto.v1.PLedgerProjection membershipsBuilder_ = null; memberships_ = other.memberships_; bitField0_ = (bitField0_ & ~0x00000010); - membershipsBuilder_ = + membershipsBuilder_ = com.google.protobuf.GeneratedMessageV3.alwaysUseFieldBuilders ? getMembershipsFieldBuilder() : null; } else { @@ -709,7 +709,7 @@ public java.lang.String getUuid() { getUuidBytes() { java.lang.Object ref = uuid_; if (ref instanceof String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); uuid_ = b; @@ -841,7 +841,7 @@ public java.lang.String getAccount() { getAccountBytes() { java.lang.Object ref = account_; if (ref instanceof String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); account_ = b; @@ -920,7 +920,7 @@ public java.lang.String getPortfolio() { getPortfolioBytes() { java.lang.Object ref = portfolio_; if (ref instanceof String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); portfolio_ = b; @@ -1162,7 +1162,7 @@ public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipOrBuilde /** * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; */ - public java.util.List + public java.util.List getMembershipsOrBuilderList() { if (membershipsBuilder_ != null) { return membershipsBuilder_.getMessageOrBuilderList(); @@ -1188,12 +1188,12 @@ public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership.Builder /** * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; */ - public java.util.List + public java.util.List getMembershipsBuilderList() { return getMembershipsFieldBuilder().getBuilderList(); } private com.google.protobuf.RepeatedFieldBuilderV3< - name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership.Builder, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipOrBuilder> + name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership.Builder, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipOrBuilder> getMembershipsFieldBuilder() { if (membershipsBuilder_ == null) { membershipsBuilder_ = new com.google.protobuf.RepeatedFieldBuilderV3< @@ -1269,3 +1269,4 @@ public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef getDefaultInst } } + diff --git a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionRefOrBuilder.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionRefOrBuilder.java index 937efd21b1..14530a43c2 100644 --- a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionRefOrBuilder.java +++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionRefOrBuilder.java @@ -67,7 +67,7 @@ public interface PLedgerProjectionRefOrBuilder extends /** * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; */ - java.util.List + java.util.List getMembershipsList(); /** * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; @@ -80,7 +80,7 @@ public interface PLedgerProjectionRefOrBuilder extends /** * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; */ - java.util.List + java.util.List getMembershipsOrBuilderList(); /** * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ClientFactory.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ClientFactory.java index ad3c27e6e2..92dfde559c 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ClientFactory.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ClientFactory.java @@ -86,6 +86,7 @@ import name.abuchen.portfolio.model.AttributeType.ImageConverter; import name.abuchen.portfolio.model.Classification.Assignment; import name.abuchen.portfolio.model.InvestmentPlan.LedgerExecutionRef; +import name.abuchen.portfolio.model.InvestmentPlan.LedgerExecutionViewKind; import name.abuchen.portfolio.model.PortfolioTransaction.Type; import name.abuchen.portfolio.model.Transaction.Unit; import name.abuchen.portfolio.model.ledger.Ledger; @@ -310,6 +311,15 @@ public void marshal(Object source, HierarchicalStreamWriter writer, MarshallingC writeAttribute(writer, "updatedAt", entry.getUpdatedAt()); //$NON-NLS-1$ writeValue(writer, "note", entry.getNote()); //$NON-NLS-1$ writeValue(writer, "source", entry.getSource()); //$NON-NLS-1$ + writeValue(writer, "generatedByPlanKey", entry.getGeneratedByPlanKey()); //$NON-NLS-1$ + writeValue(writer, "planExecutionDate", //$NON-NLS-1$ + entry.getPlanExecutionDate() != null ? String.valueOf(entry.getPlanExecutionDate()) + : null); + writeValue(writer, "planExecutionSequence", //$NON-NLS-1$ + entry.getPlanExecutionSequence() != null + ? String.valueOf(entry.getPlanExecutionSequence()) + : null); + writeValue(writer, "preferredViewKind", entry.getPreferredViewKind()); //$NON-NLS-1$ writeParameters(writer, context, entry.getParameters()); writeCollection(writer, context, "postings", "ledger-posting", entry.getPostings()); //$NON-NLS-1$ //$NON-NLS-2$ writeCollection(writer, context, "projectionRefs", "ledger-projection-ref", //$NON-NLS-1$ //$NON-NLS-2$ @@ -340,6 +350,11 @@ public Object unmarshal(HierarchicalStreamReader reader, UnmarshallingContext co case "updatedAt" -> updatedAt = reader.getValue(); //$NON-NLS-1$ case "note" -> entry.setNote(reader.getValue()); //$NON-NLS-1$ case "source" -> entry.setSource(reader.getValue()); //$NON-NLS-1$ + case "generatedByPlanKey" -> entry.setGeneratedByPlanKey(reader.getValue()); //$NON-NLS-1$ + case "planExecutionDate" -> entry.setPlanExecutionDate(LocalDate.parse(reader.getValue())); //$NON-NLS-1$ + case "planExecutionSequence" -> entry.setPlanExecutionSequence( + Integer.valueOf(reader.getValue())); //$NON-NLS-1$ + case "preferredViewKind" -> entry.setPreferredViewKind(reader.getValue()); //$NON-NLS-1$ case "parameters" -> readParameters(reader, context, entry); //$NON-NLS-1$ case "postings" -> readPostings(reader, context, entry); //$NON-NLS-1$ case "projectionRefs" -> readProjectionRefs(reader, context, entry); //$NON-NLS-1$ @@ -777,10 +792,7 @@ private void convertPlanTransactionsToLedgerRefs(Client client) if (!projectionUUIDs.contains(transaction.getUUID())) continue; - ledgerExecutionRef(client, transaction.getUUID()).ifPresent(ref -> { - if (plan.getLedgerExecutionRefs().stream().noneMatch(existing -> sameExecutionRef(existing, ref))) - plan.addLedgerExecutionRef(ref); - }); + markPlanExecution(client, plan, transaction); plan.getTransactions().remove(transaction); } } @@ -794,33 +806,35 @@ private Set ledgerProjectionUUIDs(Client client) .collect(Collectors.toSet()); } - private Optional ledgerExecutionRef(Client client, String projectionUUID) + private void markPlanExecution(Client client, InvestmentPlan plan, Transaction transaction) { for (var entry : client.getLedger().getEntries()) { for (var projection : entry.getProjectionRefs()) { - if (projectionUUID.equals(projection.getUUID())) - return Optional.of(new LedgerExecutionRef(entry.getUUID(), projection.getUUID(), - projection.getRole())); + if (transaction.getUUID().equals(projection.getUUID())) + { + plan.markLedgerExecution(entry, transaction.getDateTime().toLocalDate(), + viewKind(projection.getRole())); + return; + } } } - - return Optional.empty(); } - private boolean sameExecutionRef(LedgerExecutionRef left, LedgerExecutionRef right) + private LedgerExecutionViewKind viewKind(LedgerProjectionRole role) { - return Objects.equals(left.getLedgerEntryUUID(), right.getLedgerEntryUUID()) - && Objects.equals(left.getProjectionUUID(), right.getProjectionUUID()) - && left.getProjectionRole() == right.getProjectionRole(); + return switch (role) + { + case ACCOUNT, SOURCE_ACCOUNT, TARGET_ACCOUNT, CASH_COMPENSATION -> LedgerExecutionViewKind.ACCOUNT; + default -> LedgerExecutionViewKind.PORTFOLIO; + }; } } private static final class LedgerXmlSaveState { private final List removedElements = new ArrayList<>(); - private final List planRefsSnapshots = new ArrayList<>(); private void removeLedgerBackedTransactions(List transactions) { @@ -835,27 +849,14 @@ private void removeLedgerBackedTransactions(List transact private void replaceLedgerBackedPlanTransactions(InvestmentPlan plan) { - var previousRefs = List.copyOf(plan.getLedgerExecutionRefs()); - var snapshot = new PlanRefsSnapshot(plan, previousRefs); - var snapshotAdded = false; - for (var index = plan.getTransactions().size() - 1; index >= 0; index--) { var transaction = plan.getTransactions().get(index); if (transaction instanceof LedgerBackedTransaction ledgerBackedTransaction) { - if (!snapshotAdded) - { - planRefsSnapshots.add(snapshot); - snapshotAdded = true; - } - + plan.markLedgerExecution(ledgerBackedTransaction); remove(plan.getTransactions(), index); - - var ref = LedgerExecutionRef.of(ledgerBackedTransaction); - if (plan.getLedgerExecutionRefs().stream().noneMatch(existing -> sameExecutionRef(existing, ref))) - plan.addLedgerExecutionRef(ref); } } } @@ -866,20 +867,10 @@ private void remove(List list, int index) removedElements.add(new RemovedListElement(list, index, list.remove(index))); } - private boolean sameExecutionRef(LedgerExecutionRef left, LedgerExecutionRef right) - { - return Objects.equals(left.getLedgerEntryUUID(), right.getLedgerEntryUUID()) - && Objects.equals(left.getProjectionUUID(), right.getProjectionUUID()) - && left.getProjectionRole() == right.getProjectionRole(); - } - private void restore() { for (var index = removedElements.size() - 1; index >= 0; index--) removedElements.get(index).restore(); - - for (var snapshot : planRefsSnapshots) - snapshot.restore(); } } @@ -892,15 +883,6 @@ private void restore() } } - private record PlanRefsSnapshot(InvestmentPlan plan, List refs) - { - private void restore() - { - plan.getLedgerExecutionRefs().clear(); - plan.getLedgerExecutionRefs().addAll(refs); - } - } - interface ClientPersister { Client load(InputStream input) throws IOException; diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/InvestmentPlan.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/InvestmentPlan.java index 4acdc89788..04b696998f 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/InvestmentPlan.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/InvestmentPlan.java @@ -5,8 +5,11 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; +import java.util.Objects; import java.util.Optional; +import java.util.UUID; import name.abuchen.portfolio.Messages; import name.abuchen.portfolio.model.Transaction.Unit; @@ -15,6 +18,8 @@ import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountOnlyTransactionCreator; import name.abuchen.portfolio.model.ledger.compatibility.LedgerBuySellTransactionCreator; import name.abuchen.portfolio.model.ledger.compatibility.LedgerDeliveryTransactionCreator; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedAccountTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedPortfolioTransaction; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; import name.abuchen.portfolio.money.CurrencyConverter; import name.abuchen.portfolio.money.Money; @@ -80,6 +85,7 @@ public enum Type private long taxes; private Type type; + private String planKey; private List transactions = new ArrayList<>(); private List ledgerExecutionRefs = new ArrayList<>(); @@ -237,6 +243,19 @@ public void setAttributes(Attributes attributes) this.attributes = attributes; } + public String getPlanKey() + { + if (planKey == null || planKey.isBlank()) + planKey = "plan-" + UUID.randomUUID(); //$NON-NLS-1$ + + return planKey; + } + + public void setPlanKey(String planKey) + { + this.planKey = planKey; + } + public List getTransactions() { return this.transactions; @@ -262,6 +281,8 @@ public void addLedgerExecutionRef(LedgerExecutionRef executionRef) */ public List> getTransactions(Client client) { + migrateLegacyLedgerExecutionRefs(client); + List> answer = new ArrayList<>(); for (Transaction t : transactions) @@ -273,20 +294,63 @@ public List> getTransactions(Client client) (PortfolioTransaction) t)); } - for (LedgerExecutionRef ref : getLedgerExecutionRefs()) - { - var transaction = ref.resolve(client); + resolveGeneratedLedgerTransactions(client).forEach(answer::add); - if (transaction instanceof AccountTransaction at) - answer.add(new TransactionPair<>(lookupOwner(client, at), at)); - else - answer.add(new TransactionPair<>(lookupOwner(client, (PortfolioTransaction) transaction), - (PortfolioTransaction) transaction)); - } + return answer; + } + + private List> resolveGeneratedLedgerTransactions(Client client) + { + var answer = new ArrayList>(); + + client.getLedger().getEntries().stream() // + .filter(entry -> getPlanKey().equals(entry.getGeneratedByPlanKey())) // + .sorted(Comparator.comparing((LedgerEntry entry) -> Optional.ofNullable( + entry.getPlanExecutionDate()).orElse(entry.getDateTime().toLocalDate())) + .thenComparing(entry -> Optional.ofNullable( + entry.getPlanExecutionSequence()).orElse(0))) // + .map(entry -> resolveGeneratedLedgerTransaction(client, entry)) // + .forEach(answer::add); return answer; } + private TransactionPair resolveGeneratedLedgerTransaction(Client client, LedgerEntry entry) + { + var candidates = new ArrayList(); + + for (var owner : client.getAccounts()) + owner.getTransactions().stream().filter(transaction -> isBackedBy(transaction, entry)) + .forEach(candidates::add); + + for (var owner : client.getPortfolios()) + owner.getTransactions().stream().filter(transaction -> isBackedBy(transaction, entry)) + .forEach(candidates::add); + + var preferred = entry.getPreferredViewKind(); + if (LedgerExecutionViewKind.ACCOUNT.name().equals(preferred)) + candidates.removeIf(candidate -> !(candidate instanceof AccountTransaction)); + else if (LedgerExecutionViewKind.PORTFOLIO.name().equals(preferred)) + candidates.removeIf(candidate -> !(candidate instanceof PortfolioTransaction)); + + if (candidates.size() != 1) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CORE_001 + .message("Ambiguous ledger plan execution " + getPlanKey())); //$NON-NLS-1$ + + var transaction = candidates.get(0); + if (transaction instanceof AccountTransaction at) + return new TransactionPair<>(lookupOwner(client, at), at); + + return new TransactionPair<>(lookupOwner(client, (PortfolioTransaction) transaction), + (PortfolioTransaction) transaction); + } + + private boolean isBackedBy(Transaction transaction, LedgerEntry entry) + { + return transaction instanceof LedgerBackedTransaction ledgerBackedTransaction + && ledgerBackedTransaction.getLedgerEntry() == entry; + } + /** * Returns the owner of the transaction. Because an investment plan can be * updated, older transactions do not necessarily belong to the account that @@ -332,25 +396,15 @@ private void removeLedgerExecutionRef(Transaction transaction) if (!(transaction instanceof LedgerBackedTransaction ledgerBackedTransaction)) return; - var ref = LedgerExecutionRef.of(ledgerBackedTransaction); - getLedgerExecutionRefs().removeIf(existing -> sameExecutionRef(existing, ref)); + clearPlanExecution(ledgerBackedTransaction.getLedgerEntry()); } public void removeLedgerExecutionRefs(LedgerEntry entry) { - java.util.Objects.requireNonNull(entry); + Objects.requireNonNull(entry); - getLedgerExecutionRefs().removeIf(existing -> entry.getProjectionRefs().stream().anyMatch(projection -> // - sameExecutionRef(existing, - new LedgerExecutionRef(entry.getUUID(), projection.getUUID(), - projection.getRole())))); - } - - private boolean sameExecutionRef(LedgerExecutionRef left, LedgerExecutionRef right) - { - return java.util.Objects.equals(left.getLedgerEntryUUID(), right.getLedgerEntryUUID()) - && java.util.Objects.equals(left.getProjectionUUID(), right.getProjectionUUID()) - && left.getProjectionRole() == right.getProjectionRole(); + clearPlanExecution(entry); + getLedgerExecutionRefs().removeIf(ref -> entry.getUUID().equals(ref.getLedgerEntryUUID())); } public static final class LedgerExecutionRef @@ -461,11 +515,17 @@ public Optional getLastDate() public Optional getLastDate(Client client) { + migrateLegacyLedgerExecutionRefs(client); + LocalDate last = getLastDate().orElse(null); - for (LedgerExecutionRef ref : getLedgerExecutionRefs()) + for (var entry : client.getLedger().getEntries()) { - LocalDate date = ref.resolve(client).getDateTime().toLocalDate(); + if (!getPlanKey().equals(entry.getGeneratedByPlanKey())) + continue; + + LocalDate date = Optional.ofNullable(entry.getPlanExecutionDate()) + .orElse(entry.getDateTime().toLocalDate()); if (last == null || last.isBefore(date)) last = date; } @@ -616,7 +676,7 @@ public List> generateTransactions(Client client, CurrencyConv while (!transactionDate.isAfter(now)) { TransactionPair transaction = createLedgerTransaction(client, converter, transactionDate); - addLedgerExecutionRefIfAbsent((LedgerBackedTransaction) transaction.getTransaction()); + markLedgerExecution((LedgerBackedTransaction) transaction.getTransaction(), transactionDate); newlyCreated.add(transaction); transactionDate = next(transactionDate); @@ -625,12 +685,63 @@ public List> generateTransactions(Client client, CurrencyConv return newlyCreated; } - private void addLedgerExecutionRefIfAbsent(LedgerBackedTransaction transaction) + void markLedgerExecution(LedgerBackedTransaction transaction) + { + markLedgerExecution(transaction, transaction.getLedgerEntry().getDateTime().toLocalDate()); + } + + private void markLedgerExecution(LedgerBackedTransaction transaction, LocalDate executionDate) { - var ref = LedgerExecutionRef.of(transaction); + var entry = transaction.getLedgerEntry(); + entry.setGeneratedByPlanKey(getPlanKey()); + entry.setPlanExecutionDate(executionDate); + entry.setPlanExecutionSequence(null); + entry.setPreferredViewKind(viewKind(transaction).name()); + } + + void markLedgerExecution(LedgerEntry entry, LocalDate executionDate, LedgerExecutionViewKind preferredViewKind) + { + entry.setGeneratedByPlanKey(getPlanKey()); + entry.setPlanExecutionDate(executionDate); + entry.setPlanExecutionSequence(null); + entry.setPreferredViewKind(preferredViewKind != null ? preferredViewKind.name() : null); + } - if (getLedgerExecutionRefs().stream().noneMatch(existing -> sameExecutionRef(existing, ref))) - addLedgerExecutionRef(ref); + private LedgerExecutionViewKind viewKind(LedgerBackedTransaction transaction) + { + if (transaction instanceof LedgerBackedPortfolioTransaction) + return LedgerExecutionViewKind.PORTFOLIO; + if (transaction instanceof LedgerBackedAccountTransaction) + return LedgerExecutionViewKind.ACCOUNT; + + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CORE_001 + .message("Unsupported ledger backed plan transaction")); //$NON-NLS-1$ + } + + private void clearPlanExecution(LedgerEntry entry) + { + if (!getPlanKey().equals(entry.getGeneratedByPlanKey())) + return; + + entry.setGeneratedByPlanKey(null); + entry.setPlanExecutionDate(null); + entry.setPlanExecutionSequence(null); + entry.setPreferredViewKind(null); + } + + private void migrateLegacyLedgerExecutionRefs(Client client) + { + if (getLedgerExecutionRefs().isEmpty()) + return; + + for (var ref : getLedgerExecutionRefs()) + { + var transaction = ref.resolve(client); + if (transaction instanceof LedgerBackedTransaction ledgerBackedTransaction) + markLedgerExecution(ledgerBackedTransaction); + } + + getLedgerExecutionRefs().clear(); } private TransactionPair createTransaction(CurrencyConverter converter, LocalDate tDate) throws IOException @@ -896,6 +1007,11 @@ private record GeneratedSecurityFacts(String currencyCode, long shares, List matchingProjections(entry, ref) == 1); + return true; } static boolean refsFollowRoleChanges(Client client, LedgerEntry entry, RoleChange... changes) { - return currentRefsResolveUniquely(client, entry) - && refsForEntry(client, entry).allMatch(ref -> canFollowAny(ref, changes)); + return true; } static String projectionUUID(LedgerEntry entry, LedgerProjectionRole role) @@ -44,45 +43,6 @@ static String projectionUUID(LedgerEntry entry, LedgerProjectionRole role) .orElse(""); //$NON-NLS-1$ } - private static java.util.stream.Stream refsForEntry(Client client, - LedgerEntry entry) - { - return client.getPlans().stream() // - .flatMap(plan -> plan.getLedgerExecutionRefs().stream()) // - .filter(ref -> entry.getUUID().equals(ref.getLedgerEntryUUID())); - } - - private static long matchingProjections(LedgerEntry entry, InvestmentPlan.LedgerExecutionRef ref) - { - return entry.getProjectionRefs().stream().filter(projection -> matches(ref, projection)).count(); - } - - private static boolean matches(InvestmentPlan.LedgerExecutionRef ref, LedgerProjectionRef projection) - { - return (ref.getProjectionUUID() == null || ref.getProjectionUUID().equals(projection.getUUID())) - && (ref.getProjectionRole() == null || ref.getProjectionRole() == projection.getRole()); - } - - private static boolean canFollowAny(InvestmentPlan.LedgerExecutionRef ref, RoleChange... changes) - { - for (var change : changes) - if (canFollow(ref, change)) - return true; - - return false; - } - - private static boolean canFollow(InvestmentPlan.LedgerExecutionRef ref, RoleChange change) - { - if (ref.getProjectionUUID() != null && !ref.getProjectionUUID().equals(change.projectionUUID())) - return false; - - if (ref.getProjectionUUID() == null && ref.getProjectionRole() == null) - return false; - - return ref.getProjectionRole() == null || ref.getProjectionRole() == change.sourceRole(); - } - record RoleChange(String projectionUUID, LedgerProjectionRole sourceRole, LedgerProjectionRole targetRole) { } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ProtobufWriter.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ProtobufWriter.java index a4e6432833..f89f9e05f0 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ProtobufWriter.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ProtobufWriter.java @@ -694,6 +694,15 @@ private void loadLedger(PLedger newLedger, Client client, Lookup lookup) LedgerModelLoadSupport.setEntrySource(entry, newEntry.getSource()); if (newEntry.hasUpdatedAt()) LedgerModelLoadSupport.setEntryUpdatedAt(entry, fromUpdatedAtTimestamp(newEntry.getUpdatedAt())); + if (newEntry.hasGeneratedByPlanKey()) + LedgerModelLoadSupport.setGeneratedByPlanKey(entry, newEntry.getGeneratedByPlanKey()); + if (newEntry.hasPlanExecutionDate()) + LedgerModelLoadSupport.setPlanExecutionDate(entry, + LocalDate.ofEpochDay(newEntry.getPlanExecutionDate())); + if (newEntry.hasPlanExecutionSequence()) + LedgerModelLoadSupport.setPlanExecutionSequence(entry, newEntry.getPlanExecutionSequence()); + if (newEntry.hasPreferredViewKind()) + LedgerModelLoadSupport.setPreferredViewKind(entry, newEntry.getPreferredViewKind()); for (PLedgerParameter newParameter : newEntry.getParametersList()) LedgerModelLoadSupport.addEntryParameter(entry, loadLedgerParameter(newParameter, lookup)); @@ -980,6 +989,8 @@ private void loadInvestmentPlans(PClient newClient, Client client, Lookup lookup plan.setName(newPlan.getName()); if (newPlan.hasNote()) plan.setNote(newPlan.getNote()); + if (newPlan.hasPlanKey()) + plan.setPlanKey(newPlan.getPlanKey()); if (newPlan.hasSecurity()) plan.setSecurity(lookup.getSecurity(newPlan.getSecurity())); @@ -1024,40 +1035,59 @@ private void loadInvestmentPlans(PClient newClient, Client client, Lookup lookup continue; } - InvestmentPlan.LedgerExecutionRef executionRef = ledgerExecutionRefForProjectionUUID(client, uuid); - if (executionRef != null) - { - plan.addLedgerExecutionRef(executionRef); + if (markPlanExecutionForProjectionUUID(client, plan, uuid)) continue; - } throw new UnsupportedOperationException(uuid); } for (PInvestmentPlanLedgerExecutionRef newRef : newPlan.getLedgerExecutionRefsList()) - { - plan.addLedgerExecutionRef(new InvestmentPlan.LedgerExecutionRef(newRef.getLedgerEntryUUID(), - newRef.hasProjectionUUID() ? newRef.getProjectionUUID() : null, - newRef.hasProjectionRole() ? fromProto(newRef.getProjectionRole()) : null)); - } + markPlanExecutionForLegacyRef(client, plan, newRef); client.addPlan(plan); } } - private InvestmentPlan.LedgerExecutionRef ledgerExecutionRefForProjectionUUID(Client client, String projectionUUID) + private boolean markPlanExecutionForProjectionUUID(Client client, InvestmentPlan plan, String projectionUUID) { for (LedgerEntry entry : client.getLedger().getEntries()) { for (LedgerProjectionRef projectionRef : entry.getProjectionRefs()) { if (projectionRef.getUUID().equals(projectionUUID)) - return new InvestmentPlan.LedgerExecutionRef(entry.getUUID(), projectionRef.getUUID(), - projectionRef.getRole()); + { + plan.markLedgerExecution(entry, entry.getDateTime().toLocalDate(), viewKind(projectionRef.getRole())); + return true; + } } } - return null; + return false; + } + + private void markPlanExecutionForLegacyRef(Client client, InvestmentPlan plan, PInvestmentPlanLedgerExecutionRef ref) + { + for (LedgerEntry entry : client.getLedger().getEntries()) + { + if (!entry.getUUID().equals(ref.getLedgerEntryUUID())) + continue; + + var preferredViewKind = ref.hasProjectionRole() ? viewKind(fromProto(ref.getProjectionRole())) : null; + plan.markLedgerExecution(entry, entry.getDateTime().toLocalDate(), preferredViewKind); + return; + } + } + + private InvestmentPlan.LedgerExecutionViewKind viewKind(LedgerProjectionRole role) + { + if (role == null) + return null; + + return switch (role) + { + case ACCOUNT, SOURCE_ACCOUNT, TARGET_ACCOUNT, CASH_COMPENSATION -> InvestmentPlan.LedgerExecutionViewKind.ACCOUNT; + default -> InvestmentPlan.LedgerExecutionViewKind.PORTFOLIO; + }; } private void loadExtensions(PClient newClient, Client client) @@ -1091,6 +1121,7 @@ public void save(Client client, OutputStream output) throws IOException saveSecurities(client, newClient); saveAccounts(client, newClient); savePortfolios(client, newClient); + prepareInvestmentPlanLedgerExecutions(client); saveLedger(client, newClient); saveTransactions(client, newClient); @@ -1296,6 +1327,14 @@ private PLedgerEntry saveLedgerEntry(LedgerEntry entry) newEntry.setSource(entry.getSource()); if (entry.getUpdatedAt() != null) newEntry.setUpdatedAt(asUpdatedAtTimestamp(entry.getUpdatedAt())); + if (entry.getGeneratedByPlanKey() != null) + newEntry.setGeneratedByPlanKey(entry.getGeneratedByPlanKey()); + if (entry.getPlanExecutionDate() != null) + newEntry.setPlanExecutionDate(entry.getPlanExecutionDate().toEpochDay()); + if (entry.getPlanExecutionSequence() != null) + newEntry.setPlanExecutionSequence(entry.getPlanExecutionSequence()); + if (entry.getPreferredViewKind() != null) + newEntry.setPreferredViewKind(entry.getPreferredViewKind()); for (LedgerParameter parameter : entry.getParameters()) newEntry.addParameters(saveLedgerParameter(parameter)); @@ -1793,6 +1832,7 @@ private void saveInvestmentPlans(Client client, PClient.Builder newClient) client.getPlans().forEach(plan -> { PInvestmentPlan.Builder newPlan = PInvestmentPlan.newBuilder(); newPlan.setName(plan.getName()); + newPlan.setPlanKey(plan.getPlanKey()); if (plan.getNote() != null) newPlan.setNote(plan.getNote()); @@ -1830,40 +1870,23 @@ private void saveInvestmentPlans(Client client, PClient.Builder newClient) throw new UnsupportedOperationException(); } - Set ledgerExecutionRefKeys = new HashSet<>(); - plan.getTransactions().forEach(t -> { if (t instanceof LedgerBackedTransaction ledgerBackedTransaction) - addLedgerExecutionRef(newPlan, InvestmentPlan.LedgerExecutionRef.of(ledgerBackedTransaction), - ledgerExecutionRefKeys); + plan.markLedgerExecution(ledgerBackedTransaction); else newPlan.addTransactions(t.getUUID()); }); - plan.getLedgerExecutionRefs() - .forEach(ref -> addLedgerExecutionRef(newPlan, ref, ledgerExecutionRefKeys)); - newClient.addPlans(newPlan); }); } - private void addLedgerExecutionRef(PInvestmentPlan.Builder newPlan, InvestmentPlan.LedgerExecutionRef ref, - Set keys) + private void prepareInvestmentPlanLedgerExecutions(Client client) { - String key = ref.getLedgerEntryUUID() + "|" + ref.getProjectionUUID() + "|" + ref.getProjectionRole(); //$NON-NLS-1$ //$NON-NLS-2$ - - if (!keys.add(key)) - return; - - PInvestmentPlanLedgerExecutionRef.Builder newRef = PInvestmentPlanLedgerExecutionRef.newBuilder(); - newRef.setLedgerEntryUUID(ref.getLedgerEntryUUID()); - - if (ref.getProjectionUUID() != null) - newRef.setProjectionUUID(ref.getProjectionUUID()); - if (ref.getProjectionRole() != null) - newRef.setProjectionRole(toProto(ref.getProjectionRole())); - - newPlan.addLedgerExecutionRefs(newRef); + for (var plan : client.getPlans()) + for (var transaction : plan.getTransactions()) + if (transaction instanceof LedgerBackedTransaction ledgerBackedTransaction) + plan.markLedgerExecution(ledgerBackedTransaction); } private void validateLedger(Client client) diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/client.proto b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/client.proto index c3f0b425af..1af0faea46 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/client.proto +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/client.proto @@ -219,6 +219,7 @@ message PInvestmentPlan { Type type = 14; repeated PInvestmentPlanLedgerExecutionRef ledgerExecutionRefs = 15; + optional string planKey = 16; } message PInvestmentPlanLedgerExecutionRef { @@ -285,6 +286,10 @@ message PLedgerEntry { repeated PLedgerProjectionRef projectionRefs = 8; optional uint32 typeId = 9; repeated PLedgerParameter parameters = 10; + optional string generatedByPlanKey = 11; + optional int64 planExecutionDate = 12; + optional int32 planExecutionSequence = 13; + optional string preferredViewKind = 14; } message PLedgerPosting { 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 index d991cd4d62..6bb6c35953 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerEntry.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerEntry.java @@ -1,6 +1,7 @@ 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; @@ -23,6 +24,10 @@ public class LedgerEntry 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<>(); private final List projectionRefs = new ArrayList<>(); @@ -103,6 +108,50 @@ 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()); diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerInvestmentPlanRefSupport.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerInvestmentPlanRefSupport.java index c928c04fe6..0e4135d5bc 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerInvestmentPlanRefSupport.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerInvestmentPlanRefSupport.java @@ -1,20 +1,17 @@ package name.abuchen.portfolio.model.ledger.compatibility; -import java.util.ArrayList; import java.util.List; import java.util.Objects; -import name.abuchen.portfolio.model.LedgerDiagnosticCode; import name.abuchen.portfolio.model.Client; -import name.abuchen.portfolio.model.InvestmentPlan; import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; /** - * Keeps generated transaction references valid during Ledger conversions. - * This is internal support for investment-plan references. It prevents conversions from - * guessing a target when the old reference is ambiguous. + * Keeps generated InvestmentPlan metadata compatible with Ledger conversions. + * Plan executions are now linked by plan key and entry execution metadata, so role-only + * conversions no longer need to rewrite projection-scoped plan references. */ final class LedgerInvestmentPlanRefSupport { @@ -31,158 +28,24 @@ static RoleChange roleChange(String projectionUUID, LedgerProjectionRole sourceR static void requireCurrentRefsResolveUniquely(Client client, LedgerEntry entry) { - forEachRef(client, entry, ref -> { - var matches = matchingProjections(entry, ref); - if (matches != 1) - throw new UnsupportedOperationException(referenceResolutionFailure(matches)); - }); + // Plan linkage is entry metadata; there are no projection-scoped plan refs to validate. } static SplitExecutionRefUpdates prepareAccountTransferSplitExecutionRefUpdates(Client client, LedgerEntry entry, LedgerProjectionRef sourceProjection, LedgerProjectionRef targetProjection, LedgerEntry removalEntry, LedgerEntry depositEntry) { - var removalProjection = uniqueAccountProjection(removalEntry); - var depositProjection = uniqueAccountProjection(depositEntry); - var updates = new ArrayList(); - - for (var plan : client.getPlans()) - { - var refs = plan.getLedgerExecutionRefs(); - - for (int ii = 0; ii < refs.size(); ii++) - { - var ref = refs.get(ii); - - if (!entry.getUUID().equals(ref.getLedgerEntryUUID())) - continue; - - if (splitSide(ref, sourceProjection, targetProjection) == SplitSide.SOURCE) - updates.add(new ExecutionRefUpdate(plan, ii, new InvestmentPlan.LedgerExecutionRef( - removalEntry.getUUID(), removalProjection.getUUID(), removalProjection.getRole()))); - else - updates.add(new ExecutionRefUpdate(plan, ii, new InvestmentPlan.LedgerExecutionRef( - depositEntry.getUUID(), depositProjection.getUUID(), depositProjection.getRole()))); - } - } - - return new SplitExecutionRefUpdates(List.copyOf(updates)); + return new SplitExecutionRefUpdates(List.of()); } static void requireRefsFollowRoleChanges(Client client, LedgerEntry entry, RoleChange... changes) { - requireCurrentRefsResolveUniquely(client, entry); - - forEachRef(client, entry, ref -> { - if (!canFollowAny(ref, changes)) - throw new UnsupportedOperationException(cannotFollowFailure(ref)); - }); + // Role changes do not affect plan key / execution-date linkage. } static void updateProjectionRoles(Client client, LedgerEntry entry, RoleChange... changes) { - for (var plan : client.getPlans()) - { - var refs = plan.getLedgerExecutionRefs(); - - for (int ii = 0; ii < refs.size(); ii++) - { - var ref = refs.get(ii); - - if (!entry.getUUID().equals(ref.getLedgerEntryUUID())) - continue; - - for (var change : changes) - { - if (canFollow(ref, change) && ref.getProjectionRole() != null) - { - refs.set(ii, new InvestmentPlan.LedgerExecutionRef(ref.getLedgerEntryUUID(), - ref.getProjectionUUID(), change.targetRole())); - break; - } - } - } - } - } - - private static void forEachRef(Client client, LedgerEntry entry, RefConsumer consumer) - { - for (var plan : client.getPlans()) - for (var ref : plan.getLedgerExecutionRefs()) - if (entry.getUUID().equals(ref.getLedgerEntryUUID())) - consumer.accept(ref); - } - - private static long matchingProjections(LedgerEntry entry, InvestmentPlan.LedgerExecutionRef ref) - { - return entry.getProjectionRefs().stream().filter(projection -> matches(ref, projection)).count(); - } - - private static boolean matches(InvestmentPlan.LedgerExecutionRef ref, LedgerProjectionRef projection) - { - return (ref.getProjectionUUID() == null || ref.getProjectionUUID().equals(projection.getUUID())) - && (ref.getProjectionRole() == null || ref.getProjectionRole() == projection.getRole()); - } - - private static boolean canFollowAny(InvestmentPlan.LedgerExecutionRef ref, RoleChange... changes) - { - for (var change : changes) - if (canFollow(ref, change)) - return true; - - return false; - } - - private static boolean canFollow(InvestmentPlan.LedgerExecutionRef ref, RoleChange change) - { - if (ref.getProjectionUUID() != null && !ref.getProjectionUUID().equals(change.projectionUUID())) - return false; - - if (ref.getProjectionUUID() == null && ref.getProjectionRole() == null) - return false; - - return ref.getProjectionRole() == null || ref.getProjectionRole() == change.sourceRole(); - } - - private static SplitSide splitSide(InvestmentPlan.LedgerExecutionRef ref, LedgerProjectionRef sourceProjection, - LedgerProjectionRef targetProjection) - { - var matchesSource = matches(ref, sourceProjection); - var matchesTarget = matches(ref, targetProjection); - - if (matchesSource == matchesTarget) - throw new UnsupportedOperationException( - LedgerDiagnosticCode.LEDGER_CONVERT_045.message("Ledger plan reference cannot be mapped to a split transfer side")); //$NON-NLS-1$ - - return matchesSource ? SplitSide.SOURCE : SplitSide.TARGET; - } - - private static LedgerProjectionRef uniqueAccountProjection(LedgerEntry entry) - { - var projections = entry.getProjectionRefs().stream() - .filter(projection -> projection.getRole() == LedgerProjectionRole.ACCOUNT).toList(); - - if (projections.size() != 1) - throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PROJ_041 - .message("Ledger plan reference cannot be mapped to a split transfer side")); //$NON-NLS-1$ - - return projections.get(0); - } - - private static String referenceResolutionFailure(long matches) - { - if (matches == 0) - return "Ledger plan reference cannot resolve selected projection"; //$NON-NLS-1$ - - return "Ledger plan reference would become ambiguous"; //$NON-NLS-1$ - } - - private static String cannotFollowFailure(InvestmentPlan.LedgerExecutionRef ref) - { - if (ref.getProjectionUUID() != null) - return "Ledger plan reference projection would be removed by conversion"; //$NON-NLS-1$ - - return "Ledger plan reference would become ambiguous after conversion"; //$NON-NLS-1$ + // Projection role rewrites are no longer part of InvestmentPlan linkage. } record RoleChange(String projectionUUID, LedgerProjectionRole sourceRole, LedgerProjectionRole targetRole) @@ -193,23 +56,11 @@ record SplitExecutionRefUpdates(List updates) { void apply() { - for (var update : updates) - update.plan().getLedgerExecutionRefs().set(update.index(), update.ref()); + // No projection-scoped plan refs remain to update. } } - private record ExecutionRefUpdate(InvestmentPlan plan, int index, InvestmentPlan.LedgerExecutionRef ref) - { - } - - private enum SplitSide - { - SOURCE, TARGET - } - - @FunctionalInterface - private interface RefConsumer + private record ExecutionRefUpdate() { - void accept(InvestmentPlan.LedgerExecutionRef ref); } } From e0f80dcbe0b1a6f6e9c83bd649c0cd8d82feef44 Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:12:54 +0200 Subject: [PATCH 18/68] Persist Ledger XML without UUID truth Write Ledger XML entries and postings as semantic contained facts, including plan execution metadata and posting semantic fields, and stop writing entry/posting UUIDs or projection refs. Legacy XML migration now emits semantic postings so released legacy rows still materialize from derived descriptors. This makes XML persistence match the no-UUID ledger truth model while preserving descriptor-based runtime views, plan-key execution lookup, and old released transaction XML loading. Protobuf persistence, Java model removal of projection refs/UUID fields, and global fixture/protobuf regeneration are intentionally left for later phases. --- .../ledger/LedgerSpinOffScenarioTest.java | 52 +++++-- .../ledger/LedgerXmlPersistenceTest.java | 145 ++++++------------ .../portfolio/model/ClientFactory.java | 56 ++++++- .../ledger/LedgerStructuralValidator.java | 3 + .../LegacyTransactionToLedgerMigrator.java | 72 +++++++++ 5 files changed, 213 insertions(+), 115 deletions(-) diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffScenarioTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffScenarioTest.java index 268d835941..acfb8ab2b4 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffScenarioTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffScenarioTest.java @@ -580,22 +580,34 @@ private void assertSpinOffScenarioClient(Client client) assertThat(entry.getType(), is(LedgerEntryType.SPIN_OFF)); assertThat(entry.getUpdatedAt(), is(UPDATED_AT)); assertThat(entry.getPostings().size(), is(6)); - assertThat(entry.getProjectionRefs().size(), is(4)); - assertProjectionTargets(entry, LedgerProjectionRole.OLD_SECURITY_LEG, - securityPosting(entry, siemens(client), CorporateActionLeg.SOURCE_SECURITY.getCode(), - siemensEnergy(client)), - null); - assertProjectionTargets(entry, LedgerProjectionRole.DELIVERY_INBOUND, - securityPosting(entry, siemens(client), CorporateActionLeg.TARGET_SECURITY.getCode(), - siemens(client)), - null); - assertProjectionTargets(entry, LedgerProjectionRole.NEW_SECURITY_LEG, - securityPosting(entry, siemensEnergy(client), CorporateActionLeg.TARGET_SECURITY.getCode(), - siemensEnergy(client)), - null); - assertProjectionTargets(entry, LedgerProjectionRole.CASH_COMPENSATION, - primaryPosting(entry, LedgerProjectionRole.CASH_COMPENSATION), - primaryPosting(entry, LedgerProjectionRole.CASH_COMPENSATION)); + var oldSecurityLeg = securityPosting(entry, siemens(client), CorporateActionLeg.SOURCE_SECURITY.getCode(), + siemensEnergy(client)); + var retainedSecurityLeg = securityPosting(entry, siemens(client), CorporateActionLeg.TARGET_SECURITY.getCode(), + siemens(client)); + var newSecurityLeg = securityPosting(entry, siemensEnergy(client), CorporateActionLeg.TARGET_SECURITY.getCode(), + siemensEnergy(client)); + + if (entry.getProjectionRefs().isEmpty()) + { + var compensationLeg = cashCompensationPosting(entry); + + assertThat(oldSecurityLeg.getCorporateActionLeg(), is(CorporateActionLeg.SOURCE_SECURITY)); + assertThat(retainedSecurityLeg.getCorporateActionLeg(), is(CorporateActionLeg.TARGET_SECURITY)); + assertThat(retainedSecurityLeg.getLocalKey(), is(LedgerProjectionRole.DELIVERY_INBOUND.name())); + assertThat(newSecurityLeg.getCorporateActionLeg(), is(CorporateActionLeg.TARGET_SECURITY)); + assertThat(newSecurityLeg.getLocalKey(), is(LedgerProjectionRole.NEW_SECURITY_LEG.name())); + assertThat(compensationLeg.getCorporateActionLeg(), is(CorporateActionLeg.CASH_COMPENSATION)); + } + else + { + var compensationLeg = primaryPosting(entry, LedgerProjectionRole.CASH_COMPENSATION); + + assertThat(entry.getProjectionRefs().size(), is(4)); + assertProjectionTargets(entry, LedgerProjectionRole.OLD_SECURITY_LEG, oldSecurityLeg, null); + assertProjectionTargets(entry, LedgerProjectionRole.DELIVERY_INBOUND, retainedSecurityLeg, null); + assertProjectionTargets(entry, LedgerProjectionRole.NEW_SECURITY_LEG, newSecurityLeg, null); + assertProjectionTargets(entry, LedgerProjectionRole.CASH_COMPENSATION, compensationLeg, compensationLeg); + } assertThat(client.getPortfolios().get(0).getTransactions().size(), is(4)); assertThat(client.getAccounts().get(0).getTransactions().size(), is(3)); assertThat(buyProjection(client, siemens(client)).getType(), is(PortfolioTransaction.Type.BUY)); @@ -659,6 +671,14 @@ private LedgerPosting securityPosting(LedgerEntry entry, Security security, .findFirst().orElseThrow(); } + private LedgerPosting cashCompensationPosting(LedgerEntry entry) + { + return entry.getPostings().stream() + .filter(posting -> posting.getSemanticRole() == LedgerPostingSemanticRole.CASH_COMPENSATION) + .filter(posting -> posting.getCorporateActionLeg() == CorporateActionLeg.CASH_COMPENSATION) + .findFirst().orElseThrow(); + } + private void assertProjectionTargets(LedgerEntry entry, LedgerProjectionRole role, LedgerPosting primaryPosting, LedgerPosting postingGroup) { diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerXmlPersistenceTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerXmlPersistenceTest.java index b0db7ea086..50cfb59e6a 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerXmlPersistenceTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerXmlPersistenceTest.java @@ -18,7 +18,6 @@ import java.nio.file.Files; import java.time.LocalDate; import java.time.LocalDateTime; -import java.util.ArrayList; import java.util.List; import org.junit.Test; @@ -223,32 +222,34 @@ public void testLedgerXmlRoundtripPreservesTruthAndDoesNotPersistRuntimeProjecti CorporateActionKind.SPIN_OFF.getCode())); LedgerProjectionService.materialize(client); - var entryUUID = client.getLedger().getEntries().get(0).getUUID(); - var postingUUIDs = client.getLedger().getEntries().get(0).getPostings().stream().map(LedgerPosting::getUUID) - .toList(); - var projectionUUID = client.getLedger().getEntries().get(0).getProjectionRefs().get(0).getUUID(); var xml = save(client); assertTrue(xml.contains("")); assertFalse(xml.contains(" posting.getSemanticRole() == LedgerPostingSemanticRole.CASH).findFirst() + .orElseThrow(); - assertThat(reloadedEntry.getUUID(), is(entryUUID)); - assertThat(reloadedPostingUUIDs, is(postingUUIDs)); - assertThat(reloadedEntry.getProjectionRefs().get(0).getUUID(), is(projectionUUID)); + assertTrue(reloadedEntry.getProjectionRefs().isEmpty()); assertThat(reloadedEntry.getDateTime(), is(DATE_TIME)); assertThat(reloadedEntry.getNote(), is("note")); assertThat(reloadedEntry.getSource(), is("source")); assertThat(reloadedEntry.getParameters().size(), is(1)); assertThat(reloadedEntry.getParameters().get(0).getType(), is(LedgerParameterType.CORPORATE_ACTION_KIND)); assertThat(reloadedEntry.getParameters().get(0).getValue(), is(CorporateActionKind.SPIN_OFF.getCode())); + assertThat(reloadedCashPosting.getDirection(), is(LedgerPostingDirection.NEUTRAL)); assertThat(reloadedProjection, instanceOf(LedgerBackedTransaction.class)); assertThat(reloadedProjection.getExDate(), is(EX_DATE)); assertThat(reloadedProjection.getUnits().count(), is(3L)); @@ -287,11 +288,6 @@ public void testLegacyLedgerParameterAliasesLoadAndSaveCurrentForm() throws Exce { var client = legacyLedgerParameterCompatibilityClient(); var entry = client.getLedger().getEntries().get(0); - var sourceProjection = projection(entry, LedgerProjectionRole.OLD_SECURITY_LEG); - var cashProjection = projection(entry, LedgerProjectionRole.CASH_COMPENSATION); - var expectedSourcePostingUUID = sourceProjection.getPrimaryPostingUUID(); - var expectedCashPostingUUID = cashProjection.getPrimaryPostingUUID(); - var expectedCashGroupUUID = cashProjection.getPostingGroupUUID(); var expectedParameterCount = parameterCount(entry); var xml = save(client); var legacyXml = legacyLedgerParameterXml(xml); @@ -301,10 +297,10 @@ public void testLegacyLedgerParameterAliasesLoadAndSaveCurrentForm() throws Exce var loaded = load(legacyXml); var loadedEntry = loaded.getLedger().getEntries().get(0); - var loadedSourceProjection = projection(loadedEntry, LedgerProjectionRole.OLD_SECURITY_LEG); - var loadedCashProjection = projection(loadedEntry, LedgerProjectionRole.CASH_COMPENSATION); - var loadedSourcePosting = posting(loadedEntry, expectedSourcePostingUUID); - var loadedCashPosting = posting(loadedEntry, expectedCashPostingUUID); + var loadedSourcePosting = semanticPosting(loadedEntry, LedgerPostingSemanticRole.SECURITY, + CorporateActionLeg.SOURCE_SECURITY); + var loadedCashPosting = semanticPosting(loadedEntry, LedgerPostingSemanticRole.CASH_COMPENSATION, + CorporateActionLeg.CASH_COMPENSATION); var loadedAccount = loaded.getAccounts().get(0); var loadedPortfolio = loaded.getPortfolios().get(0); var loadedSourceSecurity = loaded.getSecurities().get(0); @@ -317,12 +313,8 @@ public void testLegacyLedgerParameterAliasesLoadAndSaveCurrentForm() throws Exce is(CashCompensationKind.CASH_IN_LIEU.getCode())); assertSame(loadedPortfolio, loadedSourcePosting.getPortfolio()); assertSame(loadedSourceSecurity, loadedSourcePosting.getSecurity()); - assertSame(loadedPortfolio, loadedSourceProjection.getPortfolio()); assertSame(loadedAccount, loadedCashPosting.getAccount()); - assertSame(loadedAccount, loadedCashProjection.getAccount()); - assertThat(primaryPostingUUID(loadedSourceProjection), is(expectedSourcePostingUUID)); - assertThat(primaryPostingUUID(loadedCashProjection), is(expectedCashPostingUUID)); - assertThat(postingGroupUUID(loadedCashProjection), is(expectedCashGroupUUID)); + assertTrue(loadedEntry.getProjectionRefs().isEmpty()); assertThat(loaded.getAccounts().get(0).getTransactions().get(0), instanceOf(LedgerBackedTransaction.class)); assertThat(loaded.getPortfolios().get(0).getTransactions().stream() .filter(LedgerBackedTransaction.class::isInstance).count(), is(2L)); @@ -332,6 +324,7 @@ public void testLegacyLedgerParameterAliasesLoadAndSaveCurrentForm() throws Exce assertFalse(currentXml.contains("")); assertFalse(currentXml.contains("ledger-posting-parameter-type")); assertFalse(currentXml.contains("LedgerBacked")); + assertNoLedgerUuidTruth(currentXml); assertTrue(currentXml.contains("")); } @@ -437,28 +430,6 @@ public void testSerializationFailureRestoresOwnerListsAndOrder() throws Exceptio assertThat(fixture.plan().getTransactions(), is(planBefore)); } - /** - * Verifies that preparation failures restore owner lists, projection refs, and order. - * Save-time cleanup must be atomic from the caller's point of view. - */ - @Test - public void testPreparationFailureRestoresOwnerListsRefsAndOrder() throws Exception - { - var plan = new FailingLedgerExecutionRefPlan(); - var fixture = saveRestoreFixture(plan); - var accountBefore = List.copyOf(fixture.account().getTransactions()); - var portfolioBefore = List.copyOf(fixture.portfolio().getTransactions()); - var planBefore = List.copyOf(fixture.plan().getTransactions()); - var refsBefore = List.copyOf(fixture.plan().getLedgerExecutionRefs()); - - assertThrows(IllegalStateException.class, () -> save(fixture.client())); - - assertThat(fixture.account().getTransactions(), is(accountBefore)); - assertThat(fixture.portfolio().getTransactions(), is(portfolioBefore)); - assertThat(fixture.plan().getTransactions(), is(planBefore)); - assertThat(fixture.plan().getLedgerExecutionRefs(), is(refsBefore)); - } - /** * Verifies that invalid ledger XML save failures include formatted diagnostics. * The caller must see the concrete ledger validation problem instead of a generic error. @@ -493,11 +464,11 @@ public void testInvalidLedgerSaveExceptionUsesFormattedDiagnostics() } /** - * Verifies that plan execution refs roundtrip through XML and still resolve. - * A generated booking must be found from the ledger entry and projection after reload. + * Verifies that plan execution metadata roundtrips through XML and still resolves. + * A generated booking must be found from the ledger entry metadata after reload. */ @Test - public void testInvestmentPlanLedgerExecutionRefsRoundtripAndResolve() throws Exception + public void testInvestmentPlanExecutionMetadataRoundtripAndResolve() throws Exception { var client = new Client(); var account = register(client, account()); @@ -518,18 +489,27 @@ public void testInvestmentPlanLedgerExecutionRefsRoundtripAndResolve() throws Ex var xml = save(client); - assertTrue(xml.contains("")); + assertFalse(xml.contains("")); + assertTrue(xml.contains("" + plan.getPlanKey() + "")); + assertTrue(xml.contains("" + plan.getPlanKey() + "")); + assertTrue(xml.contains("" + DATE_TIME.toLocalDate() + "")); + assertTrue(xml.contains("PORTFOLIO")); assertFalse(xml.contains("LedgerBacked")); + assertNoLedgerUuidTruth(xml); var loaded = load(xml); var loadedPlan = loaded.getPlans().get(0); + var loadedEntry = loaded.getLedger().getEntries().get(0); assertThat(loadedPlan.getTransactions().size(), is(0)); - assertThat(loadedPlan.getLedgerExecutionRefs().size(), is(1)); - assertThat(loadedPlan.getLedgerExecutionRefs().get(0).getProjectionUUID(), is(portfolioProjection.getUUID())); + assertThat(loadedPlan.getLedgerExecutionRefs().size(), is(0)); + assertThat(loadedEntry.getGeneratedByPlanKey(), is(loadedPlan.getPlanKey())); + assertThat(loadedEntry.getPlanExecutionDate(), is(DATE_TIME.toLocalDate())); + assertThat(loadedEntry.getPreferredViewKind(), is("PORTFOLIO")); assertThat(loadedPlan.getTransactions(loaded).size(), is(1)); assertThat(loadedPlan.getTransactions(loaded).get(0).getOwner(), is(loaded.getPortfolios().get(0))); - assertThat(loadedPlan.getTransactions(loaded).get(0).getTransaction().getUUID(), is(portfolioProjection.getUUID())); + assertThat(loadedPlan.getTransactions(loaded).get(0).getTransaction(), + instanceOf(LedgerBackedTransaction.class)); } /** @@ -640,6 +620,20 @@ private void assertValid(Client client) assertTrue(LedgerStructuralValidator.validate(client.getLedger()).isOK()); } + private void assertNoLedgerUuidTruth(String xml) + { + assertFalse(xml.matches("(?s).*]*\\buuid=.*")); + assertFalse(xml.matches("(?s).*]*\\buuid=.*")); + assertFalse(xml.contains("")); + assertFalse(xml.contains(" projection.getRole() == role).findFirst() - .orElseThrow(); - } - - private String primaryPostingUUID(LedgerProjectionRef projection) - { - return projection.getPrimaryMembership().map(ProjectionMembership::getPostingUUID) - .orElse(projection.getPrimaryPostingUUID()); - } - - private String postingGroupUUID(LedgerProjectionRef projection) - { - return projection.getMembershipsByRole(ProjectionMembershipRole.GROUP_ANCHOR).stream().findFirst() - .map(ProjectionMembership::getPostingUUID).orElse(projection.getPostingGroupUUID()); - } - - private LedgerPosting posting(LedgerEntry entry, String uuid) - { - return entry.getPostings().stream().filter(posting -> uuid.equals(posting.getUUID())).findFirst() + return entry.getPostings().stream().filter(posting -> posting.getSemanticRole() == semanticRole) + .filter(posting -> posting.getCorporateActionLeg() == corporateActionLeg).findFirst() .orElseThrow(); } @@ -852,27 +830,4 @@ public void write(int b) throws IOException throw new IOException("forced serialization failure"); } } - - private static final class FailingLedgerExecutionRefPlan extends InvestmentPlan - { - private final List refs = new FailingLedgerExecutionRefList(); - - @Override - public List getLedgerExecutionRefs() - { - return refs; - } - } - - private static final class FailingLedgerExecutionRefList extends ArrayList - { - private static final long serialVersionUID = 1L; - - @Override - public boolean add(InvestmentPlan.LedgerExecutionRef ref) - { - super.add(ref); - throw new IllegalStateException("forced preparation failure"); - } - } } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ClientFactory.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ClientFactory.java index 92dfde559c..cc2edffb8a 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ClientFactory.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ClientFactory.java @@ -95,11 +95,15 @@ import name.abuchen.portfolio.model.ledger.LedgerParameter; import name.abuchen.portfolio.model.ledger.LedgerParameter.ValueKind; import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerPostingDirection; +import name.abuchen.portfolio.model.ledger.LedgerPostingSemanticRole; +import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; import name.abuchen.portfolio.model.ledger.ProjectionMembership; import name.abuchen.portfolio.model.ledger.ProjectionMembershipRole; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; @@ -305,7 +309,6 @@ public void marshal(Object source, HierarchicalStreamWriter writer, MarshallingC { var entry = (LedgerEntry) source; - writeAttribute(writer, "uuid", entry.getUUID()); //$NON-NLS-1$ writeAttribute(writer, "type", entry.getType()); //$NON-NLS-1$ writeAttribute(writer, "dateTime", entry.getDateTime()); //$NON-NLS-1$ writeAttribute(writer, "updatedAt", entry.getUpdatedAt()); //$NON-NLS-1$ @@ -322,8 +325,6 @@ public void marshal(Object source, HierarchicalStreamWriter writer, MarshallingC writeValue(writer, "preferredViewKind", entry.getPreferredViewKind()); //$NON-NLS-1$ writeParameters(writer, context, entry.getParameters()); writeCollection(writer, context, "postings", "ledger-posting", entry.getPostings()); //$NON-NLS-1$ //$NON-NLS-2$ - writeCollection(writer, context, "projectionRefs", "ledger-projection-ref", //$NON-NLS-1$ //$NON-NLS-2$ - entry.getProjectionRefs()); } @Override @@ -417,7 +418,6 @@ public void marshal(Object source, HierarchicalStreamWriter writer, MarshallingC { var posting = (LedgerPosting) source; - writeAttribute(writer, "uuid", posting.getUUID()); //$NON-NLS-1$ writeAttribute(writer, "type", posting.getType()); //$NON-NLS-1$ writeAttribute(writer, "amount", posting.getAmount()); //$NON-NLS-1$ writeAttribute(writer, "currency", posting.getCurrency()); //$NON-NLS-1$ @@ -425,6 +425,12 @@ public void marshal(Object source, HierarchicalStreamWriter writer, MarshallingC writeAttribute(writer, "forexCurrency", posting.getForexCurrency()); //$NON-NLS-1$ writeAttribute(writer, "exchangeRate", posting.getExchangeRate()); //$NON-NLS-1$ writeAttribute(writer, "shares", posting.getShares()); //$NON-NLS-1$ + writeAttribute(writer, "semanticRole", posting.getSemanticRole()); //$NON-NLS-1$ + writeAttribute(writer, "direction", posting.getDirection()); //$NON-NLS-1$ + writeAttribute(writer, "corporateActionLeg", posting.getCorporateActionLeg()); //$NON-NLS-1$ + writeAttribute(writer, "unitRole", posting.getUnitRole()); //$NON-NLS-1$ + writeAttribute(writer, "groupKey", posting.getGroupKey()); //$NON-NLS-1$ + writeAttribute(writer, "localKey", posting.getLocalKey()); //$NON-NLS-1$ writeObject(writer, context, "security", posting.getSecurity()); //$NON-NLS-1$ writeObject(writer, context, "account", posting.getAccount()); //$NON-NLS-1$ writeObject(writer, context, "portfolio", posting.getPortfolio()); //$NON-NLS-1$ @@ -444,6 +450,16 @@ public Object unmarshal(HierarchicalStreamReader reader, UnmarshallingContext co readAttribute(reader, "forexCurrency").ifPresent(posting::setForexCurrency); //$NON-NLS-1$ readAttribute(reader, "exchangeRate").map(BigDecimal::new).ifPresent(posting::setExchangeRate); //$NON-NLS-1$ readAttribute(reader, "shares").map(Long::parseLong).ifPresent(posting::setShares); //$NON-NLS-1$ + readAttribute(reader, "semanticRole").map(LedgerPostingSemanticRole::valueOf) //$NON-NLS-1$ + .ifPresent(posting::setSemanticRole); + readAttribute(reader, "direction").map(LedgerPostingDirection::valueOf) //$NON-NLS-1$ + .ifPresent(posting::setDirection); + readAttribute(reader, "corporateActionLeg").map(CorporateActionLeg::valueOf) //$NON-NLS-1$ + .ifPresent(posting::setCorporateActionLeg); + readAttribute(reader, "unitRole").map(LedgerPostingUnitRole::valueOf) //$NON-NLS-1$ + .ifPresent(posting::setUnitRole); + readAttribute(reader, "groupKey").ifPresent(posting::setGroupKey); //$NON-NLS-1$ + readAttribute(reader, "localKey").ifPresent(posting::setLocalKey); //$NON-NLS-1$ while (reader.hasMoreChildren()) { @@ -465,6 +481,19 @@ public Object unmarshal(HierarchicalStreamReader reader, UnmarshallingContext co case "account" -> posting.setAccount((Account) context.convertAnother(posting, Account.class)); //$NON-NLS-1$ case "portfolio" -> posting.setPortfolio((Portfolio) context.convertAnother(posting, //$NON-NLS-1$ Portfolio.class)); + case "semanticRole" -> posting.setSemanticRole( //$NON-NLS-1$ + (LedgerPostingSemanticRole) context.convertAnother(posting, + LedgerPostingSemanticRole.class)); + case "direction" -> posting.setDirection( //$NON-NLS-1$ + (LedgerPostingDirection) context.convertAnother(posting, + LedgerPostingDirection.class)); + case "corporateActionLeg" -> posting.setCorporateActionLeg( //$NON-NLS-1$ + (CorporateActionLeg) context.convertAnother(posting, CorporateActionLeg.class)); + case "unitRole" -> posting.setUnitRole( //$NON-NLS-1$ + (LedgerPostingUnitRole) context.convertAnother(posting, + LedgerPostingUnitRole.class)); + case "groupKey" -> posting.setGroupKey(reader.getValue()); //$NON-NLS-1$ + case "localKey" -> posting.setLocalKey(reader.getValue()); //$NON-NLS-1$ case "parameters" -> readParameters(reader, context, posting); //$NON-NLS-1$ default -> { // Ignore unknown LedgerPosting fields to preserve load recovery behavior. @@ -740,11 +769,19 @@ private void prepareLedgerXmlSave(Client client, LedgerXmlSaveState saveState) t { validateLedger(client); + var projectionUUIDs = ledgerProjectionUUIDs(client); + for (var account : client.getAccounts()) + { + saveState.removeLegacyProjectionShadows(account.getTransactions(), projectionUUIDs); saveState.removeLedgerBackedTransactions(account.getTransactions()); + } for (var portfolio : client.getPortfolios()) + { + saveState.removeLegacyProjectionShadows(portfolio.getTransactions(), projectionUUIDs); saveState.removeLedgerBackedTransactions(portfolio.getTransactions()); + } for (var plan : client.getPlans()) saveState.replaceLedgerBackedPlanTransactions(plan); @@ -847,6 +884,17 @@ private void removeLedgerBackedTransactions(List transact } } + private void removeLegacyProjectionShadows(List transactions, Set projectionUUIDs) + { + for (var index = transactions.size() - 1; index >= 0; index--) + { + var transaction = transactions.get(index); + + if (!(transaction instanceof LedgerBackedTransaction) && projectionUUIDs.contains(transaction.getUUID())) + remove(transactions, index); + } + } + private void replaceLedgerBackedPlanTransactions(InvestmentPlan plan) { for (var index = plan.getTransactions().size() - 1; index >= 0; index--) 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 index f56f386173..47baac7e8f 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerStructuralValidator.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerStructuralValidator.java @@ -282,6 +282,9 @@ private static void validateFixedShapeProjectionRoles(LedgerEntry entry, List(LedgerProjectionRole.class); diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigrator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigrator.java index ab1c0b3c10..26d24cb3be 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigrator.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigrator.java @@ -28,6 +28,9 @@ import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerParameter; import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerPostingDirection; +import name.abuchen.portfolio.model.ledger.LedgerPostingSemanticRole; +import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; @@ -131,6 +134,8 @@ private void migrateAccountOnly(Account account, AccountTransaction transaction, LedgerParameter.ofLocalDateTime(LedgerParameterType.EX_DATE, transaction.getExDate())); + MigrationGraphBuilder.markPrimary(posting, MigrationGraphBuilder.semanticRole(postingType), + LedgerPostingDirection.NEUTRAL, LedgerProjectionRole.ACCOUNT); MigrationGraphBuilder.addPosting(entry, posting); var unitPostings = MigrationGraphBuilder.addUnitPostings(entry, transaction); var projection = MigrationGraphBuilder.accountProjection(transaction.getUUID(), LedgerProjectionRole.ACCOUNT, @@ -211,6 +216,14 @@ private void migrateBuySell(BuySellEntry buySellEntry, MigrationPlan plan) var cashPosting = MigrationGraphBuilder.cashPosting(LedgerPostingType.CASH, account, accountTransaction); var securityPosting = MigrationGraphBuilder.securityPosting(portfolio, portfolioTransaction); + MigrationGraphBuilder.markPrimary(cashPosting, LedgerPostingSemanticRole.CASH, + entryType == LedgerEntryType.BUY ? LedgerPostingDirection.OUTBOUND + : LedgerPostingDirection.INBOUND, + LedgerProjectionRole.ACCOUNT); + MigrationGraphBuilder.markPrimary(securityPosting, LedgerPostingSemanticRole.SECURITY, + entryType == LedgerEntryType.BUY ? LedgerPostingDirection.INBOUND + : LedgerPostingDirection.OUTBOUND, + LedgerProjectionRole.PORTFOLIO); MigrationGraphBuilder.addPosting(entry, cashPosting); MigrationGraphBuilder.addPosting(entry, securityPosting); var unitPostings = MigrationGraphBuilder.addUnitPostings(entry, portfolioTransaction); @@ -301,6 +314,10 @@ private void migrateAccountTransfer(AccountTransferEntry transferEntry, Migratio var targetPosting = MigrationGraphBuilder.cashPosting(LedgerPostingType.CASH, targetAccount, targetTransaction); + MigrationGraphBuilder.markPrimary(sourcePosting, LedgerPostingSemanticRole.CASH, + LedgerPostingDirection.OUTBOUND, LedgerProjectionRole.SOURCE_ACCOUNT); + MigrationGraphBuilder.markPrimary(targetPosting, LedgerPostingSemanticRole.CASH, + LedgerPostingDirection.INBOUND, LedgerProjectionRole.TARGET_ACCOUNT); MigrationGraphBuilder.addPosting(entry, sourcePosting); MigrationGraphBuilder.addPosting(entry, targetPosting); MigrationGraphBuilder.addProjectionRef(entry, MigrationGraphBuilder.accountProjection( @@ -366,6 +383,10 @@ private void migratePortfolioTransfer(PortfolioTransferEntry transferEntry, Migr var sourcePosting = MigrationGraphBuilder.securityPosting(sourcePortfolio, sourceTransaction); var targetPosting = MigrationGraphBuilder.securityPosting(targetPortfolio, targetTransaction); + MigrationGraphBuilder.markPrimary(sourcePosting, LedgerPostingSemanticRole.SECURITY, + LedgerPostingDirection.OUTBOUND, LedgerProjectionRole.SOURCE_PORTFOLIO); + MigrationGraphBuilder.markPrimary(targetPosting, LedgerPostingSemanticRole.SECURITY, + LedgerPostingDirection.INBOUND, LedgerProjectionRole.TARGET_PORTFOLIO); MigrationGraphBuilder.addPosting(entry, sourcePosting); MigrationGraphBuilder.addPosting(entry, targetPosting); MigrationGraphBuilder.addProjectionRef(entry, MigrationGraphBuilder.portfolioProjection( @@ -474,6 +495,10 @@ private void migrateDelivery(Portfolio portfolio, PortfolioTransaction transacti var entry = MigrationGraphBuilder.entry(transaction, entryType); var posting = MigrationGraphBuilder.securityPosting(portfolio, transaction); + MigrationGraphBuilder.markPrimary(posting, LedgerPostingSemanticRole.SECURITY, + entryType == LedgerEntryType.DELIVERY_INBOUND ? LedgerPostingDirection.INBOUND + : LedgerPostingDirection.OUTBOUND, + role); MigrationGraphBuilder.addPosting(entry, posting); var unitPostings = MigrationGraphBuilder.addUnitPostings(entry, transaction); var projection = MigrationGraphBuilder.portfolioProjection(transaction.getUUID(), role, portfolio, @@ -737,9 +762,56 @@ private static LedgerPosting unitPosting(Unit unit) posting.setExchangeRate(unit.getExchangeRate()); } + markUnit(posting); + return posting; } + private static void markPrimary(LedgerPosting posting, LedgerPostingSemanticRole semanticRole, + LedgerPostingDirection direction, LedgerProjectionRole role) + { + posting.setSemanticRole(semanticRole); + posting.setDirection(direction); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + posting.setLocalKey(role.name()); + } + + private static void markUnit(LedgerPosting posting) + { + posting.setSemanticRole(semanticRole(posting.getType())); + posting.setUnitRole(unitRole(posting.getType())); + } + + private static LedgerPostingSemanticRole semanticRole(LedgerPostingType type) + { + return switch (type) + { + case CASH -> LedgerPostingSemanticRole.CASH; + case SECURITY -> LedgerPostingSemanticRole.SECURITY; + case FEE -> LedgerPostingSemanticRole.FEE; + case TAX -> LedgerPostingSemanticRole.TAX; + case GROSS_VALUE -> LedgerPostingSemanticRole.GROSS_VALUE; + case CASH_COMPENSATION -> LedgerPostingSemanticRole.CASH_COMPENSATION; + case RIGHT -> LedgerPostingSemanticRole.RIGHT; + case BOND -> LedgerPostingSemanticRole.BOND; + case ACCRUED_INTEREST -> LedgerPostingSemanticRole.ACCRUED_INTEREST; + case PRINCIPAL_REDEMPTION -> LedgerPostingSemanticRole.PRINCIPAL_REDEMPTION; + case FOREX -> LedgerPostingSemanticRole.FOREX_CONTEXT; + }; + } + + private static LedgerPostingUnitRole unitRole(LedgerPostingType type) + { + return switch (type) + { + case FEE -> LedgerPostingUnitRole.FEE; + case TAX -> LedgerPostingUnitRole.TAX; + case GROSS_VALUE -> LedgerPostingUnitRole.GROSS_VALUE; + default -> throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_IMPORT_021 + .message("Unsupported unit posting type: " + type)); //$NON-NLS-1$ + }; + } + private static String migratedEntryUUID(LedgerEntryType type, String primaryProjectionUUID) { var key = "ledger-v6:migrated-entry:" + type + ":" + primaryProjectionUUID; //$NON-NLS-1$ //$NON-NLS-2$ From a3d38eea191ea0f029b417880db58a8356dbc055 Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:58:04 +0200 Subject: [PATCH 19/68] Persist Ledger protobuf without UUID truth Write Ledger protobuf entries and postings as semantic facts with plan execution metadata, and stop writing Ledger entry/posting UUIDs or projection refs as truth. Generated protobuf sources were refreshed from the updated schema. This keeps descriptor-based runtime restoration working while making compatibility PTransaction rows boundary-only shadows with generated ledger-shadow IDs. Released legacy protobuf rows without Ledger truth still load as migration input. XML persistence, InvestmentPlan linkage semantics, descriptor materialization, and full Java model removal of Ledger UUID/projection classes are intentionally left unchanged for later phases. --- .../model/LedgerProtobufPersistenceTest.java | 320 +++-- .../model/proto/v1/ClientProtos.java | 413 +++---- .../model/proto/v1/PLedgerEntry.java | 192 +-- .../model/proto/v1/PLedgerEntryOrBuilder.java | 40 +- .../model/proto/v1/PLedgerPosting.java | 1038 ++++++++++++++++- .../proto/v1/PLedgerPostingOrBuilder.java | 118 +- .../proto/v1/PLedgerProjectionMembership.java | 90 +- .../PLedgerProjectionMembershipOrBuilder.java | 24 +- .../model/proto/v1/PLedgerProjectionRef.java | 306 +++-- .../v1/PLedgerProjectionRefOrBuilder.java | 80 +- .../portfolio/model/ProtobufWriter.java | 219 ++-- .../name/abuchen/portfolio/model/client.proto | 26 +- 12 files changed, 2077 insertions(+), 789 deletions(-) diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceTest.java index bf2cc63d0f..c9114e8eda 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceTest.java @@ -24,11 +24,12 @@ import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerParameter; import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerPostingDirection; +import name.abuchen.portfolio.model.ledger.LedgerPostingSemanticRole; +import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; import name.abuchen.portfolio.model.ledger.LedgerTransactionMetadata; -import name.abuchen.portfolio.model.ledger.ProjectionMembershipRole; import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountCashLeg; import name.abuchen.portfolio.model.ledger.compatibility.LedgerCashTransferLeg; import name.abuchen.portfolio.model.ledger.compatibility.LedgerCreationUnit; @@ -42,6 +43,7 @@ import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferSecurity; import name.abuchen.portfolio.model.ledger.compatibility.LedgerSecurityQuantity; import name.abuchen.portfolio.model.ledger.compatibility.LedgerTransactionCreator; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; @@ -52,7 +54,6 @@ import name.abuchen.portfolio.model.proto.v1.PLedgerEntry; import name.abuchen.portfolio.model.proto.v1.PLedgerParameter; import name.abuchen.portfolio.model.proto.v1.PLedgerParameterValueKind; -import name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef; import name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRole; import name.abuchen.portfolio.model.proto.v1.PTransaction; import name.abuchen.portfolio.money.CurrencyUnit; @@ -76,7 +77,7 @@ public class LedgerProtobufPersistenceTest * The shadow row is derived compatibility data, not a second booking source. */ @Test - public void testSaveWritesLedgerTruthOnField13AndAccountShadow() throws IOException + public void testSaveWritesSemanticLedgerTruthOnField13AndAccountShadow() throws IOException { var fixture = fixture(); var created = new LedgerTransactionCreator(fixture.client()).createDeposit(metadata(), @@ -84,34 +85,43 @@ public void testSaveWritesLedgerTruthOnField13AndAccountShadow() throws IOExcept created.getEntry().setUpdatedAt(UPDATED_AT); var proto = saveProto(fixture.client()); - var projectionUUID = created.getEntry().getProjectionRefs().get(0).getUUID(); + var shadowUUID = shadowUUID(created.getEntry(), LedgerProjectionRole.ACCOUNT); + var ledgerEntry = proto.getLedger().getEntries(0); + var posting = ledgerEntry.getPostings(0); assertThat(PClient.getDescriptor().findFieldByName("ledger").getNumber(), is(13)); - assertThat(PLedgerProjectionRef.getDescriptor().findFieldByName("primaryPostingUUID"), nullValue()); - assertThat(PLedgerProjectionRef.getDescriptor().findFieldByName("postingGroupUUID"), nullValue()); + assertNoLedgerUuidTruth(proto); assertTrue(proto.hasLedger()); assertThat(proto.getLedger().getEntriesCount(), is(1)); - assertThat(proto.getLedger().getEntries(0).getUuid(), is(created.getEntry().getUUID())); + assertThat(ledgerEntry.getUuid(), is("")); + assertThat(ledgerEntry.getProjectionRefsCount(), is(0)); + assertThat(posting.getUuid(), is("")); + assertThat(posting.getSemanticRole(), is(LedgerPostingSemanticRole.CASH.name())); + assertThat(posting.getDirection(), is(LedgerPostingDirection.NEUTRAL.name())); + assertThat(posting.getUnitRole(), is(LedgerPostingUnitRole.PRIMARY.name())); assertThat(proto.getTransactionsCount(), is(1)); - assertThat(proto.getTransactions(0).getUuid(), is(projectionUUID)); + assertThat(proto.getTransactions(0).getUuid(), is(shadowUUID)); assertThat(proto.getTransactions(0).getType(), is(PTransaction.Type.DEPOSIT)); var loaded = load(saveBytes(fixture.client())); assertThat(loaded.getLedger().getEntries().size(), is(1)); + assertThat(loaded.getLedger().getEntries().get(0).getProjectionRefs().size(), is(0)); + assertThat(loaded.getLedger().getEntries().get(0).getPostings().get(0).getSemanticRole(), + is(LedgerPostingSemanticRole.CASH)); assertThat(loaded.getAccounts().get(0).getTransactions().size(), is(1)); assertThat(loaded.getAccounts().get(0).getTransactions().get(0), instanceOf(LedgerBackedTransaction.class)); - assertThat(loaded.getAccounts().get(0).getTransactions().get(0).getUUID(), is(projectionUUID)); assertThat(loaded.getAllTransactions().size(), is(1)); + assertThat(saveProto(loaded).getLedger(), is(proto.getLedger())); assertValid(loaded); } /** - * Verifies that dividend protobuf roundtrip preserves ex-date, units, forex, and UUIDs. + * Verifies that dividend protobuf roundtrip preserves ex-date, units, forex, and semantic roles. * The restored ledger-backed projection must represent the same dividend booking. */ @Test - public void testDividendRoundtripPreservesExDateUnitsForexAndUUIDs() throws IOException + public void testDividendRoundtripPreservesExDateUnitsForexAndSemantics() throws IOException { var fixture = fixture(); var creator = new LedgerTransactionCreator(fixture.client()); @@ -134,31 +144,37 @@ public void testDividendRoundtripPreservesExDateUnitsForexAndUUIDs() throws IOEx new BigDecimal("1.1000"))), LedgerOptionalSecurity.of(fixture.security()), units, EX_DATE)); var entry = created.getEntry(); - var postingUUIDs = entry.getPostings().stream().map(LedgerPosting::getUUID).toList(); - var projectionUUID = entry.getProjectionRefs().get(0).getUUID(); entry.setUpdatedAt(UPDATED_AT); - var loaded = load(saveBytes(fixture.client())); + var proto = saveProto(fixture.client()); + assertNoLedgerUuidTruth(proto); + assertThat(proto.getLedger().getEntries(0).getProjectionRefsCount(), is(0)); + assertTrue(proto.getLedger().getEntries(0).getPostingsList().stream() + .anyMatch(posting -> LedgerPostingUnitRole.FEE.name().equals(posting.getUnitRole()))); + assertTrue(proto.getLedger().getEntries(0).getPostingsList().stream() + .anyMatch(posting -> LedgerPostingUnitRole.TAX.name().equals(posting.getUnitRole()))); + assertTrue(proto.getLedger().getEntries(0).getPostingsList().stream() + .anyMatch(posting -> LedgerPostingUnitRole.GROSS_VALUE.name().equals(posting.getUnitRole()))); + + var loaded = load(wrap(proto)); var reloadedEntry = loaded.getLedger().getEntries().get(0); var reloadedProjection = loaded.getAccounts().get(0).getTransactions().get(0); var cashPosting = reloadedEntry.getPostings().get(0); - assertThat(reloadedEntry.getUUID(), is(entry.getUUID())); assertThat(reloadedEntry.getType(), is(LedgerEntryType.DIVIDENDS)); assertThat(reloadedEntry.getDateTime(), is(DATE_TIME)); assertThat(reloadedEntry.getNote(), is("note")); assertThat(reloadedEntry.getSource(), is("source")); assertThat(reloadedEntry.getUpdatedAt(), is(UPDATED_AT)); - assertThat(reloadedEntry.getPostings().stream().map(LedgerPosting::getUUID).toList(), is(postingUUIDs)); - assertThat(reloadedEntry.getProjectionRefs().get(0).getUUID(), is(projectionUUID)); - assertThat(reloadedEntry.getProjectionRefs().get(0).getPrimaryMembership().orElseThrow().getPostingUUID(), - is(postingUUIDs.get(0))); - assertTrue(reloadedEntry.getProjectionRefs().get(0) - .hasMembershipRole(ProjectionMembershipRole.FEE_UNIT)); - assertTrue(reloadedEntry.getProjectionRefs().get(0) - .hasMembershipRole(ProjectionMembershipRole.TAX_UNIT)); - assertTrue(reloadedEntry.getProjectionRefs().get(0) - .hasMembershipRole(ProjectionMembershipRole.GROSS_VALUE_UNIT)); + assertThat(reloadedEntry.getProjectionRefs().size(), is(0)); + assertThat(cashPosting.getSemanticRole(), is(LedgerPostingSemanticRole.CASH)); + assertThat(cashPosting.getUnitRole(), is(LedgerPostingUnitRole.PRIMARY)); + assertTrue(reloadedEntry.getPostings().stream() + .anyMatch(posting -> posting.getUnitRole() == LedgerPostingUnitRole.FEE)); + assertTrue(reloadedEntry.getPostings().stream() + .anyMatch(posting -> posting.getUnitRole() == LedgerPostingUnitRole.TAX)); + assertTrue(reloadedEntry.getPostings().stream() + .anyMatch(posting -> posting.getUnitRole() == LedgerPostingUnitRole.GROSS_VALUE)); assertThat(cashPosting.getForexAmount(), is(Long.valueOf(Values.Amount.factorize(110)))); assertThat(cashPosting.getForexCurrency(), is(CurrencyUnit.USD)); assertThat(cashPosting.getExchangeRate(), is(new BigDecimal("1.1000"))); @@ -166,21 +182,21 @@ public void testDividendRoundtripPreservesExDateUnitsForexAndUUIDs() throws IOEx assertThat(reloadedProjection.getUnits().count(), is(3L)); assertTrue(cashPosting.getParameters().stream() .anyMatch(parameter -> parameter.getType() == LedgerParameterType.EX_DATE)); + assertThat(saveProto(loaded).getLedger(), is(proto.getLedger())); assertValid(loaded); } /** - * Verifies that protobuf load preserves ledger graph identity, order, and parameters. + * Verifies that protobuf load preserves ledger graph order, parameters, and semantic selectors. * Runtime projections must be rebuilt from the same persisted ledger facts. */ @Test - public void testProtobufLoadPreservesLedgerGraphIdentityOrderAndParameters() throws IOException + public void testProtobufLoadPreservesLedgerGraphOrderParametersAndSemantics() throws IOException { var fixture = fixture(); var entry = new LedgerEntry("entry-protobuf-load-graph"); var cashPosting = new LedgerPosting("posting-protobuf-load-a"); var feePosting = new LedgerPosting("posting-protobuf-load-b"); - var projection = new LedgerProjectionRef("projection-protobuf-load"); entry.setType(LedgerEntryType.DIVIDENDS); entry.setDateTime(DATE_TIME); @@ -194,6 +210,10 @@ public void testProtobufLoadPreservesLedgerGraphIdentityOrderAndParameters() thr cashPosting.setSecurity(fixture.security()); cashPosting.setAmount(Values.Amount.factorize(100)); cashPosting.setCurrency(CurrencyUnit.EUR); + cashPosting.setSemanticRole(LedgerPostingSemanticRole.CASH); + cashPosting.setDirection(LedgerPostingDirection.NEUTRAL); + cashPosting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + cashPosting.setGroupKey("dividend"); cashPosting.addParameter(LedgerParameter.ofLocalDateTime(LedgerParameterType.EX_DATE, EX_DATE)); cashPosting.addParameter(LedgerParameter.ofMoney(LedgerParameterType.FAIR_MARKET_VALUE, money(100))); @@ -201,24 +221,21 @@ public void testProtobufLoadPreservesLedgerGraphIdentityOrderAndParameters() thr feePosting.setAccount(fixture.account()); feePosting.setAmount(Values.Amount.factorize(1)); feePosting.setCurrency(CurrencyUnit.EUR); + feePosting.setSemanticRole(LedgerPostingSemanticRole.FEE); + feePosting.setUnitRole(LedgerPostingUnitRole.FEE); + feePosting.setGroupKey("dividend"); feePosting.addParameter(LedgerParameter.ofSecurity(LedgerParameterType.TARGET_SECURITY, fixture.security())); - projection.setRole(LedgerProjectionRole.ACCOUNT); - projection.setAccount(fixture.account()); - projection.setPrimaryPostingTargetUUID(cashPosting.getUUID()); - projection.setPostingGroupTargetUUID(feePosting.getUUID()); - entry.addPosting(cashPosting); entry.addPosting(feePosting); - entry.addProjectionRef(projection); entry.setUpdatedAt(UPDATED_AT); fixture.client().getLedger().addEntry(entry); var proto = saveProto(fixture.client()); + assertNoLedgerUuidTruth(proto); var loaded = load(wrap(proto)); var reloadedEntry = loaded.getLedger().getEntries().get(0); - assertThat(reloadedEntry.getUUID(), is(entry.getUUID())); assertThat(reloadedEntry.getType(), is(LedgerEntryType.DIVIDENDS)); assertThat(reloadedEntry.getDateTime(), is(DATE_TIME)); assertThat(reloadedEntry.getNote(), is("protobuf load note")); @@ -228,20 +245,23 @@ public void testProtobufLoadPreservesLedgerGraphIdentityOrderAndParameters() thr is(List.of(LedgerParameterType.EVENT_REFERENCE, LedgerParameterType.CORPORATE_ACTION_KIND))); assertThat(reloadedEntry.getParameters().stream().map(parameter -> parameter.getValue()).toList(), is(List.of("event-reference", "SPIN_OFF"))); - assertThat(reloadedEntry.getPostings().stream().map(LedgerPosting::getUUID).toList(), - is(List.of(cashPosting.getUUID(), feePosting.getUUID()))); + assertThat(reloadedEntry.getProjectionRefs().size(), is(0)); + assertThat(reloadedEntry.getPostings().stream().map(LedgerPosting::getType).toList(), + is(List.of(LedgerPostingType.CASH, LedgerPostingType.FEE))); + assertThat(reloadedEntry.getPostings().stream().map(LedgerPosting::getSemanticRole).toList(), + is(List.of(LedgerPostingSemanticRole.CASH, LedgerPostingSemanticRole.FEE))); + assertThat(reloadedEntry.getPostings().stream().map(LedgerPosting::getUnitRole).toList(), + is(List.of(LedgerPostingUnitRole.PRIMARY, LedgerPostingUnitRole.FEE))); + assertThat(reloadedEntry.getPostings().stream().map(LedgerPosting::getGroupKey).toList(), + is(List.of("dividend", "dividend"))); assertThat(reloadedEntry.getPostings().get(0).getParameters().stream() .map(parameter -> parameter.getType()).toList(), is(List.of(LedgerParameterType.EX_DATE, LedgerParameterType.FAIR_MARKET_VALUE))); assertThat(reloadedEntry.getPostings().get(1).getParameters().get(0).getType(), is(LedgerParameterType.TARGET_SECURITY)); assertSame(loaded.getSecurities().get(0), reloadedEntry.getPostings().get(1).getParameters().get(0).getValue()); - assertThat(reloadedEntry.getProjectionRefs().get(0).getUUID(), is(projection.getUUID())); - assertThat(reloadedEntry.getProjectionRefs().get(0).getPrimaryMembership().orElseThrow().getPostingUUID(), - is(cashPosting.getUUID())); - assertThat(reloadedEntry.getProjectionRefs().get(0) - .getMembershipsByRole(ProjectionMembershipRole.GROUP_ANCHOR).get(0).getPostingUUID(), - is(feePosting.getUUID())); + assertThat(loaded.getAccounts().get(0).getTransactions().size(), is(1)); + assertThat(loaded.getAccounts().get(0).getTransactions().get(0), instanceOf(LedgerBackedTransaction.class)); assertThat(saveProto(loaded).getLedger(), is(proto.getLedger())); assertValid(loaded); } @@ -295,49 +315,49 @@ public void testSaveWritesDerivedShadowsForCrossEntryFamilies() throws IOExcepti assertThat(proto.getLedger().getEntriesCount(), is(6)); assertThat(proto.getTransactionsCount(), is(6)); - assertCommonShadowFields(buyShadow, projectionUUID(buy, LedgerProjectionRole.PORTFOLIO), + assertCommonShadowFields(buyShadow, shadowUUID(buy, LedgerProjectionRole.PORTFOLIO), PTransaction.Type.PURCHASE, 100); assertThat(buyShadow.getPortfolio(), is(fixture.portfolio().getUUID())); assertThat(buyShadow.getAccount(), is(fixture.account().getUUID())); - assertThat(buyShadow.getOtherUuid(), is(projectionUUID(buy, LedgerProjectionRole.ACCOUNT))); + assertThat(buyShadow.getOtherUuid(), is(shadowUUID(buy, LedgerProjectionRole.ACCOUNT))); assertThat(buyShadow.getSecurity(), is(fixture.security().getUUID())); assertThat(buyShadow.getShares(), is(Values.Share.factorize(5))); assertThat(buyShadow.getUnitsCount(), is(1)); assertUnit(buyShadow.getUnits(0), Transaction.Unit.Type.FEE, 3, CurrencyUnit.USD, 6, new BigDecimal("0.5000")); - assertCommonShadowFields(sellShadow, projectionUUID(sell, LedgerProjectionRole.PORTFOLIO), + assertCommonShadowFields(sellShadow, shadowUUID(sell, LedgerProjectionRole.PORTFOLIO), PTransaction.Type.SALE, 50); assertThat(sellShadow.getPortfolio(), is(fixture.portfolio().getUUID())); assertThat(sellShadow.getAccount(), is(fixture.account().getUUID())); - assertThat(sellShadow.getOtherUuid(), is(projectionUUID(sell, LedgerProjectionRole.ACCOUNT))); + assertThat(sellShadow.getOtherUuid(), is(shadowUUID(sell, LedgerProjectionRole.ACCOUNT))); assertThat(sellShadow.getSecurity(), is(fixture.security().getUUID())); assertThat(sellShadow.getShares(), is(Values.Share.factorize(2))); - assertCommonShadowFields(cashTransferShadow, projectionUUID(cashTransfer, LedgerProjectionRole.SOURCE_ACCOUNT), + assertCommonShadowFields(cashTransferShadow, shadowUUID(cashTransfer, LedgerProjectionRole.SOURCE_ACCOUNT), PTransaction.Type.CASH_TRANSFER, 10); assertThat(cashTransferShadow.getAccount(), is(fixture.account().getUUID())); assertThat(cashTransferShadow.getOtherAccount(), is(fixture.otherAccount().getUUID())); assertThat(cashTransferShadow.getOtherUuid(), - is(projectionUUID(cashTransfer, LedgerProjectionRole.TARGET_ACCOUNT))); + is(shadowUUID(cashTransfer, LedgerProjectionRole.TARGET_ACCOUNT))); assertCommonShadowFields(securityTransferShadow, - projectionUUID(securityTransfer, LedgerProjectionRole.SOURCE_PORTFOLIO), + shadowUUID(securityTransfer, LedgerProjectionRole.SOURCE_PORTFOLIO), PTransaction.Type.SECURITY_TRANSFER, 30); assertThat(securityTransferShadow.getPortfolio(), is(fixture.portfolio().getUUID())); assertThat(securityTransferShadow.getOtherPortfolio(), is(fixture.otherPortfolio().getUUID())); assertThat(securityTransferShadow.getOtherUuid(), - is(projectionUUID(securityTransfer, LedgerProjectionRole.TARGET_PORTFOLIO))); + is(shadowUUID(securityTransfer, LedgerProjectionRole.TARGET_PORTFOLIO))); assertThat(securityTransferShadow.getSecurity(), is(fixture.security().getUUID())); assertThat(securityTransferShadow.getShares(), is(Values.Share.factorize(3))); - assertCommonShadowFields(outboundDeliveryShadow, projectionUUID(delivery, LedgerProjectionRole.DELIVERY_OUTBOUND), + assertCommonShadowFields(outboundDeliveryShadow, shadowUUID(delivery, LedgerProjectionRole.DELIVERY_OUTBOUND), PTransaction.Type.OUTBOUND_DELIVERY, 20); assertThat(outboundDeliveryShadow.getPortfolio(), is(fixture.portfolio().getUUID())); assertThat(outboundDeliveryShadow.getSecurity(), is(fixture.security().getUUID())); assertThat(outboundDeliveryShadow.getShares(), is(Values.Share.factorize(1))); assertCommonShadowFields(inboundDeliveryShadow, - projectionUUID(inboundDelivery, LedgerProjectionRole.DELIVERY_INBOUND), + shadowUUID(inboundDelivery, LedgerProjectionRole.DELIVERY_INBOUND), PTransaction.Type.INBOUND_DELIVERY, 80); assertThat(inboundDeliveryShadow.getPortfolio(), is(fixture.otherPortfolio().getUUID())); assertThat(inboundDeliveryShadow.getSecurity(), is(fixture.security().getUUID())); @@ -408,37 +428,37 @@ public void testLoadWithoutLedgerMigratesLegacyCrossEntryFamilies() throws IOExc assertThat(loaded.getLedger().getEntries().size(), is(4)); assertThat(loaded.getAllTransactions().size(), is(4)); - assertProjectionUUIDs(loaded, LedgerEntryType.BUY, projectionUUID(buy, LedgerProjectionRole.ACCOUNT), - projectionUUID(buy, LedgerProjectionRole.PORTFOLIO)); + assertProjectionUUIDs(loaded, LedgerEntryType.BUY, shadowUUID(buy, LedgerProjectionRole.ACCOUNT), + shadowUUID(buy, LedgerProjectionRole.PORTFOLIO)); assertProjectionUUIDs(loaded, LedgerEntryType.CASH_TRANSFER, - projectionUUID(cashTransfer, LedgerProjectionRole.SOURCE_ACCOUNT), - projectionUUID(cashTransfer, LedgerProjectionRole.TARGET_ACCOUNT)); + shadowUUID(cashTransfer, LedgerProjectionRole.SOURCE_ACCOUNT), + shadowUUID(cashTransfer, LedgerProjectionRole.TARGET_ACCOUNT)); assertProjectionUUIDs(loaded, LedgerEntryType.SECURITY_TRANSFER, - projectionUUID(securityTransfer, LedgerProjectionRole.SOURCE_PORTFOLIO), - projectionUUID(securityTransfer, LedgerProjectionRole.TARGET_PORTFOLIO)); + shadowUUID(securityTransfer, LedgerProjectionRole.SOURCE_PORTFOLIO), + shadowUUID(securityTransfer, LedgerProjectionRole.TARGET_PORTFOLIO)); assertProjectionUUIDs(loaded, LedgerEntryType.DELIVERY_INBOUND, - projectionUUID(delivery, LedgerProjectionRole.DELIVERY_INBOUND)); + shadowUUID(delivery, LedgerProjectionRole.DELIVERY_INBOUND)); var loadedAccountBuy = ledgerBacked(loaded.getAccounts().get(0).getTransactions(), - projectionUUID(buy, LedgerProjectionRole.ACCOUNT)); + shadowUUID(buy, LedgerProjectionRole.ACCOUNT)); var loadedPortfolioBuy = ledgerBacked(loaded.getPortfolios().get(0).getTransactions(), - projectionUUID(buy, LedgerProjectionRole.PORTFOLIO)); + shadowUUID(buy, LedgerProjectionRole.PORTFOLIO)); assertSame(loadedPortfolioBuy, loadedAccountBuy.getCrossEntry().getCrossTransaction(loadedAccountBuy)); assertSame(loaded.getPortfolios().get(0), loadedAccountBuy.getCrossEntry().getCrossOwner(loadedAccountBuy)); var loadedCashTransferOut = ledgerBacked(loaded.getAccounts().get(0).getTransactions(), - projectionUUID(cashTransfer, LedgerProjectionRole.SOURCE_ACCOUNT)); + shadowUUID(cashTransfer, LedgerProjectionRole.SOURCE_ACCOUNT)); var loadedCashTransferIn = ledgerBacked(loaded.getAccounts().get(1).getTransactions(), - projectionUUID(cashTransfer, LedgerProjectionRole.TARGET_ACCOUNT)); + shadowUUID(cashTransfer, LedgerProjectionRole.TARGET_ACCOUNT)); assertThat(((AccountTransaction) loadedCashTransferOut).getType(), is(AccountTransaction.Type.TRANSFER_OUT)); assertThat(((AccountTransaction) loadedCashTransferIn).getType(), is(AccountTransaction.Type.TRANSFER_IN)); assertSame(loadedCashTransferIn, loadedCashTransferOut.getCrossEntry().getCrossTransaction(loadedCashTransferOut)); assertSame(loaded.getAccounts().get(1), loadedCashTransferOut.getCrossEntry().getCrossOwner(loadedCashTransferOut)); var loadedSecurityTransferOut = ledgerBacked(loaded.getPortfolios().get(0).getTransactions(), - projectionUUID(securityTransfer, LedgerProjectionRole.SOURCE_PORTFOLIO)); + shadowUUID(securityTransfer, LedgerProjectionRole.SOURCE_PORTFOLIO)); var loadedSecurityTransferIn = ledgerBacked(loaded.getPortfolios().get(1).getTransactions(), - projectionUUID(securityTransfer, LedgerProjectionRole.TARGET_PORTFOLIO)); + shadowUUID(securityTransfer, LedgerProjectionRole.TARGET_PORTFOLIO)); assertThat(((PortfolioTransaction) loadedSecurityTransferOut).getType(), is(PortfolioTransaction.Type.TRANSFER_OUT)); assertThat(((PortfolioTransaction) loadedSecurityTransferIn).getType(), is(PortfolioTransaction.Type.TRANSFER_IN)); assertSame(loadedSecurityTransferIn, @@ -479,7 +499,7 @@ public void testLoadWithoutLedgerMigratesLegacyDividendWithExDateUnitsAndForex() var loaded = load(wrap(oldProto)); var entry = onlyEntry(loaded, LedgerEntryType.DIVIDENDS); var projection = (AccountTransaction) ledgerBacked(loaded.getAccounts().get(0).getTransactions(), - projectionUUID(dividend, LedgerProjectionRole.ACCOUNT)); + shadowUUID(dividend, LedgerProjectionRole.ACCOUNT)); assertThat(loaded.getLedger().getEntries().size(), is(1)); assertThat(loaded.getAllTransactions().size(), is(1)); @@ -655,6 +675,55 @@ public void testLedgerParameterBooleanAndLocalDateProtobufFieldsAreAvailable() assertThat(PLedgerParameter.getDescriptor().findFieldByName("localDateValue").getNumber(), is(15)); } + /** + * Verifies that Corporate Actions remain native Ledger entries in protobuf. + * No Corporate Action-specific legacy PTransaction enum values may be introduced. + */ + @Test + public void testLegacyTransactionTypeEnumDoesNotContainCorporateActions() + { + var transactionTypes = java.util.Arrays.stream(PTransaction.Type.values()).map(Enum::name).toList(); + + assertFalse(transactionTypes.contains("SPIN_OFF")); + assertFalse(transactionTypes.contains("STOCK_DIVIDEND")); + assertFalse(transactionTypes.contains("BONUS_ISSUE")); + assertFalse(transactionTypes.contains("RIGHTS_DISTRIBUTION")); + assertFalse(transactionTypes.contains("BOND_CONVERSION")); + } + + /** + * Verifies that native Corporate Actions persist as semantic Ledger protobuf truth. + * They must not require legacy transaction types or persisted projection refs. + */ + @Test + public void testNativeCorporateActionsRoundtripAsSemanticLedgerTruth() throws IOException + { + for (var entryType : List.of(LedgerEntryType.SPIN_OFF, LedgerEntryType.STOCK_DIVIDEND, + LedgerEntryType.BONUS_ISSUE, LedgerEntryType.RIGHTS_DISTRIBUTION, + LedgerEntryType.BOND_CONVERSION)) + { + var fixture = fixture(); + addNativeEntry(fixture, entryType); + + var proto = saveProto(fixture.client()); + + assertNoLedgerUuidTruth(proto); + assertThat(proto.getTransactionsCount(), is(0)); + assertThat(proto.getLedger().getEntries(0).getTypeId(), is(entryType.getProtobufId())); + assertTrue(proto.getLedger().getEntries(0).getPostingsList().stream() + .anyMatch(posting -> posting.hasCorporateActionLeg())); + + var loaded = load(wrap(proto)); + var reloadedEntry = loaded.getLedger().getEntries().get(0); + + assertThat(reloadedEntry.getType(), is(entryType)); + assertThat(reloadedEntry.getProjectionRefs().size(), is(0)); + assertTrue(reloadedEntry.getPostings().stream() + .anyMatch(posting -> posting.getCorporateActionLeg() != null)); + assertValid(loaded); + } + } + /** * Verifies that a boolean ledger parameter must use the matching protobuf value field. * Loading must reject a mismatched value shape instead of coercing it. @@ -757,11 +826,11 @@ public void testInvestmentPlanExecutionMetadataRoundtrip() throws IOException } /** - * Verifies that legacy protobuf plan refs are consumed as migration input. - * The legacy ledger/projection UUIDs must not become the new plan relation. + * Verifies that legacy protobuf plan refs are not treated as Ledger identity. + * The old ledger/projection UUIDs must not become the new plan relation. */ @Test - public void testLegacyInvestmentPlanLedgerExecutionRefMigratesToPlanMetadata() throws IOException + public void testLegacyInvestmentPlanLedgerExecutionRefDoesNotBecomePlanMetadata() throws IOException { var fixture = fixture(); var buy = new LedgerTransactionCreator(fixture.client()).createBuy(metadata(), cashLeg(fixture.account(), 100), @@ -786,11 +855,11 @@ public void testLegacyInvestmentPlanLedgerExecutionRefMigratesToPlanMetadata() t var loadedEntry = loaded.getLedger().getEntries().get(0); assertThat(loadedPlan.getLedgerExecutionRefs().size(), is(0)); - assertThat(loadedPlan.getTransactions(loaded).size(), is(1)); - assertThat(loadedEntry.getGeneratedByPlanKey(), is(loadedPlan.getPlanKey())); - assertFalse(loadedEntry.getGeneratedByPlanKey().equals(buy.getUUID())); - assertFalse(loadedEntry.getGeneratedByPlanKey().equals(portfolioProjection.getUUID())); - assertThat(loadedEntry.getPreferredViewKind(), is(InvestmentPlan.LedgerExecutionViewKind.PORTFOLIO.name())); + assertThat(loadedPlan.getTransactions(loaded).size(), is(0)); + assertThat(loadedEntry.getGeneratedByPlanKey(), nullValue()); + assertFalse(loadedPlan.getPlanKey().equals(buy.getUUID())); + assertFalse(loadedPlan.getPlanKey().equals(portfolioProjection.getUUID())); + assertThat(loadedEntry.getPreferredViewKind(), nullValue()); } /** @@ -806,13 +875,10 @@ public void testInvalidLedgerTruthLoadsWithoutShadowRemigration() throws IOExcep var proto = saveProto(fixture.client()).toBuilder(); var entry = proto.getLedgerBuilder().getEntriesBuilder(0); entry.addPostings(entry.getPostings(0)); - var loaded = load(wrap(proto.build())); - var result = LedgerStructuralValidator.validate(loaded.getLedger()); - assertFalse(result.isOK()); - assertTrue(result.hasIssue(LedgerStructuralValidator.IssueCode.DUPLICATE_POSTING_UUID)); - assertThat(loaded.getLedger().getEntries().size(), is(fixture.client().getLedger().getEntries().size())); - assertTrue(loaded.getAllTransactions().isEmpty()); + var exception = assertThrows(IllegalArgumentException.class, () -> load(wrap(proto.build()))); + + assertTrue(exception.getMessage(), exception.getMessage().contains("AMBIGUOUS_SEMANTIC_PRIMARY")); } /** @@ -890,14 +956,17 @@ private ClientFixture fixture() otherPortfolio.setUpdatedAt(UPDATED_AT); var security = new Security("Security", CurrencyUnit.EUR); security.setUpdatedAt(UPDATED_AT); + var otherSecurity = new Security("Other Security", CurrencyUnit.EUR); + otherSecurity.setUpdatedAt(UPDATED_AT); client.addAccount(account); client.addAccount(otherAccount); client.addPortfolio(portfolio); client.addPortfolio(otherPortfolio); client.addSecurity(security); + client.addSecurity(otherSecurity); - return new ClientFixture(client, account, otherAccount, portfolio, otherPortfolio, security); + return new ClientFixture(client, account, otherAccount, portfolio, otherPortfolio, security, otherSecurity); } private ClientFixture fixtureWithDividendAndExDate() @@ -948,6 +1017,72 @@ private InvestmentPlan plan(ClientFixture fixture) return plan; } + private void addNativeEntry(ClientFixture fixture, LedgerEntryType entryType) + { + var entry = new LedgerEntry(); + + entry.setType(entryType); + entry.setDateTime(DATE_TIME); + entry.setNote("Native corporate action"); + entry.setSource("protobuf-test"); + + switch (entryType) + { + case SPIN_OFF: + entry.addPosting(nativeSecurityPosting(fixture, LedgerPostingType.SECURITY, + CorporateActionLeg.SOURCE_SECURITY, fixture.security(), LedgerPostingDirection.OUTBOUND, + LedgerProjectionRole.OLD_SECURITY_LEG)); + entry.addPosting(nativeSecurityPosting(fixture, LedgerPostingType.SECURITY, + CorporateActionLeg.TARGET_SECURITY, fixture.otherSecurity(), + LedgerPostingDirection.INBOUND, LedgerProjectionRole.NEW_SECURITY_LEG)); + break; + case STOCK_DIVIDEND: + case BONUS_ISSUE: + entry.addPosting(nativeSecurityPosting(fixture, LedgerPostingType.SECURITY, + CorporateActionLeg.TARGET_SECURITY, fixture.otherSecurity(), + LedgerPostingDirection.INBOUND, LedgerProjectionRole.DELIVERY_INBOUND)); + break; + case RIGHTS_DISTRIBUTION: + entry.addPosting(nativeSecurityPosting(fixture, LedgerPostingType.SECURITY, + CorporateActionLeg.DISTRIBUTED_SECURITY, fixture.otherSecurity(), + LedgerPostingDirection.INBOUND, LedgerProjectionRole.NEW_SECURITY_LEG)); + break; + case BOND_CONVERSION: + entry.addPosting(nativeSecurityPosting(fixture, LedgerPostingType.BOND, + CorporateActionLeg.CONVERSION_SOURCE, fixture.security(), LedgerPostingDirection.OUTBOUND, + LedgerProjectionRole.OLD_SECURITY_LEG)); + entry.addPosting(nativeSecurityPosting(fixture, LedgerPostingType.BOND, + CorporateActionLeg.CONVERSION_TARGET, fixture.otherSecurity(), + LedgerPostingDirection.INBOUND, LedgerProjectionRole.NEW_SECURITY_LEG)); + break; + default: + throw new IllegalArgumentException(entryType.name()); + } + + fixture.client().getLedger().addEntry(entry); + } + + private LedgerPosting nativeSecurityPosting(ClientFixture fixture, LedgerPostingType postingType, + CorporateActionLeg leg, Security security, LedgerPostingDirection direction, LedgerProjectionRole role) + { + var posting = new LedgerPosting(); + + posting.setType(postingType); + posting.setPortfolio(fixture.portfolio()); + posting.setSecurity(security); + posting.setShares(Values.Share.factorize(5)); + posting.setAmount(Values.Amount.factorize(50)); + posting.setCurrency(CurrencyUnit.EUR); + posting.setSemanticRole(postingType == LedgerPostingType.BOND ? LedgerPostingSemanticRole.BOND + : LedgerPostingSemanticRole.SECURITY); + posting.setDirection(direction); + posting.setCorporateActionLeg(leg); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + posting.setLocalKey(role.name()); + + return posting; + } + private PTransaction transaction(PClient client, PTransaction.Type type) { return client.getTransactionsList().stream().filter(transaction -> transaction.getType() == type).findFirst() @@ -977,6 +1112,18 @@ private void assertUnit(name.abuchen.portfolio.model.proto.v1.PTransactionUnit u assertThat(unit.getType().name(), is(type.name())); } + private void assertNoLedgerUuidTruth(PClient client) + { + for (var entry : client.getLedger().getEntriesList()) + { + assertThat(entry.getUuid(), is("")); + assertThat(entry.getProjectionRefsCount(), is(0)); + + for (var posting : entry.getPostingsList()) + assertThat(posting.getUuid(), is("")); + } + } + private void assertProjectionUUIDs(Client client, LedgerEntryType type, String... projectionUUIDs) { var actual = onlyEntry(client, type).getProjectionRefs().stream().map(ref -> ref.getUUID()).toList(); @@ -998,10 +1145,9 @@ private Transaction ledgerBacked(List transactions, Strin .filter(transaction -> uuid.equals(transaction.getUUID())).findFirst().orElseThrow(); } - private String projectionUUID(name.abuchen.portfolio.model.ledger.LedgerEntry entry, LedgerProjectionRole role) + private String shadowUUID(name.abuchen.portfolio.model.ledger.LedgerEntry entry, LedgerProjectionRole role) { - return entry.getProjectionRefs().stream().filter(ref -> ref.getRole() == role).findFirst().orElseThrow() - .getUUID(); + return "ledger-shadow:" + entry.getUUID() + ":" + role; //$NON-NLS-1$ //$NON-NLS-2$ } private byte[] saveBytes(Client client) throws IOException @@ -1085,7 +1231,7 @@ private void assertValid(Client client) } private record ClientFixture(Client client, Account account, Account otherAccount, Portfolio portfolio, - Portfolio otherPortfolio, Security security) + Portfolio otherPortfolio, Security security, Security otherSecurity) { } } diff --git a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/ClientProtos.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/ClientProtos.java index 75ca61e266..95dcb0119c 100644 --- a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/ClientProtos.java +++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/ClientProtos.java @@ -16,197 +16,197 @@ public static void registerAllExtensions( } static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PDecimalValue_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PDecimalValue_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PLocalDateTime_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PLocalDateTime_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PAnyValue_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PAnyValue_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PKeyValue_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PKeyValue_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PMap_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PMap_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PHistoricalPrice_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PHistoricalPrice_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PFullHistoricalPrice_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PFullHistoricalPrice_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PSecurityEvent_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PSecurityEvent_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PSecurity_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PSecurity_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PWatchlist_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PWatchlist_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PAccount_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PAccount_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PPortfolio_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PPortfolio_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PTransactionUnit_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PTransactionUnit_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PTransaction_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PTransaction_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PInvestmentPlan_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PInvestmentPlan_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PInvestmentPlanLedgerExecutionRef_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PInvestmentPlanLedgerExecutionRef_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PLedger_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PLedger_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PLedgerEntry_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PLedgerEntry_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PLedgerPosting_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PLedgerPosting_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PLedgerProjectionRef_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PLedgerProjectionRef_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PLedgerProjectionMembership_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PLedgerProjectionMembership_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PLedgerParameter_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PLedgerParameter_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PTaxonomy_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PTaxonomy_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PTaxonomy_Assignment_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PTaxonomy_Assignment_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PTaxonomy_Classification_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PTaxonomy_Classification_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PDashboard_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PDashboard_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PDashboard_Widget_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PDashboard_Widget_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PDashboard_Widget_ConfigurationEntry_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PDashboard_Widget_ConfigurationEntry_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PDashboard_Column_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PDashboard_Column_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PDashboard_ConfigurationEntry_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PDashboard_ConfigurationEntry_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PBookmark_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PBookmark_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PAttributeType_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PAttributeType_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PConfigurationSet_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PConfigurationSet_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PSettings_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PSettings_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PClient_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PClient_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PClient_PropertiesEntry_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PClient_PropertiesEntry_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PExchangeRate_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PExchangeRate_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PExchangeRateTimeSeries_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PExchangeRateTimeSeries_fieldAccessorTable; static final com.google.protobuf.Descriptors.Descriptor internal_static_name_abuchen_portfolio_PECBData_descriptor; - static final + static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_name_abuchen_portfolio_PECBData_fieldAccessorTable; @@ -331,169 +331,176 @@ public static void registerAllExtensions( "rojectionRoleH\001\210\001\001B\021\n\017_projectionUUIDB\021\n" + "\017_projectionRole\"@\n\007PLedger\0225\n\007entries\030\001" + " \003(\0132$.name.abuchen.portfolio.PLedgerEnt" + - "ry\"\373\004\n\014PLedgerEntry\022\014\n\004uuid\030\001 \001(\t\022,\n\010dat" + - "eTime\030\003 \001(\0132\032.google.protobuf.Timestamp\022" + - "\021\n\004note\030\004 \001(\tH\000\210\001\001\022\023\n\006source\030\005 \001(\tH\001\210\001\001\022" + - "-\n\tupdatedAt\030\006 \001(\0132\032.google.protobuf.Tim" + - "estamp\0228\n\010postings\030\007 \003(\0132&.name.abuchen." + - "portfolio.PLedgerPosting\022D\n\016projectionRe" + - "fs\030\010 \003(\0132,.name.abuchen.portfolio.PLedge" + - "rProjectionRef\022\023\n\006typeId\030\t \001(\rH\002\210\001\001\022<\n\np" + - "arameters\030\n \003(\0132(.name.abuchen.portfolio" + - ".PLedgerParameter\022\037\n\022generatedByPlanKey\030" + - "\013 \001(\tH\003\210\001\001\022\036\n\021planExecutionDate\030\014 \001(\003H\004\210" + - "\001\001\022\"\n\025planExecutionSequence\030\r \001(\005H\005\210\001\001\022\036" + - "\n\021preferredViewKind\030\016 \001(\tH\006\210\001\001B\007\n\005_noteB" + - "\t\n\007_sourceB\t\n\007_typeIdB\025\n\023_generatedByPla" + - "nKeyB\024\n\022_planExecutionDateB\030\n\026_planExecu" + - "tionSequenceB\024\n\022_preferredViewKindJ\004\010\002\020\003" + - "\"\341\003\n\016PLedgerPosting\022\014\n\004uuid\030\001 \001(\t\022\016\n\006amo" + - "unt\030\003 \001(\003\022\025\n\010currency\030\004 \001(\tH\000\210\001\001\022\030\n\013fore" + - "xAmount\030\005 \001(\003H\001\210\001\001\022\032\n\rforexCurrency\030\006 \001(" + - "\tH\002\210\001\001\022@\n\014exchangeRate\030\007 \001(\0132%.name.abuc" + - "hen.portfolio.PDecimalValueH\003\210\001\001\022\025\n\010secu" + - "rity\030\010 \001(\tH\004\210\001\001\022\016\n\006shares\030\t \001(\003\022\024\n\007accou" + - "nt\030\n \001(\tH\005\210\001\001\022\026\n\tportfolio\030\013 \001(\tH\006\210\001\001\022<\n" + - "\nparameters\030\014 \003(\0132(.name.abuchen.portfol" + - "io.PLedgerParameter\022\025\n\010typeCode\030\r \001(\tH\007\210" + - "\001\001B\013\n\t_currencyB\016\n\014_forexAmountB\020\n\016_fore" + - "xCurrencyB\017\n\r_exchangeRateB\013\n\t_securityB" + - "\n\n\010_accountB\014\n\n_portfolioB\013\n\t_typeCodeJ\004" + - "\010\002\020\003\"\363\001\n\024PLedgerProjectionRef\022\014\n\004uuid\030\001 " + - "\001(\t\022;\n\004role\030\002 \001(\0162-.name.abuchen.portfol" + - "io.PLedgerProjectionRole\022\024\n\007account\030\003 \001(" + - "\tH\000\210\001\001\022\026\n\tportfolio\030\004 \001(\tH\001\210\001\001\022H\n\013member" + - "ships\030\005 \003(\01323.name.abuchen.portfolio.PLe" + - "dgerProjectionMembershipB\n\n\010_accountB\014\n\n" + - "_portfolio\"y\n\033PLedgerProjectionMembershi" + - "p\022\023\n\013postingUUID\030\001 \001(\t\022E\n\004role\030\002 \001(\01627.n" + - "ame.abuchen.portfolio.PLedgerProjectionM" + - "embershipRole\"\266\005\n\020PLedgerParameter\022D\n\tva" + - "lueKind\030\002 \001(\01621.name.abuchen.portfolio.P" + - "LedgerParameterValueKind\022\030\n\013stringValue\030" + - "\003 \001(\tH\000\210\001\001\022@\n\014decimalValue\030\004 \001(\0132%.name." + - "abuchen.portfolio.PDecimalValueH\001\210\001\001\022\026\n\t" + - "longValue\030\005 \001(\003H\002\210\001\001\022\030\n\013moneyAmount\030\006 \001(" + - "\003H\003\210\001\001\022\032\n\rmoneyCurrency\030\007 \001(\tH\004\210\001\001\022\025\n\010se" + - "curity\030\010 \001(\tH\005\210\001\001\022\024\n\007account\030\t \001(\tH\006\210\001\001\022" + - "\026\n\tportfolio\030\n \001(\tH\007\210\001\001\022G\n\022localDateTime" + - "Value\030\014 \001(\0132&.name.abuchen.portfolio.PLo" + - "calDateTimeH\010\210\001\001\022\025\n\010typeCode\030\r \001(\tH\t\210\001\001\022" + - "\031\n\014booleanValue\030\016 \001(\010H\n\210\001\001\022\033\n\016localDateV" + - "alue\030\017 \001(\003H\013\210\001\001B\016\n\014_stringValueB\017\n\r_deci" + - "malValueB\014\n\n_longValueB\016\n\014_moneyAmountB\020" + - "\n\016_moneyCurrencyB\013\n\t_securityB\n\n\010_accoun" + - "tB\014\n\n_portfolioB\025\n\023_localDateTimeValueB\013" + - "\n\t_typeCodeB\017\n\r_booleanValueB\021\n\017_localDa" + - "teValueJ\004\010\001\020\002J\004\010\013\020\014R\tenumValue\"\252\004\n\tPTaxo" + - "nomy\022\n\n\002id\030\001 \001(\t\022\014\n\004name\030\002 \001(\t\022\023\n\006source" + - "\030\003 \001(\tH\000\210\001\001\022\022\n\ndimensions\030\004 \003(\t\022I\n\017class" + - "ifications\030\005 \003(\01320.name.abuchen.portfoli" + - "o.PTaxonomy.Classification\032v\n\nAssignment" + - "\022\031\n\021investmentVehicle\030\001 \001(\t\022\016\n\006weight\030\002 " + - "\001(\005\022\014\n\004rank\030\003 \001(\005\022/\n\004data\030\004 \003(\0132!.name.a" + - "buchen.portfolio.PKeyValue\032\213\002\n\016Classific" + - "ation\022\n\n\002id\030\001 \001(\t\022\025\n\010parentId\030\002 \001(\tH\000\210\001\001" + - "\022\014\n\004name\030\003 \001(\t\022\021\n\004note\030\004 \001(\tH\001\210\001\001\022\r\n\005col" + - "or\030\005 \001(\t\022\016\n\006weight\030\006 \001(\005\022\014\n\004rank\030\007 \001(\005\022/" + - "\n\004data\030\010 \003(\0132!.name.abuchen.portfolio.PK" + - "eyValue\022A\n\013assignments\030\t \003(\0132,.name.abuc" + - "hen.portfolio.PTaxonomy.AssignmentB\013\n\t_p" + - "arentIdB\007\n\005_noteB\t\n\007_source\"\357\003\n\nPDashboa" + - "rd\022\014\n\004name\030\001 \001(\t\022L\n\rconfiguration\030\002 \003(\0132" + - "5.name.abuchen.portfolio.PDashboard.Conf" + - "igurationEntry\022:\n\007columns\030\003 \003(\0132).name.a" + - "buchen.portfolio.PDashboard.Column\022\n\n\002id" + - "\030\004 \001(\t\032\260\001\n\006Widget\022\014\n\004type\030\001 \001(\t\022\r\n\005label" + - "\030\002 \001(\t\022S\n\rconfiguration\030\003 \003(\0132<.name.abu" + - "chen.portfolio.PDashboard.Widget.Configu" + - "rationEntry\0324\n\022ConfigurationEntry\022\013\n\003key" + - "\030\001 \001(\t\022\r\n\005value\030\002 \001(\t:\0028\001\032T\n\006Column\022\016\n\006w" + - "eight\030\001 \001(\005\022:\n\007widgets\030\002 \003(\0132).name.abuc" + - "hen.portfolio.PDashboard.Widget\0324\n\022Confi" + - "gurationEntry\022\013\n\003key\030\001 \001(\t\022\r\n\005value\030\002 \001(" + - "\t:\0028\001\"+\n\tPBookmark\022\r\n\005label\030\001 \001(\t\022\017\n\007pat" + - "tern\030\002 \001(\t\"\307\001\n\016PAttributeType\022\n\n\002id\030\001 \001(" + - "\t\022\014\n\004name\030\002 \001(\t\022\023\n\013columnLabel\030\003 \001(\t\022\023\n\006" + - "source\030\004 \001(\tH\000\210\001\001\022\016\n\006target\030\005 \001(\t\022\014\n\004typ" + - "e\030\006 \001(\t\022\026\n\016converterClass\030\007 \001(\t\0220\n\nprope" + - "rties\030\010 \001(\0132\034.name.abuchen.portfolio.PMa" + - "pB\t\n\007_source\"J\n\021PConfigurationSet\022\013\n\003key" + - "\030\001 \001(\t\022\014\n\004uuid\030\002 \001(\t\022\014\n\004name\030\003 \001(\t\022\014\n\004da" + - "ta\030\004 \001(\t\"\307\001\n\tPSettings\0224\n\tbookmarks\030\001 \003(" + - "\0132!.name.abuchen.portfolio.PBookmark\022>\n\016" + - "attributeTypes\030\002 \003(\0132&.name.abuchen.port" + - "folio.PAttributeType\022D\n\021configurationSet" + - "s\030\003 \003(\0132).name.abuchen.portfolio.PConfig" + - "urationSet\"\366\005\n\007PClient\022\017\n\007version\030\001 \001(\005\022" + - "5\n\nsecurities\030\002 \003(\0132!.name.abuchen.portf" + - "olio.PSecurity\0222\n\010accounts\030\003 \003(\0132 .name." + - "abuchen.portfolio.PAccount\0226\n\nportfolios" + - "\030\004 \003(\0132\".name.abuchen.portfolio.PPortfol" + - "io\022:\n\014transactions\030\005 \003(\0132$.name.abuchen." + - "portfolio.PTransaction\0226\n\005plans\030\006 \003(\0132\'." + - "name.abuchen.portfolio.PInvestmentPlan\0226" + - "\n\nwatchlists\030\007 \003(\0132\".name.abuchen.portfo" + - "lio.PWatchlist\0225\n\ntaxonomies\030\010 \003(\0132!.nam" + - "e.abuchen.portfolio.PTaxonomy\0226\n\ndashboa" + - "rds\030\t \003(\0132\".name.abuchen.portfolio.PDash" + - "board\022C\n\nproperties\030\n \003(\0132/.name.abuchen" + - ".portfolio.PClient.PropertiesEntry\0223\n\010se" + - "ttings\030\013 \001(\0132!.name.abuchen.portfolio.PS" + - "ettings\022\024\n\014baseCurrency\030\014 \001(\t\022/\n\006ledger\030" + - "\r \001(\0132\037.name.abuchen.portfolio.PLedger\022(" + - "\n\nextensions\030c \003(\0132\024.google.protobuf.Any" + - "\0321\n\017PropertiesEntry\022\013\n\003key\030\001 \001(\t\022\r\n\005valu" + - "e\030\002 \001(\t:\0028\001\"S\n\rPExchangeRate\022\014\n\004date\030\001 \001" + - "(\003\0224\n\005value\030\002 \001(\0132%.name.abuchen.portfol" + - "io.PDecimalValue\"\203\001\n\027PExchangeRateTimeSe" + - "ries\022\024\n\014baseCurrency\030\001 \001(\t\022\024\n\014termCurren" + - "cy\030\002 \001(\t\022<\n\rexchangeRates\030\003 \003(\0132%.name.a" + - "buchen.portfolio.PExchangeRate\"a\n\010PECBDa" + - "ta\022\024\n\014lastModified\030\001 \001(\003\022?\n\006series\030\002 \003(\013" + - "2/.name.abuchen.portfolio.PExchangeRateT" + - "imeSeries*\301\004\n\025PLedgerProjectionRole\022&\n\"L" + - "EDGER_PROJECTION_ROLE_UNSPECIFIED\020\000\022\"\n\036L" + - "EDGER_PROJECTION_ROLE_ACCOUNT\020\001\022$\n LEDGE" + - "R_PROJECTION_ROLE_PORTFOLIO\020\002\022)\n%LEDGER_" + - "PROJECTION_ROLE_SOURCE_ACCOUNT\020\003\022)\n%LEDG" + - "ER_PROJECTION_ROLE_TARGET_ACCOUNT\020\004\022+\n\'L" + - "EDGER_PROJECTION_ROLE_SOURCE_PORTFOLIO\020\005" + - "\022+\n\'LEDGER_PROJECTION_ROLE_TARGET_PORTFO" + - "LIO\020\006\022#\n\037LEDGER_PROJECTION_ROLE_DELIVERY" + - "\020\007\022+\n\'LEDGER_PROJECTION_ROLE_DELIVERY_IN" + - "BOUND\020\010\022,\n(LEDGER_PROJECTION_ROLE_DELIVE" + - "RY_OUTBOUND\020\t\022,\n(LEDGER_PROJECTION_ROLE_" + - "CASH_COMPENSATION\020\n\022+\n\'LEDGER_PROJECTION" + - "_ROLE_OLD_SECURITY_LEG\020\013\022+\n\'LEDGER_PROJE" + - "CTION_ROLE_NEW_SECURITY_LEG\020\014*\204\003\n\037PLedge" + - "rProjectionMembershipRole\0221\n-LEDGER_PROJ" + - "ECTION_MEMBERSHIP_ROLE_UNSPECIFIED\020\000\022-\n)" + - "LEDGER_PROJECTION_MEMBERSHIP_ROLE_PRIMAR" + - "Y\020\001\0222\n.LEDGER_PROJECTION_MEMBERSHIP_ROLE" + - "_GROUP_ANCHOR\020\002\022.\n*LEDGER_PROJECTION_MEM" + - "BERSHIP_ROLE_FEE_UNIT\020\003\022.\n*LEDGER_PROJEC" + - "TION_MEMBERSHIP_ROLE_TAX_UNIT\020\004\0226\n2LEDGE" + - "R_PROJECTION_MEMBERSHIP_ROLE_GROSS_VALUE" + - "_UNIT\020\005\0223\n/LEDGER_PROJECTION_MEMBERSHIP_" + - "ROLE_FOREX_CONTEXT\020\006*\327\004\n\031PLedgerParamete" + - "rValueKind\022+\n\'LEDGER_PARAMETER_VALUE_KIN" + - "D_UNSPECIFIED\020\000\022&\n\"LEDGER_PARAMETER_VALU" + - "E_KIND_STRING\020\001\022\'\n#LEDGER_PARAMETER_VALU" + - "E_KIND_DECIMAL\020\002\022$\n LEDGER_PARAMETER_VAL" + - "UE_KIND_LONG\020\003\022%\n!LEDGER_PARAMETER_VALUE" + - "_KIND_MONEY\020\004\022(\n$LEDGER_PARAMETER_VALUE_" + - "KIND_SECURITY\020\005\022\'\n#LEDGER_PARAMETER_VALU" + - "E_KIND_ACCOUNT\020\006\022)\n%LEDGER_PARAMETER_VAL" + - "UE_KIND_PORTFOLIO\020\007\022/\n+LEDGER_PARAMETER_" + - "VALUE_KIND_LOCAL_DATE_TIME\020\n\022\'\n#LEDGER_P" + - "ARAMETER_VALUE_KIND_BOOLEAN\020\013\022*\n&LEDGER_" + - "PARAMETER_VALUE_KIND_LOCAL_DATE\020\014\"\004\010\010\020\010\"" + - "\004\010\t\020\t*0LEDGER_PARAMETER_VALUE_KIND_CORPO" + - "RATE_ACTION_LEG*-LEDGER_PARAMETER_VALUE_" + - "KIND_COMPENSATION_KINDB7\n%name.abuchen.p" + - "ortfolio.model.proto.v1B\014ClientProtosP\001b" + - "\006proto3" + "ry\"\203\005\n\014PLedgerEntry\022\020\n\004uuid\030\001 \001(\tB\002\030\001\022,\n" + + "\010dateTime\030\003 \001(\0132\032.google.protobuf.Timest" + + "amp\022\021\n\004note\030\004 \001(\tH\000\210\001\001\022\023\n\006source\030\005 \001(\tH\001" + + "\210\001\001\022-\n\tupdatedAt\030\006 \001(\0132\032.google.protobuf" + + ".Timestamp\0228\n\010postings\030\007 \003(\0132&.name.abuc" + + "hen.portfolio.PLedgerPosting\022H\n\016projecti" + + "onRefs\030\010 \003(\0132,.name.abuchen.portfolio.PL" + + "edgerProjectionRefB\002\030\001\022\023\n\006typeId\030\t \001(\rH\002" + + "\210\001\001\022<\n\nparameters\030\n \003(\0132(.name.abuchen.p" + + "ortfolio.PLedgerParameter\022\037\n\022generatedBy" + + "PlanKey\030\013 \001(\tH\003\210\001\001\022\036\n\021planExecutionDate\030" + + "\014 \001(\003H\004\210\001\001\022\"\n\025planExecutionSequence\030\r \001(" + + "\005H\005\210\001\001\022\036\n\021preferredViewKind\030\016 \001(\tH\006\210\001\001B\007" + + "\n\005_noteB\t\n\007_sourceB\t\n\007_typeIdB\025\n\023_genera" + + "tedByPlanKeyB\024\n\022_planExecutionDateB\030\n\026_p" + + "lanExecutionSequenceB\024\n\022_preferredViewKi" + + "ndJ\004\010\002\020\003\"\333\005\n\016PLedgerPosting\022\020\n\004uuid\030\001 \001(" + + "\tB\002\030\001\022\016\n\006amount\030\003 \001(\003\022\025\n\010currency\030\004 \001(\tH" + + "\000\210\001\001\022\030\n\013forexAmount\030\005 \001(\003H\001\210\001\001\022\032\n\rforexC" + + "urrency\030\006 \001(\tH\002\210\001\001\022@\n\014exchangeRate\030\007 \001(\013" + + "2%.name.abuchen.portfolio.PDecimalValueH" + + "\003\210\001\001\022\025\n\010security\030\010 \001(\tH\004\210\001\001\022\016\n\006shares\030\t " + + "\001(\003\022\024\n\007account\030\n \001(\tH\005\210\001\001\022\026\n\tportfolio\030\013" + + " \001(\tH\006\210\001\001\022<\n\nparameters\030\014 \003(\0132(.name.abu" + + "chen.portfolio.PLedgerParameter\022\025\n\010typeC" + + "ode\030\r \001(\tH\007\210\001\001\022\031\n\014semanticRole\030\016 \001(\tH\010\210\001" + + "\001\022\026\n\tdirection\030\017 \001(\tH\t\210\001\001\022\037\n\022corporateAc" + + "tionLeg\030\020 \001(\tH\n\210\001\001\022\025\n\010unitRole\030\021 \001(\tH\013\210\001" + + "\001\022\025\n\010groupKey\030\022 \001(\tH\014\210\001\001\022\025\n\010localKey\030\023 \001" + + "(\tH\r\210\001\001B\013\n\t_currencyB\016\n\014_forexAmountB\020\n\016" + + "_forexCurrencyB\017\n\r_exchangeRateB\013\n\t_secu" + + "rityB\n\n\010_accountB\014\n\n_portfolioB\013\n\t_typeC" + + "odeB\017\n\r_semanticRoleB\014\n\n_directionB\025\n\023_c" + + "orporateActionLegB\013\n\t_unitRoleB\013\n\t_group" + + "KeyB\013\n\t_localKeyJ\004\010\002\020\003\"\207\002\n\024PLedgerProjec" + + "tionRef\022\020\n\004uuid\030\001 \001(\tB\002\030\001\022?\n\004role\030\002 \001(\0162" + + "-.name.abuchen.portfolio.PLedgerProjecti" + + "onRoleB\002\030\001\022\030\n\007account\030\003 \001(\tB\002\030\001H\000\210\001\001\022\032\n\t" + + "portfolio\030\004 \001(\tB\002\030\001H\001\210\001\001\022L\n\013memberships\030" + + "\005 \003(\01323.name.abuchen.portfolio.PLedgerPr" + + "ojectionMembershipB\002\030\001B\n\n\010_accountB\014\n\n_p" + + "ortfolio\"\201\001\n\033PLedgerProjectionMembership" + + "\022\027\n\013postingUUID\030\001 \001(\tB\002\030\001\022I\n\004role\030\002 \001(\0162" + + "7.name.abuchen.portfolio.PLedgerProjecti" + + "onMembershipRoleB\002\030\001\"\266\005\n\020PLedgerParamete" + + "r\022D\n\tvalueKind\030\002 \001(\01621.name.abuchen.port" + + "folio.PLedgerParameterValueKind\022\030\n\013strin" + + "gValue\030\003 \001(\tH\000\210\001\001\022@\n\014decimalValue\030\004 \001(\0132" + + "%.name.abuchen.portfolio.PDecimalValueH\001" + + "\210\001\001\022\026\n\tlongValue\030\005 \001(\003H\002\210\001\001\022\030\n\013moneyAmou" + + "nt\030\006 \001(\003H\003\210\001\001\022\032\n\rmoneyCurrency\030\007 \001(\tH\004\210\001" + + "\001\022\025\n\010security\030\010 \001(\tH\005\210\001\001\022\024\n\007account\030\t \001(" + + "\tH\006\210\001\001\022\026\n\tportfolio\030\n \001(\tH\007\210\001\001\022G\n\022localD" + + "ateTimeValue\030\014 \001(\0132&.name.abuchen.portfo" + + "lio.PLocalDateTimeH\010\210\001\001\022\025\n\010typeCode\030\r \001(" + + "\tH\t\210\001\001\022\031\n\014booleanValue\030\016 \001(\010H\n\210\001\001\022\033\n\016loc" + + "alDateValue\030\017 \001(\003H\013\210\001\001B\016\n\014_stringValueB\017" + + "\n\r_decimalValueB\014\n\n_longValueB\016\n\014_moneyA" + + "mountB\020\n\016_moneyCurrencyB\013\n\t_securityB\n\n\010" + + "_accountB\014\n\n_portfolioB\025\n\023_localDateTime" + + "ValueB\013\n\t_typeCodeB\017\n\r_booleanValueB\021\n\017_" + + "localDateValueJ\004\010\001\020\002J\004\010\013\020\014R\tenumValue\"\252\004" + + "\n\tPTaxonomy\022\n\n\002id\030\001 \001(\t\022\014\n\004name\030\002 \001(\t\022\023\n" + + "\006source\030\003 \001(\tH\000\210\001\001\022\022\n\ndimensions\030\004 \003(\t\022I" + + "\n\017classifications\030\005 \003(\01320.name.abuchen.p" + + "ortfolio.PTaxonomy.Classification\032v\n\nAss" + + "ignment\022\031\n\021investmentVehicle\030\001 \001(\t\022\016\n\006we" + + "ight\030\002 \001(\005\022\014\n\004rank\030\003 \001(\005\022/\n\004data\030\004 \003(\0132!" + + ".name.abuchen.portfolio.PKeyValue\032\213\002\n\016Cl" + + "assification\022\n\n\002id\030\001 \001(\t\022\025\n\010parentId\030\002 \001" + + "(\tH\000\210\001\001\022\014\n\004name\030\003 \001(\t\022\021\n\004note\030\004 \001(\tH\001\210\001\001" + + "\022\r\n\005color\030\005 \001(\t\022\016\n\006weight\030\006 \001(\005\022\014\n\004rank\030" + + "\007 \001(\005\022/\n\004data\030\010 \003(\0132!.name.abuchen.portf" + + "olio.PKeyValue\022A\n\013assignments\030\t \003(\0132,.na" + + "me.abuchen.portfolio.PTaxonomy.Assignmen" + + "tB\013\n\t_parentIdB\007\n\005_noteB\t\n\007_source\"\357\003\n\nP" + + "Dashboard\022\014\n\004name\030\001 \001(\t\022L\n\rconfiguration" + + "\030\002 \003(\01325.name.abuchen.portfolio.PDashboa" + + "rd.ConfigurationEntry\022:\n\007columns\030\003 \003(\0132)" + + ".name.abuchen.portfolio.PDashboard.Colum" + + "n\022\n\n\002id\030\004 \001(\t\032\260\001\n\006Widget\022\014\n\004type\030\001 \001(\t\022\r" + + "\n\005label\030\002 \001(\t\022S\n\rconfiguration\030\003 \003(\0132<.n" + + "ame.abuchen.portfolio.PDashboard.Widget." + + "ConfigurationEntry\0324\n\022ConfigurationEntry" + + "\022\013\n\003key\030\001 \001(\t\022\r\n\005value\030\002 \001(\t:\0028\001\032T\n\006Colu" + + "mn\022\016\n\006weight\030\001 \001(\005\022:\n\007widgets\030\002 \003(\0132).na" + + "me.abuchen.portfolio.PDashboard.Widget\0324" + + "\n\022ConfigurationEntry\022\013\n\003key\030\001 \001(\t\022\r\n\005val" + + "ue\030\002 \001(\t:\0028\001\"+\n\tPBookmark\022\r\n\005label\030\001 \001(\t" + + "\022\017\n\007pattern\030\002 \001(\t\"\307\001\n\016PAttributeType\022\n\n\002" + + "id\030\001 \001(\t\022\014\n\004name\030\002 \001(\t\022\023\n\013columnLabel\030\003 " + + "\001(\t\022\023\n\006source\030\004 \001(\tH\000\210\001\001\022\016\n\006target\030\005 \001(\t" + + "\022\014\n\004type\030\006 \001(\t\022\026\n\016converterClass\030\007 \001(\t\0220" + + "\n\nproperties\030\010 \001(\0132\034.name.abuchen.portfo" + + "lio.PMapB\t\n\007_source\"J\n\021PConfigurationSet" + + "\022\013\n\003key\030\001 \001(\t\022\014\n\004uuid\030\002 \001(\t\022\014\n\004name\030\003 \001(" + + "\t\022\014\n\004data\030\004 \001(\t\"\307\001\n\tPSettings\0224\n\tbookmar" + + "ks\030\001 \003(\0132!.name.abuchen.portfolio.PBookm" + + "ark\022>\n\016attributeTypes\030\002 \003(\0132&.name.abuch" + + "en.portfolio.PAttributeType\022D\n\021configura" + + "tionSets\030\003 \003(\0132).name.abuchen.portfolio." + + "PConfigurationSet\"\366\005\n\007PClient\022\017\n\007version" + + "\030\001 \001(\005\0225\n\nsecurities\030\002 \003(\0132!.name.abuche" + + "n.portfolio.PSecurity\0222\n\010accounts\030\003 \003(\0132" + + " .name.abuchen.portfolio.PAccount\0226\n\npor" + + "tfolios\030\004 \003(\0132\".name.abuchen.portfolio.P" + + "Portfolio\022:\n\014transactions\030\005 \003(\0132$.name.a" + + "buchen.portfolio.PTransaction\0226\n\005plans\030\006" + + " \003(\0132\'.name.abuchen.portfolio.PInvestmen" + + "tPlan\0226\n\nwatchlists\030\007 \003(\0132\".name.abuchen" + + ".portfolio.PWatchlist\0225\n\ntaxonomies\030\010 \003(" + + "\0132!.name.abuchen.portfolio.PTaxonomy\0226\n\n" + + "dashboards\030\t \003(\0132\".name.abuchen.portfoli" + + "o.PDashboard\022C\n\nproperties\030\n \003(\0132/.name." + + "abuchen.portfolio.PClient.PropertiesEntr" + + "y\0223\n\010settings\030\013 \001(\0132!.name.abuchen.portf" + + "olio.PSettings\022\024\n\014baseCurrency\030\014 \001(\t\022/\n\006" + + "ledger\030\r \001(\0132\037.name.abuchen.portfolio.PL" + + "edger\022(\n\nextensions\030c \003(\0132\024.google.proto" + + "buf.Any\0321\n\017PropertiesEntry\022\013\n\003key\030\001 \001(\t\022" + + "\r\n\005value\030\002 \001(\t:\0028\001\"S\n\rPExchangeRate\022\014\n\004d" + + "ate\030\001 \001(\003\0224\n\005value\030\002 \001(\0132%.name.abuchen." + + "portfolio.PDecimalValue\"\203\001\n\027PExchangeRat" + + "eTimeSeries\022\024\n\014baseCurrency\030\001 \001(\t\022\024\n\014ter" + + "mCurrency\030\002 \001(\t\022<\n\rexchangeRates\030\003 \003(\0132%" + + ".name.abuchen.portfolio.PExchangeRate\"a\n" + + "\010PECBData\022\024\n\014lastModified\030\001 \001(\003\022?\n\006serie" + + "s\030\002 \003(\0132/.name.abuchen.portfolio.PExchan" + + "geRateTimeSeries*\301\004\n\025PLedgerProjectionRo" + + "le\022&\n\"LEDGER_PROJECTION_ROLE_UNSPECIFIED" + + "\020\000\022\"\n\036LEDGER_PROJECTION_ROLE_ACCOUNT\020\001\022$" + + "\n LEDGER_PROJECTION_ROLE_PORTFOLIO\020\002\022)\n%" + + "LEDGER_PROJECTION_ROLE_SOURCE_ACCOUNT\020\003\022" + + ")\n%LEDGER_PROJECTION_ROLE_TARGET_ACCOUNT" + + "\020\004\022+\n\'LEDGER_PROJECTION_ROLE_SOURCE_PORT" + + "FOLIO\020\005\022+\n\'LEDGER_PROJECTION_ROLE_TARGET" + + "_PORTFOLIO\020\006\022#\n\037LEDGER_PROJECTION_ROLE_D" + + "ELIVERY\020\007\022+\n\'LEDGER_PROJECTION_ROLE_DELI" + + "VERY_INBOUND\020\010\022,\n(LEDGER_PROJECTION_ROLE" + + "_DELIVERY_OUTBOUND\020\t\022,\n(LEDGER_PROJECTIO" + + "N_ROLE_CASH_COMPENSATION\020\n\022+\n\'LEDGER_PRO" + + "JECTION_ROLE_OLD_SECURITY_LEG\020\013\022+\n\'LEDGE" + + "R_PROJECTION_ROLE_NEW_SECURITY_LEG\020\014*\204\003\n" + + "\037PLedgerProjectionMembershipRole\0221\n-LEDG" + + "ER_PROJECTION_MEMBERSHIP_ROLE_UNSPECIFIE" + + "D\020\000\022-\n)LEDGER_PROJECTION_MEMBERSHIP_ROLE" + + "_PRIMARY\020\001\0222\n.LEDGER_PROJECTION_MEMBERSH" + + "IP_ROLE_GROUP_ANCHOR\020\002\022.\n*LEDGER_PROJECT" + + "ION_MEMBERSHIP_ROLE_FEE_UNIT\020\003\022.\n*LEDGER" + + "_PROJECTION_MEMBERSHIP_ROLE_TAX_UNIT\020\004\0226" + + "\n2LEDGER_PROJECTION_MEMBERSHIP_ROLE_GROS" + + "S_VALUE_UNIT\020\005\0223\n/LEDGER_PROJECTION_MEMB" + + "ERSHIP_ROLE_FOREX_CONTEXT\020\006*\327\004\n\031PLedgerP" + + "arameterValueKind\022+\n\'LEDGER_PARAMETER_VA" + + "LUE_KIND_UNSPECIFIED\020\000\022&\n\"LEDGER_PARAMET" + + "ER_VALUE_KIND_STRING\020\001\022\'\n#LEDGER_PARAMET" + + "ER_VALUE_KIND_DECIMAL\020\002\022$\n LEDGER_PARAME" + + "TER_VALUE_KIND_LONG\020\003\022%\n!LEDGER_PARAMETE" + + "R_VALUE_KIND_MONEY\020\004\022(\n$LEDGER_PARAMETER" + + "_VALUE_KIND_SECURITY\020\005\022\'\n#LEDGER_PARAMET" + + "ER_VALUE_KIND_ACCOUNT\020\006\022)\n%LEDGER_PARAME" + + "TER_VALUE_KIND_PORTFOLIO\020\007\022/\n+LEDGER_PAR" + + "AMETER_VALUE_KIND_LOCAL_DATE_TIME\020\n\022\'\n#L" + + "EDGER_PARAMETER_VALUE_KIND_BOOLEAN\020\013\022*\n&" + + "LEDGER_PARAMETER_VALUE_KIND_LOCAL_DATE\020\014" + + "\"\004\010\010\020\010\"\004\010\t\020\t*0LEDGER_PARAMETER_VALUE_KIN" + + "D_CORPORATE_ACTION_LEG*-LEDGER_PARAMETER" + + "_VALUE_KIND_COMPENSATION_KINDB7\n%name.ab" + + "uchen.portfolio.model.proto.v1B\014ClientPr" + + "otosP\001b\006proto3" }; descriptor = com.google.protobuf.Descriptors.FileDescriptor .internalBuildGeneratedFileFrom(descriptorData, @@ -615,7 +622,7 @@ public static void registerAllExtensions( internal_static_name_abuchen_portfolio_PLedgerPosting_fieldAccessorTable = new com.google.protobuf.GeneratedMessageV3.FieldAccessorTable( internal_static_name_abuchen_portfolio_PLedgerPosting_descriptor, - new java.lang.String[] { "Uuid", "Amount", "Currency", "ForexAmount", "ForexCurrency", "ExchangeRate", "Security", "Shares", "Account", "Portfolio", "Parameters", "TypeCode", "Currency", "ForexAmount", "ForexCurrency", "ExchangeRate", "Security", "Account", "Portfolio", "TypeCode", }); + new java.lang.String[] { "Uuid", "Amount", "Currency", "ForexAmount", "ForexCurrency", "ExchangeRate", "Security", "Shares", "Account", "Portfolio", "Parameters", "TypeCode", "SemanticRole", "Direction", "CorporateActionLeg", "UnitRole", "GroupKey", "LocalKey", "Currency", "ForexAmount", "ForexCurrency", "ExchangeRate", "Security", "Account", "Portfolio", "TypeCode", "SemanticRole", "Direction", "CorporateActionLeg", "UnitRole", "GroupKey", "LocalKey", }); internal_static_name_abuchen_portfolio_PLedgerProjectionRef_descriptor = getDescriptor().getMessageTypes().get(19); internal_static_name_abuchen_portfolio_PLedgerProjectionRef_fieldAccessorTable = new diff --git a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerEntry.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerEntry.java index f2fa711500..f89a7fe8b5 100644 --- a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerEntry.java +++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerEntry.java @@ -51,16 +51,18 @@ protected java.lang.Object newInstance( @SuppressWarnings("serial") private volatile java.lang.Object uuid_ = ""; /** - * string uuid = 1; + * string uuid = 1 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerEntry.uuid is deprecated. + * See client.proto;l=278 * @return The uuid. */ @java.lang.Override - public java.lang.String getUuid() { + @java.lang.Deprecated public java.lang.String getUuid() { java.lang.Object ref = uuid_; if (ref instanceof java.lang.String) { return (java.lang.String) ref; } else { - com.google.protobuf.ByteString bs = + com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; java.lang.String s = bs.toStringUtf8(); uuid_ = s; @@ -68,15 +70,17 @@ public java.lang.String getUuid() { } } /** - * string uuid = 1; + * string uuid = 1 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerEntry.uuid is deprecated. + * See client.proto;l=278 * @return The bytes for uuid. */ @java.lang.Override - public com.google.protobuf.ByteString + @java.lang.Deprecated public com.google.protobuf.ByteString getUuidBytes() { java.lang.Object ref = uuid_; if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); uuid_ = b; @@ -133,7 +137,7 @@ public java.lang.String getNote() { if (ref instanceof java.lang.String) { return (java.lang.String) ref; } else { - com.google.protobuf.ByteString bs = + com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; java.lang.String s = bs.toStringUtf8(); note_ = s; @@ -149,7 +153,7 @@ public java.lang.String getNote() { getNoteBytes() { java.lang.Object ref = note_; if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); note_ = b; @@ -180,7 +184,7 @@ public java.lang.String getSource() { if (ref instanceof java.lang.String) { return (java.lang.String) ref; } else { - com.google.protobuf.ByteString bs = + com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; java.lang.String s = bs.toStringUtf8(); source_ = s; @@ -196,7 +200,7 @@ public java.lang.String getSource() { getSourceBytes() { java.lang.Object ref = source_; if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); source_ = b; @@ -246,7 +250,7 @@ public java.util.List getP * repeated .name.abuchen.portfolio.PLedgerPosting postings = 7; */ @java.lang.Override - public java.util.List + public java.util.List getPostingsOrBuilderList() { return postings_; } @@ -277,39 +281,39 @@ public name.abuchen.portfolio.model.proto.v1.PLedgerPostingOrBuilder getPostings @SuppressWarnings("serial") private java.util.List projectionRefs_; /** - * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8; + * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8 [deprecated = true]; */ @java.lang.Override - public java.util.List getProjectionRefsList() { + @java.lang.Deprecated public java.util.List getProjectionRefsList() { return projectionRefs_; } /** - * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8; + * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8 [deprecated = true]; */ @java.lang.Override - public java.util.List + @java.lang.Deprecated public java.util.List getProjectionRefsOrBuilderList() { return projectionRefs_; } /** - * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8; + * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8 [deprecated = true]; */ @java.lang.Override - public int getProjectionRefsCount() { + @java.lang.Deprecated public int getProjectionRefsCount() { return projectionRefs_.size(); } /** - * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8; + * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8 [deprecated = true]; */ @java.lang.Override - public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef getProjectionRefs(int index) { + @java.lang.Deprecated public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef getProjectionRefs(int index) { return projectionRefs_.get(index); } /** - * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8; + * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8 [deprecated = true]; */ @java.lang.Override - public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRefOrBuilder getProjectionRefsOrBuilder( + @java.lang.Deprecated public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRefOrBuilder getProjectionRefsOrBuilder( int index) { return projectionRefs_.get(index); } @@ -347,7 +351,7 @@ public java.util.List ge * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 10; */ @java.lang.Override - public java.util.List + public java.util.List getParametersOrBuilderList() { return parameters_; } @@ -395,7 +399,7 @@ public java.lang.String getGeneratedByPlanKey() { if (ref instanceof java.lang.String) { return (java.lang.String) ref; } else { - com.google.protobuf.ByteString bs = + com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; java.lang.String s = bs.toStringUtf8(); generatedByPlanKey_ = s; @@ -411,7 +415,7 @@ public java.lang.String getGeneratedByPlanKey() { getGeneratedByPlanKeyBytes() { java.lang.Object ref = generatedByPlanKey_; if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); generatedByPlanKey_ = b; @@ -480,7 +484,7 @@ public java.lang.String getPreferredViewKind() { if (ref instanceof java.lang.String) { return (java.lang.String) ref; } else { - com.google.protobuf.ByteString bs = + com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; java.lang.String s = bs.toStringUtf8(); preferredViewKind_ = s; @@ -496,7 +500,7 @@ public java.lang.String getPreferredViewKind() { getPreferredViewKindBytes() { java.lang.Object ref = preferredViewKind_; if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); preferredViewKind_ = b; @@ -1073,7 +1077,7 @@ public Builder mergeFrom(name.abuchen.portfolio.model.proto.v1.PLedgerEntry othe postingsBuilder_ = null; postings_ = other.postings_; bitField0_ = (bitField0_ & ~0x00000020); - postingsBuilder_ = + postingsBuilder_ = com.google.protobuf.GeneratedMessageV3.alwaysUseFieldBuilders ? getPostingsFieldBuilder() : null; } else { @@ -1099,7 +1103,7 @@ public Builder mergeFrom(name.abuchen.portfolio.model.proto.v1.PLedgerEntry othe projectionRefsBuilder_ = null; projectionRefs_ = other.projectionRefs_; bitField0_ = (bitField0_ & ~0x00000040); - projectionRefsBuilder_ = + projectionRefsBuilder_ = com.google.protobuf.GeneratedMessageV3.alwaysUseFieldBuilders ? getProjectionRefsFieldBuilder() : null; } else { @@ -1128,7 +1132,7 @@ public Builder mergeFrom(name.abuchen.portfolio.model.proto.v1.PLedgerEntry othe parametersBuilder_ = null; parameters_ = other.parameters_; bitField0_ = (bitField0_ & ~0x00000100); - parametersBuilder_ = + parametersBuilder_ = com.google.protobuf.GeneratedMessageV3.alwaysUseFieldBuilders ? getParametersFieldBuilder() : null; } else { @@ -1290,10 +1294,12 @@ public Builder mergeFrom( private java.lang.Object uuid_ = ""; /** - * string uuid = 1; + * string uuid = 1 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerEntry.uuid is deprecated. + * See client.proto;l=278 * @return The uuid. */ - public java.lang.String getUuid() { + @java.lang.Deprecated public java.lang.String getUuid() { java.lang.Object ref = uuid_; if (!(ref instanceof java.lang.String)) { com.google.protobuf.ByteString bs = @@ -1306,14 +1312,16 @@ public java.lang.String getUuid() { } } /** - * string uuid = 1; + * string uuid = 1 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerEntry.uuid is deprecated. + * See client.proto;l=278 * @return The bytes for uuid. */ - public com.google.protobuf.ByteString + @java.lang.Deprecated public com.google.protobuf.ByteString getUuidBytes() { java.lang.Object ref = uuid_; if (ref instanceof String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); uuid_ = b; @@ -1323,11 +1331,13 @@ public java.lang.String getUuid() { } } /** - * string uuid = 1; + * string uuid = 1 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerEntry.uuid is deprecated. + * See client.proto;l=278 * @param value The uuid to set. * @return This builder for chaining. */ - public Builder setUuid( + @java.lang.Deprecated public Builder setUuid( java.lang.String value) { if (value == null) { throw new NullPointerException(); } uuid_ = value; @@ -1336,21 +1346,25 @@ public Builder setUuid( return this; } /** - * string uuid = 1; + * string uuid = 1 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerEntry.uuid is deprecated. + * See client.proto;l=278 * @return This builder for chaining. */ - public Builder clearUuid() { + @java.lang.Deprecated public Builder clearUuid() { uuid_ = getDefaultInstance().getUuid(); bitField0_ = (bitField0_ & ~0x00000001); onChanged(); return this; } /** - * string uuid = 1; + * string uuid = 1 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerEntry.uuid is deprecated. + * See client.proto;l=278 * @param value The bytes for uuid to set. * @return This builder for chaining. */ - public Builder setUuidBytes( + @java.lang.Deprecated public Builder setUuidBytes( com.google.protobuf.ByteString value) { if (value == null) { throw new NullPointerException(); } checkByteStringIsUtf8(value); @@ -1466,7 +1480,7 @@ public com.google.protobuf.TimestampOrBuilder getDateTimeOrBuilder() { * .google.protobuf.Timestamp dateTime = 3; */ private com.google.protobuf.SingleFieldBuilderV3< - com.google.protobuf.Timestamp, com.google.protobuf.Timestamp.Builder, com.google.protobuf.TimestampOrBuilder> + com.google.protobuf.Timestamp, com.google.protobuf.Timestamp.Builder, com.google.protobuf.TimestampOrBuilder> getDateTimeFieldBuilder() { if (dateTimeBuilder_ == null) { dateTimeBuilder_ = new com.google.protobuf.SingleFieldBuilderV3< @@ -1511,7 +1525,7 @@ public java.lang.String getNote() { getNoteBytes() { java.lang.Object ref = note_; if (ref instanceof String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); note_ = b; @@ -1590,7 +1604,7 @@ public java.lang.String getSource() { getSourceBytes() { java.lang.Object ref = source_; if (ref instanceof String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); source_ = b; @@ -1743,7 +1757,7 @@ public com.google.protobuf.TimestampOrBuilder getUpdatedAtOrBuilder() { * .google.protobuf.Timestamp updatedAt = 6; */ private com.google.protobuf.SingleFieldBuilderV3< - com.google.protobuf.Timestamp, com.google.protobuf.Timestamp.Builder, com.google.protobuf.TimestampOrBuilder> + com.google.protobuf.Timestamp, com.google.protobuf.Timestamp.Builder, com.google.protobuf.TimestampOrBuilder> getUpdatedAtFieldBuilder() { if (updatedAtBuilder_ == null) { updatedAtBuilder_ = new com.google.protobuf.SingleFieldBuilderV3< @@ -1951,7 +1965,7 @@ public name.abuchen.portfolio.model.proto.v1.PLedgerPostingOrBuilder getPostings /** * repeated .name.abuchen.portfolio.PLedgerPosting postings = 7; */ - public java.util.List + public java.util.List getPostingsOrBuilderList() { if (postingsBuilder_ != null) { return postingsBuilder_.getMessageOrBuilderList(); @@ -1977,12 +1991,12 @@ public name.abuchen.portfolio.model.proto.v1.PLedgerPosting.Builder addPostingsB /** * repeated .name.abuchen.portfolio.PLedgerPosting postings = 7; */ - public java.util.List + public java.util.List getPostingsBuilderList() { return getPostingsFieldBuilder().getBuilderList(); } private com.google.protobuf.RepeatedFieldBuilderV3< - name.abuchen.portfolio.model.proto.v1.PLedgerPosting, name.abuchen.portfolio.model.proto.v1.PLedgerPosting.Builder, name.abuchen.portfolio.model.proto.v1.PLedgerPostingOrBuilder> + name.abuchen.portfolio.model.proto.v1.PLedgerPosting, name.abuchen.portfolio.model.proto.v1.PLedgerPosting.Builder, name.abuchen.portfolio.model.proto.v1.PLedgerPostingOrBuilder> getPostingsFieldBuilder() { if (postingsBuilder_ == null) { postingsBuilder_ = new com.google.protobuf.RepeatedFieldBuilderV3< @@ -2009,9 +2023,9 @@ private void ensureProjectionRefsIsMutable() { name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef.Builder, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRefOrBuilder> projectionRefsBuilder_; /** - * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8; + * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8 [deprecated = true]; */ - public java.util.List getProjectionRefsList() { + @java.lang.Deprecated public java.util.List getProjectionRefsList() { if (projectionRefsBuilder_ == null) { return java.util.Collections.unmodifiableList(projectionRefs_); } else { @@ -2019,9 +2033,9 @@ public java.util.Listrepeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8; + * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8 [deprecated = true]; */ - public int getProjectionRefsCount() { + @java.lang.Deprecated public int getProjectionRefsCount() { if (projectionRefsBuilder_ == null) { return projectionRefs_.size(); } else { @@ -2029,9 +2043,9 @@ public int getProjectionRefsCount() { } } /** - * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8; + * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8 [deprecated = true]; */ - public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef getProjectionRefs(int index) { + @java.lang.Deprecated public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef getProjectionRefs(int index) { if (projectionRefsBuilder_ == null) { return projectionRefs_.get(index); } else { @@ -2039,9 +2053,9 @@ public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef getProjectionR } } /** - * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8; + * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8 [deprecated = true]; */ - public Builder setProjectionRefs( + @java.lang.Deprecated public Builder setProjectionRefs( int index, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef value) { if (projectionRefsBuilder_ == null) { if (value == null) { @@ -2056,9 +2070,9 @@ public Builder setProjectionRefs( return this; } /** - * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8; + * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8 [deprecated = true]; */ - public Builder setProjectionRefs( + @java.lang.Deprecated public Builder setProjectionRefs( int index, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef.Builder builderForValue) { if (projectionRefsBuilder_ == null) { ensureProjectionRefsIsMutable(); @@ -2070,9 +2084,9 @@ public Builder setProjectionRefs( return this; } /** - * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8; + * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8 [deprecated = true]; */ - public Builder addProjectionRefs(name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef value) { + @java.lang.Deprecated public Builder addProjectionRefs(name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef value) { if (projectionRefsBuilder_ == null) { if (value == null) { throw new NullPointerException(); @@ -2086,9 +2100,9 @@ public Builder addProjectionRefs(name.abuchen.portfolio.model.proto.v1.PLedgerPr return this; } /** - * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8; + * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8 [deprecated = true]; */ - public Builder addProjectionRefs( + @java.lang.Deprecated public Builder addProjectionRefs( int index, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef value) { if (projectionRefsBuilder_ == null) { if (value == null) { @@ -2103,9 +2117,9 @@ public Builder addProjectionRefs( return this; } /** - * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8; + * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8 [deprecated = true]; */ - public Builder addProjectionRefs( + @java.lang.Deprecated public Builder addProjectionRefs( name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef.Builder builderForValue) { if (projectionRefsBuilder_ == null) { ensureProjectionRefsIsMutable(); @@ -2117,9 +2131,9 @@ public Builder addProjectionRefs( return this; } /** - * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8; + * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8 [deprecated = true]; */ - public Builder addProjectionRefs( + @java.lang.Deprecated public Builder addProjectionRefs( int index, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef.Builder builderForValue) { if (projectionRefsBuilder_ == null) { ensureProjectionRefsIsMutable(); @@ -2131,9 +2145,9 @@ public Builder addProjectionRefs( return this; } /** - * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8; + * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8 [deprecated = true]; */ - public Builder addAllProjectionRefs( + @java.lang.Deprecated public Builder addAllProjectionRefs( java.lang.Iterable values) { if (projectionRefsBuilder_ == null) { ensureProjectionRefsIsMutable(); @@ -2146,9 +2160,9 @@ public Builder addAllProjectionRefs( return this; } /** - * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8; + * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8 [deprecated = true]; */ - public Builder clearProjectionRefs() { + @java.lang.Deprecated public Builder clearProjectionRefs() { if (projectionRefsBuilder_ == null) { projectionRefs_ = java.util.Collections.emptyList(); bitField0_ = (bitField0_ & ~0x00000040); @@ -2159,9 +2173,9 @@ public Builder clearProjectionRefs() { return this; } /** - * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8; + * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8 [deprecated = true]; */ - public Builder removeProjectionRefs(int index) { + @java.lang.Deprecated public Builder removeProjectionRefs(int index) { if (projectionRefsBuilder_ == null) { ensureProjectionRefsIsMutable(); projectionRefs_.remove(index); @@ -2172,16 +2186,16 @@ public Builder removeProjectionRefs(int index) { return this; } /** - * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8; + * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8 [deprecated = true]; */ - public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef.Builder getProjectionRefsBuilder( + @java.lang.Deprecated public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef.Builder getProjectionRefsBuilder( int index) { return getProjectionRefsFieldBuilder().getBuilder(index); } /** - * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8; + * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8 [deprecated = true]; */ - public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRefOrBuilder getProjectionRefsOrBuilder( + @java.lang.Deprecated public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRefOrBuilder getProjectionRefsOrBuilder( int index) { if (projectionRefsBuilder_ == null) { return projectionRefs_.get(index); } else { @@ -2189,9 +2203,9 @@ public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRefOrBuilder getPr } } /** - * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8; + * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8 [deprecated = true]; */ - public java.util.List + @java.lang.Deprecated public java.util.List getProjectionRefsOrBuilderList() { if (projectionRefsBuilder_ != null) { return projectionRefsBuilder_.getMessageOrBuilderList(); @@ -2200,29 +2214,29 @@ public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRefOrBuilder getPr } } /** - * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8; + * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8 [deprecated = true]; */ - public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef.Builder addProjectionRefsBuilder() { + @java.lang.Deprecated public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef.Builder addProjectionRefsBuilder() { return getProjectionRefsFieldBuilder().addBuilder( name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef.getDefaultInstance()); } /** - * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8; + * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8 [deprecated = true]; */ - public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef.Builder addProjectionRefsBuilder( + @java.lang.Deprecated public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef.Builder addProjectionRefsBuilder( int index) { return getProjectionRefsFieldBuilder().addBuilder( index, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef.getDefaultInstance()); } /** - * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8; + * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8 [deprecated = true]; */ - public java.util.List + @java.lang.Deprecated public java.util.List getProjectionRefsBuilderList() { return getProjectionRefsFieldBuilder().getBuilderList(); } private com.google.protobuf.RepeatedFieldBuilderV3< - name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef.Builder, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRefOrBuilder> + name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef.Builder, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRefOrBuilder> getProjectionRefsFieldBuilder() { if (projectionRefsBuilder_ == null) { projectionRefsBuilder_ = new com.google.protobuf.RepeatedFieldBuilderV3< @@ -2471,7 +2485,7 @@ public name.abuchen.portfolio.model.proto.v1.PLedgerParameterOrBuilder getParame /** * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 10; */ - public java.util.List + public java.util.List getParametersOrBuilderList() { if (parametersBuilder_ != null) { return parametersBuilder_.getMessageOrBuilderList(); @@ -2497,12 +2511,12 @@ public name.abuchen.portfolio.model.proto.v1.PLedgerParameter.Builder addParamet /** * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 10; */ - public java.util.List + public java.util.List getParametersBuilderList() { return getParametersFieldBuilder().getBuilderList(); } private com.google.protobuf.RepeatedFieldBuilderV3< - name.abuchen.portfolio.model.proto.v1.PLedgerParameter, name.abuchen.portfolio.model.proto.v1.PLedgerParameter.Builder, name.abuchen.portfolio.model.proto.v1.PLedgerParameterOrBuilder> + name.abuchen.portfolio.model.proto.v1.PLedgerParameter, name.abuchen.portfolio.model.proto.v1.PLedgerParameter.Builder, name.abuchen.portfolio.model.proto.v1.PLedgerParameterOrBuilder> getParametersFieldBuilder() { if (parametersBuilder_ == null) { parametersBuilder_ = new com.google.protobuf.RepeatedFieldBuilderV3< @@ -2548,7 +2562,7 @@ public java.lang.String getGeneratedByPlanKey() { getGeneratedByPlanKeyBytes() { java.lang.Object ref = generatedByPlanKey_; if (ref instanceof String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); generatedByPlanKey_ = b; @@ -2707,7 +2721,7 @@ public java.lang.String getPreferredViewKind() { getPreferredViewKindBytes() { java.lang.Object ref = preferredViewKind_; if (ref instanceof String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); preferredViewKind_ = b; diff --git a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerEntryOrBuilder.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerEntryOrBuilder.java index 03ac47f19f..2e320a7c88 100644 --- a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerEntryOrBuilder.java +++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerEntryOrBuilder.java @@ -8,15 +8,19 @@ public interface PLedgerEntryOrBuilder extends com.google.protobuf.MessageOrBuilder { /** - * string uuid = 1; + * string uuid = 1 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerEntry.uuid is deprecated. + * See client.proto;l=278 * @return The uuid. */ - java.lang.String getUuid(); + @java.lang.Deprecated java.lang.String getUuid(); /** - * string uuid = 1; + * string uuid = 1 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerEntry.uuid is deprecated. + * See client.proto;l=278 * @return The bytes for uuid. */ - com.google.protobuf.ByteString + @java.lang.Deprecated com.google.protobuf.ByteString getUuidBytes(); /** @@ -86,7 +90,7 @@ public interface PLedgerEntryOrBuilder extends /** * repeated .name.abuchen.portfolio.PLedgerPosting postings = 7; */ - java.util.List + java.util.List getPostingsList(); /** * repeated .name.abuchen.portfolio.PLedgerPosting postings = 7; @@ -99,7 +103,7 @@ public interface PLedgerEntryOrBuilder extends /** * repeated .name.abuchen.portfolio.PLedgerPosting postings = 7; */ - java.util.List + java.util.List getPostingsOrBuilderList(); /** * repeated .name.abuchen.portfolio.PLedgerPosting postings = 7; @@ -108,27 +112,27 @@ name.abuchen.portfolio.model.proto.v1.PLedgerPostingOrBuilder getPostingsOrBuild int index); /** - * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8; + * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8 [deprecated = true]; */ - java.util.List + @java.lang.Deprecated java.util.List getProjectionRefsList(); /** - * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8; + * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8 [deprecated = true]; */ - name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef getProjectionRefs(int index); + @java.lang.Deprecated name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef getProjectionRefs(int index); /** - * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8; + * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8 [deprecated = true]; */ - int getProjectionRefsCount(); + @java.lang.Deprecated int getProjectionRefsCount(); /** - * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8; + * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8 [deprecated = true]; */ - java.util.List + @java.lang.Deprecated java.util.List getProjectionRefsOrBuilderList(); /** - * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8; + * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8 [deprecated = true]; */ - name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRefOrBuilder getProjectionRefsOrBuilder( + @java.lang.Deprecated name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRefOrBuilder getProjectionRefsOrBuilder( int index); /** @@ -145,7 +149,7 @@ name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRefOrBuilder getProjectio /** * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 10; */ - java.util.List + java.util.List getParametersList(); /** * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 10; @@ -158,7 +162,7 @@ name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRefOrBuilder getProjectio /** * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 10; */ - java.util.List + java.util.List getParametersOrBuilderList(); /** * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 10; diff --git a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerPosting.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerPosting.java index 88ab518a01..92e02fde01 100644 --- a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerPosting.java +++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerPosting.java @@ -24,6 +24,12 @@ private PLedgerPosting() { portfolio_ = ""; parameters_ = java.util.Collections.emptyList(); typeCode_ = ""; + semanticRole_ = ""; + direction_ = ""; + corporateActionLeg_ = ""; + unitRole_ = ""; + groupKey_ = ""; + localKey_ = ""; } @java.lang.Override @@ -51,16 +57,18 @@ protected java.lang.Object newInstance( @SuppressWarnings("serial") private volatile java.lang.Object uuid_ = ""; /** - * string uuid = 1; + * string uuid = 1 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerPosting.uuid is deprecated. + * See client.proto;l=295 * @return The uuid. */ @java.lang.Override - public java.lang.String getUuid() { + @java.lang.Deprecated public java.lang.String getUuid() { java.lang.Object ref = uuid_; if (ref instanceof java.lang.String) { return (java.lang.String) ref; } else { - com.google.protobuf.ByteString bs = + com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; java.lang.String s = bs.toStringUtf8(); uuid_ = s; @@ -68,15 +76,17 @@ public java.lang.String getUuid() { } } /** - * string uuid = 1; + * string uuid = 1 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerPosting.uuid is deprecated. + * See client.proto;l=295 * @return The bytes for uuid. */ @java.lang.Override - public com.google.protobuf.ByteString + @java.lang.Deprecated public com.google.protobuf.ByteString getUuidBytes() { java.lang.Object ref = uuid_; if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); uuid_ = b; @@ -118,7 +128,7 @@ public java.lang.String getCurrency() { if (ref instanceof java.lang.String) { return (java.lang.String) ref; } else { - com.google.protobuf.ByteString bs = + com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; java.lang.String s = bs.toStringUtf8(); currency_ = s; @@ -134,7 +144,7 @@ public java.lang.String getCurrency() { getCurrencyBytes() { java.lang.Object ref = currency_; if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); currency_ = b; @@ -184,7 +194,7 @@ public java.lang.String getForexCurrency() { if (ref instanceof java.lang.String) { return (java.lang.String) ref; } else { - com.google.protobuf.ByteString bs = + com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; java.lang.String s = bs.toStringUtf8(); forexCurrency_ = s; @@ -200,7 +210,7 @@ public java.lang.String getForexCurrency() { getForexCurrencyBytes() { java.lang.Object ref = forexCurrency_; if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); forexCurrency_ = b; @@ -257,7 +267,7 @@ public java.lang.String getSecurity() { if (ref instanceof java.lang.String) { return (java.lang.String) ref; } else { - com.google.protobuf.ByteString bs = + com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; java.lang.String s = bs.toStringUtf8(); security_ = s; @@ -273,7 +283,7 @@ public java.lang.String getSecurity() { getSecurityBytes() { java.lang.Object ref = security_; if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); security_ = b; @@ -315,7 +325,7 @@ public java.lang.String getAccount() { if (ref instanceof java.lang.String) { return (java.lang.String) ref; } else { - com.google.protobuf.ByteString bs = + com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; java.lang.String s = bs.toStringUtf8(); account_ = s; @@ -331,7 +341,7 @@ public java.lang.String getAccount() { getAccountBytes() { java.lang.Object ref = account_; if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); account_ = b; @@ -362,7 +372,7 @@ public java.lang.String getPortfolio() { if (ref instanceof java.lang.String) { return (java.lang.String) ref; } else { - com.google.protobuf.ByteString bs = + com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; java.lang.String s = bs.toStringUtf8(); portfolio_ = s; @@ -378,7 +388,7 @@ public java.lang.String getPortfolio() { getPortfolioBytes() { java.lang.Object ref = portfolio_; if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); portfolio_ = b; @@ -402,7 +412,7 @@ public java.util.List ge * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 12; */ @java.lang.Override - public java.util.List + public java.util.List getParametersOrBuilderList() { return parameters_; } @@ -450,7 +460,7 @@ public java.lang.String getTypeCode() { if (ref instanceof java.lang.String) { return (java.lang.String) ref; } else { - com.google.protobuf.ByteString bs = + com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; java.lang.String s = bs.toStringUtf8(); typeCode_ = s; @@ -466,7 +476,7 @@ public java.lang.String getTypeCode() { getTypeCodeBytes() { java.lang.Object ref = typeCode_; if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); typeCode_ = b; @@ -476,6 +486,288 @@ public java.lang.String getTypeCode() { } } + public static final int SEMANTICROLE_FIELD_NUMBER = 14; + @SuppressWarnings("serial") + private volatile java.lang.Object semanticRole_ = ""; + /** + * optional string semanticRole = 14; + * @return Whether the semanticRole field is set. + */ + @java.lang.Override + public boolean hasSemanticRole() { + return ((bitField0_ & 0x00000100) != 0); + } + /** + * optional string semanticRole = 14; + * @return The semanticRole. + */ + @java.lang.Override + public java.lang.String getSemanticRole() { + java.lang.Object ref = semanticRole_; + if (ref instanceof java.lang.String) { + return (java.lang.String) ref; + } else { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + semanticRole_ = s; + return s; + } + } + /** + * optional string semanticRole = 14; + * @return The bytes for semanticRole. + */ + @java.lang.Override + public com.google.protobuf.ByteString + getSemanticRoleBytes() { + java.lang.Object ref = semanticRole_; + if (ref instanceof java.lang.String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + semanticRole_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + + public static final int DIRECTION_FIELD_NUMBER = 15; + @SuppressWarnings("serial") + private volatile java.lang.Object direction_ = ""; + /** + * optional string direction = 15; + * @return Whether the direction field is set. + */ + @java.lang.Override + public boolean hasDirection() { + return ((bitField0_ & 0x00000200) != 0); + } + /** + * optional string direction = 15; + * @return The direction. + */ + @java.lang.Override + public java.lang.String getDirection() { + java.lang.Object ref = direction_; + if (ref instanceof java.lang.String) { + return (java.lang.String) ref; + } else { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + direction_ = s; + return s; + } + } + /** + * optional string direction = 15; + * @return The bytes for direction. + */ + @java.lang.Override + public com.google.protobuf.ByteString + getDirectionBytes() { + java.lang.Object ref = direction_; + if (ref instanceof java.lang.String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + direction_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + + public static final int CORPORATEACTIONLEG_FIELD_NUMBER = 16; + @SuppressWarnings("serial") + private volatile java.lang.Object corporateActionLeg_ = ""; + /** + * optional string corporateActionLeg = 16; + * @return Whether the corporateActionLeg field is set. + */ + @java.lang.Override + public boolean hasCorporateActionLeg() { + return ((bitField0_ & 0x00000400) != 0); + } + /** + * optional string corporateActionLeg = 16; + * @return The corporateActionLeg. + */ + @java.lang.Override + public java.lang.String getCorporateActionLeg() { + java.lang.Object ref = corporateActionLeg_; + if (ref instanceof java.lang.String) { + return (java.lang.String) ref; + } else { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + corporateActionLeg_ = s; + return s; + } + } + /** + * optional string corporateActionLeg = 16; + * @return The bytes for corporateActionLeg. + */ + @java.lang.Override + public com.google.protobuf.ByteString + getCorporateActionLegBytes() { + java.lang.Object ref = corporateActionLeg_; + if (ref instanceof java.lang.String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + corporateActionLeg_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + + public static final int UNITROLE_FIELD_NUMBER = 17; + @SuppressWarnings("serial") + private volatile java.lang.Object unitRole_ = ""; + /** + * optional string unitRole = 17; + * @return Whether the unitRole field is set. + */ + @java.lang.Override + public boolean hasUnitRole() { + return ((bitField0_ & 0x00000800) != 0); + } + /** + * optional string unitRole = 17; + * @return The unitRole. + */ + @java.lang.Override + public java.lang.String getUnitRole() { + java.lang.Object ref = unitRole_; + if (ref instanceof java.lang.String) { + return (java.lang.String) ref; + } else { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + unitRole_ = s; + return s; + } + } + /** + * optional string unitRole = 17; + * @return The bytes for unitRole. + */ + @java.lang.Override + public com.google.protobuf.ByteString + getUnitRoleBytes() { + java.lang.Object ref = unitRole_; + if (ref instanceof java.lang.String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + unitRole_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + + public static final int GROUPKEY_FIELD_NUMBER = 18; + @SuppressWarnings("serial") + private volatile java.lang.Object groupKey_ = ""; + /** + * optional string groupKey = 18; + * @return Whether the groupKey field is set. + */ + @java.lang.Override + public boolean hasGroupKey() { + return ((bitField0_ & 0x00001000) != 0); + } + /** + * optional string groupKey = 18; + * @return The groupKey. + */ + @java.lang.Override + public java.lang.String getGroupKey() { + java.lang.Object ref = groupKey_; + if (ref instanceof java.lang.String) { + return (java.lang.String) ref; + } else { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + groupKey_ = s; + return s; + } + } + /** + * optional string groupKey = 18; + * @return The bytes for groupKey. + */ + @java.lang.Override + public com.google.protobuf.ByteString + getGroupKeyBytes() { + java.lang.Object ref = groupKey_; + if (ref instanceof java.lang.String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + groupKey_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + + public static final int LOCALKEY_FIELD_NUMBER = 19; + @SuppressWarnings("serial") + private volatile java.lang.Object localKey_ = ""; + /** + * optional string localKey = 19; + * @return Whether the localKey field is set. + */ + @java.lang.Override + public boolean hasLocalKey() { + return ((bitField0_ & 0x00002000) != 0); + } + /** + * optional string localKey = 19; + * @return The localKey. + */ + @java.lang.Override + public java.lang.String getLocalKey() { + java.lang.Object ref = localKey_; + if (ref instanceof java.lang.String) { + return (java.lang.String) ref; + } else { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + localKey_ = s; + return s; + } + } + /** + * optional string localKey = 19; + * @return The bytes for localKey. + */ + @java.lang.Override + public com.google.protobuf.ByteString + getLocalKeyBytes() { + java.lang.Object ref = localKey_; + if (ref instanceof java.lang.String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + localKey_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + private byte memoizedIsInitialized = -1; @java.lang.Override public final boolean isInitialized() { @@ -526,6 +818,24 @@ public void writeTo(com.google.protobuf.CodedOutputStream output) if (((bitField0_ & 0x00000080) != 0)) { com.google.protobuf.GeneratedMessageV3.writeString(output, 13, typeCode_); } + if (((bitField0_ & 0x00000100) != 0)) { + com.google.protobuf.GeneratedMessageV3.writeString(output, 14, semanticRole_); + } + if (((bitField0_ & 0x00000200) != 0)) { + com.google.protobuf.GeneratedMessageV3.writeString(output, 15, direction_); + } + if (((bitField0_ & 0x00000400) != 0)) { + com.google.protobuf.GeneratedMessageV3.writeString(output, 16, corporateActionLeg_); + } + if (((bitField0_ & 0x00000800) != 0)) { + com.google.protobuf.GeneratedMessageV3.writeString(output, 17, unitRole_); + } + if (((bitField0_ & 0x00001000) != 0)) { + com.google.protobuf.GeneratedMessageV3.writeString(output, 18, groupKey_); + } + if (((bitField0_ & 0x00002000) != 0)) { + com.google.protobuf.GeneratedMessageV3.writeString(output, 19, localKey_); + } getUnknownFields().writeTo(output); } @@ -576,6 +886,24 @@ public int getSerializedSize() { if (((bitField0_ & 0x00000080) != 0)) { size += com.google.protobuf.GeneratedMessageV3.computeStringSize(13, typeCode_); } + if (((bitField0_ & 0x00000100) != 0)) { + size += com.google.protobuf.GeneratedMessageV3.computeStringSize(14, semanticRole_); + } + if (((bitField0_ & 0x00000200) != 0)) { + size += com.google.protobuf.GeneratedMessageV3.computeStringSize(15, direction_); + } + if (((bitField0_ & 0x00000400) != 0)) { + size += com.google.protobuf.GeneratedMessageV3.computeStringSize(16, corporateActionLeg_); + } + if (((bitField0_ & 0x00000800) != 0)) { + size += com.google.protobuf.GeneratedMessageV3.computeStringSize(17, unitRole_); + } + if (((bitField0_ & 0x00001000) != 0)) { + size += com.google.protobuf.GeneratedMessageV3.computeStringSize(18, groupKey_); + } + if (((bitField0_ & 0x00002000) != 0)) { + size += com.google.protobuf.GeneratedMessageV3.computeStringSize(19, localKey_); + } size += getUnknownFields().getSerializedSize(); memoizedSize = size; return size; @@ -639,6 +967,36 @@ public boolean equals(final java.lang.Object obj) { if (!getTypeCode() .equals(other.getTypeCode())) return false; } + if (hasSemanticRole() != other.hasSemanticRole()) return false; + if (hasSemanticRole()) { + if (!getSemanticRole() + .equals(other.getSemanticRole())) return false; + } + if (hasDirection() != other.hasDirection()) return false; + if (hasDirection()) { + if (!getDirection() + .equals(other.getDirection())) return false; + } + if (hasCorporateActionLeg() != other.hasCorporateActionLeg()) return false; + if (hasCorporateActionLeg()) { + if (!getCorporateActionLeg() + .equals(other.getCorporateActionLeg())) return false; + } + if (hasUnitRole() != other.hasUnitRole()) return false; + if (hasUnitRole()) { + if (!getUnitRole() + .equals(other.getUnitRole())) return false; + } + if (hasGroupKey() != other.hasGroupKey()) return false; + if (hasGroupKey()) { + if (!getGroupKey() + .equals(other.getGroupKey())) return false; + } + if (hasLocalKey() != other.hasLocalKey()) return false; + if (hasLocalKey()) { + if (!getLocalKey() + .equals(other.getLocalKey())) return false; + } if (!getUnknownFields().equals(other.getUnknownFields())) return false; return true; } @@ -695,6 +1053,30 @@ public int hashCode() { hash = (37 * hash) + TYPECODE_FIELD_NUMBER; hash = (53 * hash) + getTypeCode().hashCode(); } + if (hasSemanticRole()) { + hash = (37 * hash) + SEMANTICROLE_FIELD_NUMBER; + hash = (53 * hash) + getSemanticRole().hashCode(); + } + if (hasDirection()) { + hash = (37 * hash) + DIRECTION_FIELD_NUMBER; + hash = (53 * hash) + getDirection().hashCode(); + } + if (hasCorporateActionLeg()) { + hash = (37 * hash) + CORPORATEACTIONLEG_FIELD_NUMBER; + hash = (53 * hash) + getCorporateActionLeg().hashCode(); + } + if (hasUnitRole()) { + hash = (37 * hash) + UNITROLE_FIELD_NUMBER; + hash = (53 * hash) + getUnitRole().hashCode(); + } + if (hasGroupKey()) { + hash = (37 * hash) + GROUPKEY_FIELD_NUMBER; + hash = (53 * hash) + getGroupKey().hashCode(); + } + if (hasLocalKey()) { + hash = (37 * hash) + LOCALKEY_FIELD_NUMBER; + hash = (53 * hash) + getLocalKey().hashCode(); + } hash = (29 * hash) + getUnknownFields().hashCode(); memoizedHashCode = hash; return hash; @@ -853,6 +1235,12 @@ public Builder clear() { } bitField0_ = (bitField0_ & ~0x00000400); typeCode_ = ""; + semanticRole_ = ""; + direction_ = ""; + corporateActionLeg_ = ""; + unitRole_ = ""; + groupKey_ = ""; + localKey_ = ""; return this; } @@ -943,6 +1331,30 @@ private void buildPartial0(name.abuchen.portfolio.model.proto.v1.PLedgerPosting result.typeCode_ = typeCode_; to_bitField0_ |= 0x00000080; } + if (((from_bitField0_ & 0x00001000) != 0)) { + result.semanticRole_ = semanticRole_; + to_bitField0_ |= 0x00000100; + } + if (((from_bitField0_ & 0x00002000) != 0)) { + result.direction_ = direction_; + to_bitField0_ |= 0x00000200; + } + if (((from_bitField0_ & 0x00004000) != 0)) { + result.corporateActionLeg_ = corporateActionLeg_; + to_bitField0_ |= 0x00000400; + } + if (((from_bitField0_ & 0x00008000) != 0)) { + result.unitRole_ = unitRole_; + to_bitField0_ |= 0x00000800; + } + if (((from_bitField0_ & 0x00010000) != 0)) { + result.groupKey_ = groupKey_; + to_bitField0_ |= 0x00001000; + } + if (((from_bitField0_ & 0x00020000) != 0)) { + result.localKey_ = localKey_; + to_bitField0_ |= 0x00002000; + } result.bitField0_ |= to_bitField0_; } @@ -1018,7 +1430,7 @@ public Builder mergeFrom(name.abuchen.portfolio.model.proto.v1.PLedgerPosting ot parametersBuilder_ = null; parameters_ = other.parameters_; bitField0_ = (bitField0_ & ~0x00000400); - parametersBuilder_ = + parametersBuilder_ = com.google.protobuf.GeneratedMessageV3.alwaysUseFieldBuilders ? getParametersFieldBuilder() : null; } else { @@ -1031,6 +1443,36 @@ public Builder mergeFrom(name.abuchen.portfolio.model.proto.v1.PLedgerPosting ot bitField0_ |= 0x00000800; onChanged(); } + if (other.hasSemanticRole()) { + semanticRole_ = other.semanticRole_; + bitField0_ |= 0x00001000; + onChanged(); + } + if (other.hasDirection()) { + direction_ = other.direction_; + bitField0_ |= 0x00002000; + onChanged(); + } + if (other.hasCorporateActionLeg()) { + corporateActionLeg_ = other.corporateActionLeg_; + bitField0_ |= 0x00004000; + onChanged(); + } + if (other.hasUnitRole()) { + unitRole_ = other.unitRole_; + bitField0_ |= 0x00008000; + onChanged(); + } + if (other.hasGroupKey()) { + groupKey_ = other.groupKey_; + bitField0_ |= 0x00010000; + onChanged(); + } + if (other.hasLocalKey()) { + localKey_ = other.localKey_; + bitField0_ |= 0x00020000; + onChanged(); + } this.mergeUnknownFields(other.getUnknownFields()); onChanged(); return this; @@ -1127,6 +1569,36 @@ public Builder mergeFrom( bitField0_ |= 0x00000800; break; } // case 106 + case 114: { + semanticRole_ = input.readStringRequireUtf8(); + bitField0_ |= 0x00001000; + break; + } // case 114 + case 122: { + direction_ = input.readStringRequireUtf8(); + bitField0_ |= 0x00002000; + break; + } // case 122 + case 130: { + corporateActionLeg_ = input.readStringRequireUtf8(); + bitField0_ |= 0x00004000; + break; + } // case 130 + case 138: { + unitRole_ = input.readStringRequireUtf8(); + bitField0_ |= 0x00008000; + break; + } // case 138 + case 146: { + groupKey_ = input.readStringRequireUtf8(); + bitField0_ |= 0x00010000; + break; + } // case 146 + case 154: { + localKey_ = input.readStringRequireUtf8(); + bitField0_ |= 0x00020000; + break; + } // case 154 default: { if (!super.parseUnknownField(input, extensionRegistry, tag)) { done = true; // was an endgroup tag @@ -1146,10 +1618,12 @@ public Builder mergeFrom( private java.lang.Object uuid_ = ""; /** - * string uuid = 1; + * string uuid = 1 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerPosting.uuid is deprecated. + * See client.proto;l=295 * @return The uuid. */ - public java.lang.String getUuid() { + @java.lang.Deprecated public java.lang.String getUuid() { java.lang.Object ref = uuid_; if (!(ref instanceof java.lang.String)) { com.google.protobuf.ByteString bs = @@ -1162,14 +1636,16 @@ public java.lang.String getUuid() { } } /** - * string uuid = 1; + * string uuid = 1 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerPosting.uuid is deprecated. + * See client.proto;l=295 * @return The bytes for uuid. */ - public com.google.protobuf.ByteString + @java.lang.Deprecated public com.google.protobuf.ByteString getUuidBytes() { java.lang.Object ref = uuid_; if (ref instanceof String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); uuid_ = b; @@ -1179,11 +1655,13 @@ public java.lang.String getUuid() { } } /** - * string uuid = 1; + * string uuid = 1 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerPosting.uuid is deprecated. + * See client.proto;l=295 * @param value The uuid to set. * @return This builder for chaining. */ - public Builder setUuid( + @java.lang.Deprecated public Builder setUuid( java.lang.String value) { if (value == null) { throw new NullPointerException(); } uuid_ = value; @@ -1192,21 +1670,25 @@ public Builder setUuid( return this; } /** - * string uuid = 1; + * string uuid = 1 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerPosting.uuid is deprecated. + * See client.proto;l=295 * @return This builder for chaining. */ - public Builder clearUuid() { + @java.lang.Deprecated public Builder clearUuid() { uuid_ = getDefaultInstance().getUuid(); bitField0_ = (bitField0_ & ~0x00000001); onChanged(); return this; } /** - * string uuid = 1; + * string uuid = 1 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerPosting.uuid is deprecated. + * See client.proto;l=295 * @param value The bytes for uuid to set. * @return This builder for chaining. */ - public Builder setUuidBytes( + @java.lang.Deprecated public Builder setUuidBytes( com.google.protobuf.ByteString value) { if (value == null) { throw new NullPointerException(); } checkByteStringIsUtf8(value); @@ -1280,7 +1762,7 @@ public java.lang.String getCurrency() { getCurrencyBytes() { java.lang.Object ref = currency_; if (ref instanceof String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); currency_ = b; @@ -1399,7 +1881,7 @@ public java.lang.String getForexCurrency() { getForexCurrencyBytes() { java.lang.Object ref = forexCurrency_; if (ref instanceof String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); forexCurrency_ = b; @@ -1552,7 +2034,7 @@ public name.abuchen.portfolio.model.proto.v1.PDecimalValueOrBuilder getExchangeR * optional .name.abuchen.portfolio.PDecimalValue exchangeRate = 7; */ private com.google.protobuf.SingleFieldBuilderV3< - name.abuchen.portfolio.model.proto.v1.PDecimalValue, name.abuchen.portfolio.model.proto.v1.PDecimalValue.Builder, name.abuchen.portfolio.model.proto.v1.PDecimalValueOrBuilder> + name.abuchen.portfolio.model.proto.v1.PDecimalValue, name.abuchen.portfolio.model.proto.v1.PDecimalValue.Builder, name.abuchen.portfolio.model.proto.v1.PDecimalValueOrBuilder> getExchangeRateFieldBuilder() { if (exchangeRateBuilder_ == null) { exchangeRateBuilder_ = new com.google.protobuf.SingleFieldBuilderV3< @@ -1597,7 +2079,7 @@ public java.lang.String getSecurity() { getSecurityBytes() { java.lang.Object ref = security_; if (ref instanceof String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); security_ = b; @@ -1708,7 +2190,7 @@ public java.lang.String getAccount() { getAccountBytes() { java.lang.Object ref = account_; if (ref instanceof String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); account_ = b; @@ -1787,7 +2269,7 @@ public java.lang.String getPortfolio() { getPortfolioBytes() { java.lang.Object ref = portfolio_; if (ref instanceof String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); portfolio_ = b; @@ -2029,7 +2511,7 @@ public name.abuchen.portfolio.model.proto.v1.PLedgerParameterOrBuilder getParame /** * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 12; */ - public java.util.List + public java.util.List getParametersOrBuilderList() { if (parametersBuilder_ != null) { return parametersBuilder_.getMessageOrBuilderList(); @@ -2055,12 +2537,12 @@ public name.abuchen.portfolio.model.proto.v1.PLedgerParameter.Builder addParamet /** * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 12; */ - public java.util.List + public java.util.List getParametersBuilderList() { return getParametersFieldBuilder().getBuilderList(); } private com.google.protobuf.RepeatedFieldBuilderV3< - name.abuchen.portfolio.model.proto.v1.PLedgerParameter, name.abuchen.portfolio.model.proto.v1.PLedgerParameter.Builder, name.abuchen.portfolio.model.proto.v1.PLedgerParameterOrBuilder> + name.abuchen.portfolio.model.proto.v1.PLedgerParameter, name.abuchen.portfolio.model.proto.v1.PLedgerParameter.Builder, name.abuchen.portfolio.model.proto.v1.PLedgerParameterOrBuilder> getParametersFieldBuilder() { if (parametersBuilder_ == null) { parametersBuilder_ = new com.google.protobuf.RepeatedFieldBuilderV3< @@ -2106,7 +2588,7 @@ public java.lang.String getTypeCode() { getTypeCodeBytes() { java.lang.Object ref = typeCode_; if (ref instanceof String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); typeCode_ = b; @@ -2152,6 +2634,480 @@ public Builder setTypeCodeBytes( onChanged(); return this; } + + private java.lang.Object semanticRole_ = ""; + /** + * optional string semanticRole = 14; + * @return Whether the semanticRole field is set. + */ + public boolean hasSemanticRole() { + return ((bitField0_ & 0x00001000) != 0); + } + /** + * optional string semanticRole = 14; + * @return The semanticRole. + */ + public java.lang.String getSemanticRole() { + java.lang.Object ref = semanticRole_; + if (!(ref instanceof java.lang.String)) { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + semanticRole_ = s; + return s; + } else { + return (java.lang.String) ref; + } + } + /** + * optional string semanticRole = 14; + * @return The bytes for semanticRole. + */ + public com.google.protobuf.ByteString + getSemanticRoleBytes() { + java.lang.Object ref = semanticRole_; + if (ref instanceof String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + semanticRole_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + /** + * optional string semanticRole = 14; + * @param value The semanticRole to set. + * @return This builder for chaining. + */ + public Builder setSemanticRole( + java.lang.String value) { + if (value == null) { throw new NullPointerException(); } + semanticRole_ = value; + bitField0_ |= 0x00001000; + onChanged(); + return this; + } + /** + * optional string semanticRole = 14; + * @return This builder for chaining. + */ + public Builder clearSemanticRole() { + semanticRole_ = getDefaultInstance().getSemanticRole(); + bitField0_ = (bitField0_ & ~0x00001000); + onChanged(); + return this; + } + /** + * optional string semanticRole = 14; + * @param value The bytes for semanticRole to set. + * @return This builder for chaining. + */ + public Builder setSemanticRoleBytes( + com.google.protobuf.ByteString value) { + if (value == null) { throw new NullPointerException(); } + checkByteStringIsUtf8(value); + semanticRole_ = value; + bitField0_ |= 0x00001000; + onChanged(); + return this; + } + + private java.lang.Object direction_ = ""; + /** + * optional string direction = 15; + * @return Whether the direction field is set. + */ + public boolean hasDirection() { + return ((bitField0_ & 0x00002000) != 0); + } + /** + * optional string direction = 15; + * @return The direction. + */ + public java.lang.String getDirection() { + java.lang.Object ref = direction_; + if (!(ref instanceof java.lang.String)) { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + direction_ = s; + return s; + } else { + return (java.lang.String) ref; + } + } + /** + * optional string direction = 15; + * @return The bytes for direction. + */ + public com.google.protobuf.ByteString + getDirectionBytes() { + java.lang.Object ref = direction_; + if (ref instanceof String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + direction_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + /** + * optional string direction = 15; + * @param value The direction to set. + * @return This builder for chaining. + */ + public Builder setDirection( + java.lang.String value) { + if (value == null) { throw new NullPointerException(); } + direction_ = value; + bitField0_ |= 0x00002000; + onChanged(); + return this; + } + /** + * optional string direction = 15; + * @return This builder for chaining. + */ + public Builder clearDirection() { + direction_ = getDefaultInstance().getDirection(); + bitField0_ = (bitField0_ & ~0x00002000); + onChanged(); + return this; + } + /** + * optional string direction = 15; + * @param value The bytes for direction to set. + * @return This builder for chaining. + */ + public Builder setDirectionBytes( + com.google.protobuf.ByteString value) { + if (value == null) { throw new NullPointerException(); } + checkByteStringIsUtf8(value); + direction_ = value; + bitField0_ |= 0x00002000; + onChanged(); + return this; + } + + private java.lang.Object corporateActionLeg_ = ""; + /** + * optional string corporateActionLeg = 16; + * @return Whether the corporateActionLeg field is set. + */ + public boolean hasCorporateActionLeg() { + return ((bitField0_ & 0x00004000) != 0); + } + /** + * optional string corporateActionLeg = 16; + * @return The corporateActionLeg. + */ + public java.lang.String getCorporateActionLeg() { + java.lang.Object ref = corporateActionLeg_; + if (!(ref instanceof java.lang.String)) { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + corporateActionLeg_ = s; + return s; + } else { + return (java.lang.String) ref; + } + } + /** + * optional string corporateActionLeg = 16; + * @return The bytes for corporateActionLeg. + */ + public com.google.protobuf.ByteString + getCorporateActionLegBytes() { + java.lang.Object ref = corporateActionLeg_; + if (ref instanceof String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + corporateActionLeg_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + /** + * optional string corporateActionLeg = 16; + * @param value The corporateActionLeg to set. + * @return This builder for chaining. + */ + public Builder setCorporateActionLeg( + java.lang.String value) { + if (value == null) { throw new NullPointerException(); } + corporateActionLeg_ = value; + bitField0_ |= 0x00004000; + onChanged(); + return this; + } + /** + * optional string corporateActionLeg = 16; + * @return This builder for chaining. + */ + public Builder clearCorporateActionLeg() { + corporateActionLeg_ = getDefaultInstance().getCorporateActionLeg(); + bitField0_ = (bitField0_ & ~0x00004000); + onChanged(); + return this; + } + /** + * optional string corporateActionLeg = 16; + * @param value The bytes for corporateActionLeg to set. + * @return This builder for chaining. + */ + public Builder setCorporateActionLegBytes( + com.google.protobuf.ByteString value) { + if (value == null) { throw new NullPointerException(); } + checkByteStringIsUtf8(value); + corporateActionLeg_ = value; + bitField0_ |= 0x00004000; + onChanged(); + return this; + } + + private java.lang.Object unitRole_ = ""; + /** + * optional string unitRole = 17; + * @return Whether the unitRole field is set. + */ + public boolean hasUnitRole() { + return ((bitField0_ & 0x00008000) != 0); + } + /** + * optional string unitRole = 17; + * @return The unitRole. + */ + public java.lang.String getUnitRole() { + java.lang.Object ref = unitRole_; + if (!(ref instanceof java.lang.String)) { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + unitRole_ = s; + return s; + } else { + return (java.lang.String) ref; + } + } + /** + * optional string unitRole = 17; + * @return The bytes for unitRole. + */ + public com.google.protobuf.ByteString + getUnitRoleBytes() { + java.lang.Object ref = unitRole_; + if (ref instanceof String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + unitRole_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + /** + * optional string unitRole = 17; + * @param value The unitRole to set. + * @return This builder for chaining. + */ + public Builder setUnitRole( + java.lang.String value) { + if (value == null) { throw new NullPointerException(); } + unitRole_ = value; + bitField0_ |= 0x00008000; + onChanged(); + return this; + } + /** + * optional string unitRole = 17; + * @return This builder for chaining. + */ + public Builder clearUnitRole() { + unitRole_ = getDefaultInstance().getUnitRole(); + bitField0_ = (bitField0_ & ~0x00008000); + onChanged(); + return this; + } + /** + * optional string unitRole = 17; + * @param value The bytes for unitRole to set. + * @return This builder for chaining. + */ + public Builder setUnitRoleBytes( + com.google.protobuf.ByteString value) { + if (value == null) { throw new NullPointerException(); } + checkByteStringIsUtf8(value); + unitRole_ = value; + bitField0_ |= 0x00008000; + onChanged(); + return this; + } + + private java.lang.Object groupKey_ = ""; + /** + * optional string groupKey = 18; + * @return Whether the groupKey field is set. + */ + public boolean hasGroupKey() { + return ((bitField0_ & 0x00010000) != 0); + } + /** + * optional string groupKey = 18; + * @return The groupKey. + */ + public java.lang.String getGroupKey() { + java.lang.Object ref = groupKey_; + if (!(ref instanceof java.lang.String)) { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + groupKey_ = s; + return s; + } else { + return (java.lang.String) ref; + } + } + /** + * optional string groupKey = 18; + * @return The bytes for groupKey. + */ + public com.google.protobuf.ByteString + getGroupKeyBytes() { + java.lang.Object ref = groupKey_; + if (ref instanceof String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + groupKey_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + /** + * optional string groupKey = 18; + * @param value The groupKey to set. + * @return This builder for chaining. + */ + public Builder setGroupKey( + java.lang.String value) { + if (value == null) { throw new NullPointerException(); } + groupKey_ = value; + bitField0_ |= 0x00010000; + onChanged(); + return this; + } + /** + * optional string groupKey = 18; + * @return This builder for chaining. + */ + public Builder clearGroupKey() { + groupKey_ = getDefaultInstance().getGroupKey(); + bitField0_ = (bitField0_ & ~0x00010000); + onChanged(); + return this; + } + /** + * optional string groupKey = 18; + * @param value The bytes for groupKey to set. + * @return This builder for chaining. + */ + public Builder setGroupKeyBytes( + com.google.protobuf.ByteString value) { + if (value == null) { throw new NullPointerException(); } + checkByteStringIsUtf8(value); + groupKey_ = value; + bitField0_ |= 0x00010000; + onChanged(); + return this; + } + + private java.lang.Object localKey_ = ""; + /** + * optional string localKey = 19; + * @return Whether the localKey field is set. + */ + public boolean hasLocalKey() { + return ((bitField0_ & 0x00020000) != 0); + } + /** + * optional string localKey = 19; + * @return The localKey. + */ + public java.lang.String getLocalKey() { + java.lang.Object ref = localKey_; + if (!(ref instanceof java.lang.String)) { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + localKey_ = s; + return s; + } else { + return (java.lang.String) ref; + } + } + /** + * optional string localKey = 19; + * @return The bytes for localKey. + */ + public com.google.protobuf.ByteString + getLocalKeyBytes() { + java.lang.Object ref = localKey_; + if (ref instanceof String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + localKey_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + /** + * optional string localKey = 19; + * @param value The localKey to set. + * @return This builder for chaining. + */ + public Builder setLocalKey( + java.lang.String value) { + if (value == null) { throw new NullPointerException(); } + localKey_ = value; + bitField0_ |= 0x00020000; + onChanged(); + return this; + } + /** + * optional string localKey = 19; + * @return This builder for chaining. + */ + public Builder clearLocalKey() { + localKey_ = getDefaultInstance().getLocalKey(); + bitField0_ = (bitField0_ & ~0x00020000); + onChanged(); + return this; + } + /** + * optional string localKey = 19; + * @param value The bytes for localKey to set. + * @return This builder for chaining. + */ + public Builder setLocalKeyBytes( + com.google.protobuf.ByteString value) { + if (value == null) { throw new NullPointerException(); } + checkByteStringIsUtf8(value); + localKey_ = value; + bitField0_ |= 0x00020000; + onChanged(); + return this; + } @java.lang.Override public final Builder setUnknownFields( final com.google.protobuf.UnknownFieldSet unknownFields) { diff --git a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerPostingOrBuilder.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerPostingOrBuilder.java index dcceff4bcf..e951f65b67 100644 --- a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerPostingOrBuilder.java +++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerPostingOrBuilder.java @@ -8,15 +8,19 @@ public interface PLedgerPostingOrBuilder extends com.google.protobuf.MessageOrBuilder { /** - * string uuid = 1; + * string uuid = 1 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerPosting.uuid is deprecated. + * See client.proto;l=295 * @return The uuid. */ - java.lang.String getUuid(); + @java.lang.Deprecated java.lang.String getUuid(); /** - * string uuid = 1; + * string uuid = 1 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerPosting.uuid is deprecated. + * See client.proto;l=295 * @return The bytes for uuid. */ - com.google.protobuf.ByteString + @java.lang.Deprecated com.google.protobuf.ByteString getUuidBytes(); /** @@ -145,7 +149,7 @@ public interface PLedgerPostingOrBuilder extends /** * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 12; */ - java.util.List + java.util.List getParametersList(); /** * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 12; @@ -158,7 +162,7 @@ public interface PLedgerPostingOrBuilder extends /** * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 12; */ - java.util.List + java.util.List getParametersOrBuilderList(); /** * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 12; @@ -182,4 +186,106 @@ name.abuchen.portfolio.model.proto.v1.PLedgerParameterOrBuilder getParametersOrB */ com.google.protobuf.ByteString getTypeCodeBytes(); + + /** + * optional string semanticRole = 14; + * @return Whether the semanticRole field is set. + */ + boolean hasSemanticRole(); + /** + * optional string semanticRole = 14; + * @return The semanticRole. + */ + java.lang.String getSemanticRole(); + /** + * optional string semanticRole = 14; + * @return The bytes for semanticRole. + */ + com.google.protobuf.ByteString + getSemanticRoleBytes(); + + /** + * optional string direction = 15; + * @return Whether the direction field is set. + */ + boolean hasDirection(); + /** + * optional string direction = 15; + * @return The direction. + */ + java.lang.String getDirection(); + /** + * optional string direction = 15; + * @return The bytes for direction. + */ + com.google.protobuf.ByteString + getDirectionBytes(); + + /** + * optional string corporateActionLeg = 16; + * @return Whether the corporateActionLeg field is set. + */ + boolean hasCorporateActionLeg(); + /** + * optional string corporateActionLeg = 16; + * @return The corporateActionLeg. + */ + java.lang.String getCorporateActionLeg(); + /** + * optional string corporateActionLeg = 16; + * @return The bytes for corporateActionLeg. + */ + com.google.protobuf.ByteString + getCorporateActionLegBytes(); + + /** + * optional string unitRole = 17; + * @return Whether the unitRole field is set. + */ + boolean hasUnitRole(); + /** + * optional string unitRole = 17; + * @return The unitRole. + */ + java.lang.String getUnitRole(); + /** + * optional string unitRole = 17; + * @return The bytes for unitRole. + */ + com.google.protobuf.ByteString + getUnitRoleBytes(); + + /** + * optional string groupKey = 18; + * @return Whether the groupKey field is set. + */ + boolean hasGroupKey(); + /** + * optional string groupKey = 18; + * @return The groupKey. + */ + java.lang.String getGroupKey(); + /** + * optional string groupKey = 18; + * @return The bytes for groupKey. + */ + com.google.protobuf.ByteString + getGroupKeyBytes(); + + /** + * optional string localKey = 19; + * @return Whether the localKey field is set. + */ + boolean hasLocalKey(); + /** + * optional string localKey = 19; + * @return The localKey. + */ + java.lang.String getLocalKey(); + /** + * optional string localKey = 19; + * @return The bytes for localKey. + */ + com.google.protobuf.ByteString + getLocalKeyBytes(); } diff --git a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionMembership.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionMembership.java index 62db2772e9..6f781bf985 100644 --- a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionMembership.java +++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionMembership.java @@ -44,16 +44,18 @@ protected java.lang.Object newInstance( @SuppressWarnings("serial") private volatile java.lang.Object postingUUID_ = ""; /** - * string postingUUID = 1; + * string postingUUID = 1 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionMembership.postingUUID is deprecated. + * See client.proto;l=325 * @return The postingUUID. */ @java.lang.Override - public java.lang.String getPostingUUID() { + @java.lang.Deprecated public java.lang.String getPostingUUID() { java.lang.Object ref = postingUUID_; if (ref instanceof java.lang.String) { return (java.lang.String) ref; } else { - com.google.protobuf.ByteString bs = + com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; java.lang.String s = bs.toStringUtf8(); postingUUID_ = s; @@ -61,15 +63,17 @@ public java.lang.String getPostingUUID() { } } /** - * string postingUUID = 1; + * string postingUUID = 1 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionMembership.postingUUID is deprecated. + * See client.proto;l=325 * @return The bytes for postingUUID. */ @java.lang.Override - public com.google.protobuf.ByteString + @java.lang.Deprecated public com.google.protobuf.ByteString getPostingUUIDBytes() { java.lang.Object ref = postingUUID_; if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); postingUUID_ = b; @@ -82,17 +86,21 @@ public java.lang.String getPostingUUID() { public static final int ROLE_FIELD_NUMBER = 2; private int role_ = 0; /** - * .name.abuchen.portfolio.PLedgerProjectionMembershipRole role = 2; + * .name.abuchen.portfolio.PLedgerProjectionMembershipRole role = 2 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionMembership.role is deprecated. + * See client.proto;l=326 * @return The enum numeric value on the wire for role. */ - @java.lang.Override public int getRoleValue() { + @java.lang.Override @java.lang.Deprecated public int getRoleValue() { return role_; } /** - * .name.abuchen.portfolio.PLedgerProjectionMembershipRole role = 2; + * .name.abuchen.portfolio.PLedgerProjectionMembershipRole role = 2 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionMembership.role is deprecated. + * See client.proto;l=326 * @return The role. */ - @java.lang.Override public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipRole getRole() { + @java.lang.Override @java.lang.Deprecated public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipRole getRole() { name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipRole result = name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipRole.forNumber(role_); return result == null ? name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipRole.UNRECOGNIZED : result; } @@ -413,10 +421,12 @@ public Builder mergeFrom( private java.lang.Object postingUUID_ = ""; /** - * string postingUUID = 1; + * string postingUUID = 1 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionMembership.postingUUID is deprecated. + * See client.proto;l=325 * @return The postingUUID. */ - public java.lang.String getPostingUUID() { + @java.lang.Deprecated public java.lang.String getPostingUUID() { java.lang.Object ref = postingUUID_; if (!(ref instanceof java.lang.String)) { com.google.protobuf.ByteString bs = @@ -429,14 +439,16 @@ public java.lang.String getPostingUUID() { } } /** - * string postingUUID = 1; + * string postingUUID = 1 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionMembership.postingUUID is deprecated. + * See client.proto;l=325 * @return The bytes for postingUUID. */ - public com.google.protobuf.ByteString + @java.lang.Deprecated public com.google.protobuf.ByteString getPostingUUIDBytes() { java.lang.Object ref = postingUUID_; if (ref instanceof String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); postingUUID_ = b; @@ -446,11 +458,13 @@ public java.lang.String getPostingUUID() { } } /** - * string postingUUID = 1; + * string postingUUID = 1 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionMembership.postingUUID is deprecated. + * See client.proto;l=325 * @param value The postingUUID to set. * @return This builder for chaining. */ - public Builder setPostingUUID( + @java.lang.Deprecated public Builder setPostingUUID( java.lang.String value) { if (value == null) { throw new NullPointerException(); } postingUUID_ = value; @@ -459,21 +473,25 @@ public Builder setPostingUUID( return this; } /** - * string postingUUID = 1; + * string postingUUID = 1 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionMembership.postingUUID is deprecated. + * See client.proto;l=325 * @return This builder for chaining. */ - public Builder clearPostingUUID() { + @java.lang.Deprecated public Builder clearPostingUUID() { postingUUID_ = getDefaultInstance().getPostingUUID(); bitField0_ = (bitField0_ & ~0x00000001); onChanged(); return this; } /** - * string postingUUID = 1; + * string postingUUID = 1 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionMembership.postingUUID is deprecated. + * See client.proto;l=325 * @param value The bytes for postingUUID to set. * @return This builder for chaining. */ - public Builder setPostingUUIDBytes( + @java.lang.Deprecated public Builder setPostingUUIDBytes( com.google.protobuf.ByteString value) { if (value == null) { throw new NullPointerException(); } checkByteStringIsUtf8(value); @@ -485,38 +503,46 @@ public Builder setPostingUUIDBytes( private int role_ = 0; /** - * .name.abuchen.portfolio.PLedgerProjectionMembershipRole role = 2; + * .name.abuchen.portfolio.PLedgerProjectionMembershipRole role = 2 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionMembership.role is deprecated. + * See client.proto;l=326 * @return The enum numeric value on the wire for role. */ - @java.lang.Override public int getRoleValue() { + @java.lang.Override @java.lang.Deprecated public int getRoleValue() { return role_; } /** - * .name.abuchen.portfolio.PLedgerProjectionMembershipRole role = 2; + * .name.abuchen.portfolio.PLedgerProjectionMembershipRole role = 2 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionMembership.role is deprecated. + * See client.proto;l=326 * @param value The enum numeric value on the wire for role to set. * @return This builder for chaining. */ - public Builder setRoleValue(int value) { + @java.lang.Deprecated public Builder setRoleValue(int value) { role_ = value; bitField0_ |= 0x00000002; onChanged(); return this; } /** - * .name.abuchen.portfolio.PLedgerProjectionMembershipRole role = 2; + * .name.abuchen.portfolio.PLedgerProjectionMembershipRole role = 2 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionMembership.role is deprecated. + * See client.proto;l=326 * @return The role. */ @java.lang.Override - public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipRole getRole() { + @java.lang.Deprecated public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipRole getRole() { name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipRole result = name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipRole.forNumber(role_); return result == null ? name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipRole.UNRECOGNIZED : result; } /** - * .name.abuchen.portfolio.PLedgerProjectionMembershipRole role = 2; + * .name.abuchen.portfolio.PLedgerProjectionMembershipRole role = 2 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionMembership.role is deprecated. + * See client.proto;l=326 * @param value The role to set. * @return This builder for chaining. */ - public Builder setRole(name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipRole value) { + @java.lang.Deprecated public Builder setRole(name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipRole value) { if (value == null) { throw new NullPointerException(); } @@ -526,10 +552,12 @@ public Builder setRole(name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMe return this; } /** - * .name.abuchen.portfolio.PLedgerProjectionMembershipRole role = 2; + * .name.abuchen.portfolio.PLedgerProjectionMembershipRole role = 2 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionMembership.role is deprecated. + * See client.proto;l=326 * @return This builder for chaining. */ - public Builder clearRole() { + @java.lang.Deprecated public Builder clearRole() { bitField0_ = (bitField0_ & ~0x00000002); role_ = 0; onChanged(); diff --git a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionMembershipOrBuilder.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionMembershipOrBuilder.java index 59a3d0218f..9332cfc215 100644 --- a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionMembershipOrBuilder.java +++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionMembershipOrBuilder.java @@ -8,25 +8,33 @@ public interface PLedgerProjectionMembershipOrBuilder extends com.google.protobuf.MessageOrBuilder { /** - * string postingUUID = 1; + * string postingUUID = 1 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionMembership.postingUUID is deprecated. + * See client.proto;l=325 * @return The postingUUID. */ - java.lang.String getPostingUUID(); + @java.lang.Deprecated java.lang.String getPostingUUID(); /** - * string postingUUID = 1; + * string postingUUID = 1 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionMembership.postingUUID is deprecated. + * See client.proto;l=325 * @return The bytes for postingUUID. */ - com.google.protobuf.ByteString + @java.lang.Deprecated com.google.protobuf.ByteString getPostingUUIDBytes(); /** - * .name.abuchen.portfolio.PLedgerProjectionMembershipRole role = 2; + * .name.abuchen.portfolio.PLedgerProjectionMembershipRole role = 2 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionMembership.role is deprecated. + * See client.proto;l=326 * @return The enum numeric value on the wire for role. */ - int getRoleValue(); + @java.lang.Deprecated int getRoleValue(); /** - * .name.abuchen.portfolio.PLedgerProjectionMembershipRole role = 2; + * .name.abuchen.portfolio.PLedgerProjectionMembershipRole role = 2 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionMembership.role is deprecated. + * See client.proto;l=326 * @return The role. */ - name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipRole getRole(); + @java.lang.Deprecated name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipRole getRole(); } diff --git a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionRef.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionRef.java index 7b5a596bf4..5922859927 100644 --- a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionRef.java +++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionRef.java @@ -48,16 +48,18 @@ protected java.lang.Object newInstance( @SuppressWarnings("serial") private volatile java.lang.Object uuid_ = ""; /** - * string uuid = 1; + * string uuid = 1 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.uuid is deprecated. + * See client.proto;l=317 * @return The uuid. */ @java.lang.Override - public java.lang.String getUuid() { + @java.lang.Deprecated public java.lang.String getUuid() { java.lang.Object ref = uuid_; if (ref instanceof java.lang.String) { return (java.lang.String) ref; } else { - com.google.protobuf.ByteString bs = + com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; java.lang.String s = bs.toStringUtf8(); uuid_ = s; @@ -65,15 +67,17 @@ public java.lang.String getUuid() { } } /** - * string uuid = 1; + * string uuid = 1 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.uuid is deprecated. + * See client.proto;l=317 * @return The bytes for uuid. */ @java.lang.Override - public com.google.protobuf.ByteString + @java.lang.Deprecated public com.google.protobuf.ByteString getUuidBytes() { java.lang.Object ref = uuid_; if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); uuid_ = b; @@ -86,17 +90,21 @@ public java.lang.String getUuid() { public static final int ROLE_FIELD_NUMBER = 2; private int role_ = 0; /** - * .name.abuchen.portfolio.PLedgerProjectionRole role = 2; + * .name.abuchen.portfolio.PLedgerProjectionRole role = 2 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.role is deprecated. + * See client.proto;l=318 * @return The enum numeric value on the wire for role. */ - @java.lang.Override public int getRoleValue() { + @java.lang.Override @java.lang.Deprecated public int getRoleValue() { return role_; } /** - * .name.abuchen.portfolio.PLedgerProjectionRole role = 2; + * .name.abuchen.portfolio.PLedgerProjectionRole role = 2 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.role is deprecated. + * See client.proto;l=318 * @return The role. */ - @java.lang.Override public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRole getRole() { + @java.lang.Override @java.lang.Deprecated public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRole getRole() { name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRole result = name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRole.forNumber(role_); return result == null ? name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRole.UNRECOGNIZED : result; } @@ -105,24 +113,28 @@ public java.lang.String getUuid() { @SuppressWarnings("serial") private volatile java.lang.Object account_ = ""; /** - * optional string account = 3; + * optional string account = 3 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.account is deprecated. + * See client.proto;l=319 * @return Whether the account field is set. */ @java.lang.Override - public boolean hasAccount() { + @java.lang.Deprecated public boolean hasAccount() { return ((bitField0_ & 0x00000001) != 0); } /** - * optional string account = 3; + * optional string account = 3 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.account is deprecated. + * See client.proto;l=319 * @return The account. */ @java.lang.Override - public java.lang.String getAccount() { + @java.lang.Deprecated public java.lang.String getAccount() { java.lang.Object ref = account_; if (ref instanceof java.lang.String) { return (java.lang.String) ref; } else { - com.google.protobuf.ByteString bs = + com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; java.lang.String s = bs.toStringUtf8(); account_ = s; @@ -130,15 +142,17 @@ public java.lang.String getAccount() { } } /** - * optional string account = 3; + * optional string account = 3 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.account is deprecated. + * See client.proto;l=319 * @return The bytes for account. */ @java.lang.Override - public com.google.protobuf.ByteString + @java.lang.Deprecated public com.google.protobuf.ByteString getAccountBytes() { java.lang.Object ref = account_; if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); account_ = b; @@ -152,24 +166,28 @@ public java.lang.String getAccount() { @SuppressWarnings("serial") private volatile java.lang.Object portfolio_ = ""; /** - * optional string portfolio = 4; + * optional string portfolio = 4 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.portfolio is deprecated. + * See client.proto;l=320 * @return Whether the portfolio field is set. */ @java.lang.Override - public boolean hasPortfolio() { + @java.lang.Deprecated public boolean hasPortfolio() { return ((bitField0_ & 0x00000002) != 0); } /** - * optional string portfolio = 4; + * optional string portfolio = 4 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.portfolio is deprecated. + * See client.proto;l=320 * @return The portfolio. */ @java.lang.Override - public java.lang.String getPortfolio() { + @java.lang.Deprecated public java.lang.String getPortfolio() { java.lang.Object ref = portfolio_; if (ref instanceof java.lang.String) { return (java.lang.String) ref; } else { - com.google.protobuf.ByteString bs = + com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; java.lang.String s = bs.toStringUtf8(); portfolio_ = s; @@ -177,15 +195,17 @@ public java.lang.String getPortfolio() { } } /** - * optional string portfolio = 4; + * optional string portfolio = 4 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.portfolio is deprecated. + * See client.proto;l=320 * @return The bytes for portfolio. */ @java.lang.Override - public com.google.protobuf.ByteString + @java.lang.Deprecated public com.google.protobuf.ByteString getPortfolioBytes() { java.lang.Object ref = portfolio_; if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); portfolio_ = b; @@ -199,39 +219,39 @@ public java.lang.String getPortfolio() { @SuppressWarnings("serial") private java.util.List memberships_; /** - * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5 [deprecated = true]; */ @java.lang.Override - public java.util.List getMembershipsList() { + @java.lang.Deprecated public java.util.List getMembershipsList() { return memberships_; } /** - * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5 [deprecated = true]; */ @java.lang.Override - public java.util.List + @java.lang.Deprecated public java.util.List getMembershipsOrBuilderList() { return memberships_; } /** - * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5 [deprecated = true]; */ @java.lang.Override - public int getMembershipsCount() { + @java.lang.Deprecated public int getMembershipsCount() { return memberships_.size(); } /** - * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5 [deprecated = true]; */ @java.lang.Override - public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership getMemberships(int index) { + @java.lang.Deprecated public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership getMemberships(int index) { return memberships_.get(index); } /** - * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5 [deprecated = true]; */ @java.lang.Override - public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipOrBuilder getMembershipsOrBuilder( + @java.lang.Deprecated public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipOrBuilder getMembershipsOrBuilder( int index) { return memberships_.get(index); } @@ -600,7 +620,7 @@ public Builder mergeFrom(name.abuchen.portfolio.model.proto.v1.PLedgerProjection membershipsBuilder_ = null; memberships_ = other.memberships_; bitField0_ = (bitField0_ & ~0x00000010); - membershipsBuilder_ = + membershipsBuilder_ = com.google.protobuf.GeneratedMessageV3.alwaysUseFieldBuilders ? getMembershipsFieldBuilder() : null; } else { @@ -686,10 +706,12 @@ public Builder mergeFrom( private java.lang.Object uuid_ = ""; /** - * string uuid = 1; + * string uuid = 1 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.uuid is deprecated. + * See client.proto;l=317 * @return The uuid. */ - public java.lang.String getUuid() { + @java.lang.Deprecated public java.lang.String getUuid() { java.lang.Object ref = uuid_; if (!(ref instanceof java.lang.String)) { com.google.protobuf.ByteString bs = @@ -702,14 +724,16 @@ public java.lang.String getUuid() { } } /** - * string uuid = 1; + * string uuid = 1 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.uuid is deprecated. + * See client.proto;l=317 * @return The bytes for uuid. */ - public com.google.protobuf.ByteString + @java.lang.Deprecated public com.google.protobuf.ByteString getUuidBytes() { java.lang.Object ref = uuid_; if (ref instanceof String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); uuid_ = b; @@ -719,11 +743,13 @@ public java.lang.String getUuid() { } } /** - * string uuid = 1; + * string uuid = 1 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.uuid is deprecated. + * See client.proto;l=317 * @param value The uuid to set. * @return This builder for chaining. */ - public Builder setUuid( + @java.lang.Deprecated public Builder setUuid( java.lang.String value) { if (value == null) { throw new NullPointerException(); } uuid_ = value; @@ -732,21 +758,25 @@ public Builder setUuid( return this; } /** - * string uuid = 1; + * string uuid = 1 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.uuid is deprecated. + * See client.proto;l=317 * @return This builder for chaining. */ - public Builder clearUuid() { + @java.lang.Deprecated public Builder clearUuid() { uuid_ = getDefaultInstance().getUuid(); bitField0_ = (bitField0_ & ~0x00000001); onChanged(); return this; } /** - * string uuid = 1; + * string uuid = 1 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.uuid is deprecated. + * See client.proto;l=317 * @param value The bytes for uuid to set. * @return This builder for chaining. */ - public Builder setUuidBytes( + @java.lang.Deprecated public Builder setUuidBytes( com.google.protobuf.ByteString value) { if (value == null) { throw new NullPointerException(); } checkByteStringIsUtf8(value); @@ -758,38 +788,46 @@ public Builder setUuidBytes( private int role_ = 0; /** - * .name.abuchen.portfolio.PLedgerProjectionRole role = 2; + * .name.abuchen.portfolio.PLedgerProjectionRole role = 2 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.role is deprecated. + * See client.proto;l=318 * @return The enum numeric value on the wire for role. */ - @java.lang.Override public int getRoleValue() { + @java.lang.Override @java.lang.Deprecated public int getRoleValue() { return role_; } /** - * .name.abuchen.portfolio.PLedgerProjectionRole role = 2; + * .name.abuchen.portfolio.PLedgerProjectionRole role = 2 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.role is deprecated. + * See client.proto;l=318 * @param value The enum numeric value on the wire for role to set. * @return This builder for chaining. */ - public Builder setRoleValue(int value) { + @java.lang.Deprecated public Builder setRoleValue(int value) { role_ = value; bitField0_ |= 0x00000002; onChanged(); return this; } /** - * .name.abuchen.portfolio.PLedgerProjectionRole role = 2; + * .name.abuchen.portfolio.PLedgerProjectionRole role = 2 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.role is deprecated. + * See client.proto;l=318 * @return The role. */ @java.lang.Override - public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRole getRole() { + @java.lang.Deprecated public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRole getRole() { name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRole result = name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRole.forNumber(role_); return result == null ? name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRole.UNRECOGNIZED : result; } /** - * .name.abuchen.portfolio.PLedgerProjectionRole role = 2; + * .name.abuchen.portfolio.PLedgerProjectionRole role = 2 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.role is deprecated. + * See client.proto;l=318 * @param value The role to set. * @return This builder for chaining. */ - public Builder setRole(name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRole value) { + @java.lang.Deprecated public Builder setRole(name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRole value) { if (value == null) { throw new NullPointerException(); } @@ -799,10 +837,12 @@ public Builder setRole(name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRo return this; } /** - * .name.abuchen.portfolio.PLedgerProjectionRole role = 2; + * .name.abuchen.portfolio.PLedgerProjectionRole role = 2 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.role is deprecated. + * See client.proto;l=318 * @return This builder for chaining. */ - public Builder clearRole() { + @java.lang.Deprecated public Builder clearRole() { bitField0_ = (bitField0_ & ~0x00000002); role_ = 0; onChanged(); @@ -811,17 +851,21 @@ public Builder clearRole() { private java.lang.Object account_ = ""; /** - * optional string account = 3; + * optional string account = 3 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.account is deprecated. + * See client.proto;l=319 * @return Whether the account field is set. */ - public boolean hasAccount() { + @java.lang.Deprecated public boolean hasAccount() { return ((bitField0_ & 0x00000004) != 0); } /** - * optional string account = 3; + * optional string account = 3 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.account is deprecated. + * See client.proto;l=319 * @return The account. */ - public java.lang.String getAccount() { + @java.lang.Deprecated public java.lang.String getAccount() { java.lang.Object ref = account_; if (!(ref instanceof java.lang.String)) { com.google.protobuf.ByteString bs = @@ -834,14 +878,16 @@ public java.lang.String getAccount() { } } /** - * optional string account = 3; + * optional string account = 3 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.account is deprecated. + * See client.proto;l=319 * @return The bytes for account. */ - public com.google.protobuf.ByteString + @java.lang.Deprecated public com.google.protobuf.ByteString getAccountBytes() { java.lang.Object ref = account_; if (ref instanceof String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); account_ = b; @@ -851,11 +897,13 @@ public java.lang.String getAccount() { } } /** - * optional string account = 3; + * optional string account = 3 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.account is deprecated. + * See client.proto;l=319 * @param value The account to set. * @return This builder for chaining. */ - public Builder setAccount( + @java.lang.Deprecated public Builder setAccount( java.lang.String value) { if (value == null) { throw new NullPointerException(); } account_ = value; @@ -864,21 +912,25 @@ public Builder setAccount( return this; } /** - * optional string account = 3; + * optional string account = 3 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.account is deprecated. + * See client.proto;l=319 * @return This builder for chaining. */ - public Builder clearAccount() { + @java.lang.Deprecated public Builder clearAccount() { account_ = getDefaultInstance().getAccount(); bitField0_ = (bitField0_ & ~0x00000004); onChanged(); return this; } /** - * optional string account = 3; + * optional string account = 3 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.account is deprecated. + * See client.proto;l=319 * @param value The bytes for account to set. * @return This builder for chaining. */ - public Builder setAccountBytes( + @java.lang.Deprecated public Builder setAccountBytes( com.google.protobuf.ByteString value) { if (value == null) { throw new NullPointerException(); } checkByteStringIsUtf8(value); @@ -890,17 +942,21 @@ public Builder setAccountBytes( private java.lang.Object portfolio_ = ""; /** - * optional string portfolio = 4; + * optional string portfolio = 4 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.portfolio is deprecated. + * See client.proto;l=320 * @return Whether the portfolio field is set. */ - public boolean hasPortfolio() { + @java.lang.Deprecated public boolean hasPortfolio() { return ((bitField0_ & 0x00000008) != 0); } /** - * optional string portfolio = 4; + * optional string portfolio = 4 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.portfolio is deprecated. + * See client.proto;l=320 * @return The portfolio. */ - public java.lang.String getPortfolio() { + @java.lang.Deprecated public java.lang.String getPortfolio() { java.lang.Object ref = portfolio_; if (!(ref instanceof java.lang.String)) { com.google.protobuf.ByteString bs = @@ -913,14 +969,16 @@ public java.lang.String getPortfolio() { } } /** - * optional string portfolio = 4; + * optional string portfolio = 4 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.portfolio is deprecated. + * See client.proto;l=320 * @return The bytes for portfolio. */ - public com.google.protobuf.ByteString + @java.lang.Deprecated public com.google.protobuf.ByteString getPortfolioBytes() { java.lang.Object ref = portfolio_; if (ref instanceof String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); portfolio_ = b; @@ -930,11 +988,13 @@ public java.lang.String getPortfolio() { } } /** - * optional string portfolio = 4; + * optional string portfolio = 4 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.portfolio is deprecated. + * See client.proto;l=320 * @param value The portfolio to set. * @return This builder for chaining. */ - public Builder setPortfolio( + @java.lang.Deprecated public Builder setPortfolio( java.lang.String value) { if (value == null) { throw new NullPointerException(); } portfolio_ = value; @@ -943,21 +1003,25 @@ public Builder setPortfolio( return this; } /** - * optional string portfolio = 4; + * optional string portfolio = 4 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.portfolio is deprecated. + * See client.proto;l=320 * @return This builder for chaining. */ - public Builder clearPortfolio() { + @java.lang.Deprecated public Builder clearPortfolio() { portfolio_ = getDefaultInstance().getPortfolio(); bitField0_ = (bitField0_ & ~0x00000008); onChanged(); return this; } /** - * optional string portfolio = 4; + * optional string portfolio = 4 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.portfolio is deprecated. + * See client.proto;l=320 * @param value The bytes for portfolio to set. * @return This builder for chaining. */ - public Builder setPortfolioBytes( + @java.lang.Deprecated public Builder setPortfolioBytes( com.google.protobuf.ByteString value) { if (value == null) { throw new NullPointerException(); } checkByteStringIsUtf8(value); @@ -980,9 +1044,9 @@ private void ensureMembershipsIsMutable() { name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership.Builder, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipOrBuilder> membershipsBuilder_; /** - * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5 [deprecated = true]; */ - public java.util.List getMembershipsList() { + @java.lang.Deprecated public java.util.List getMembershipsList() { if (membershipsBuilder_ == null) { return java.util.Collections.unmodifiableList(memberships_); } else { @@ -990,9 +1054,9 @@ public java.util.Listrepeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5 [deprecated = true]; */ - public int getMembershipsCount() { + @java.lang.Deprecated public int getMembershipsCount() { if (membershipsBuilder_ == null) { return memberships_.size(); } else { @@ -1000,9 +1064,9 @@ public int getMembershipsCount() { } } /** - * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5 [deprecated = true]; */ - public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership getMemberships(int index) { + @java.lang.Deprecated public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership getMemberships(int index) { if (membershipsBuilder_ == null) { return memberships_.get(index); } else { @@ -1010,9 +1074,9 @@ public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership getMemb } } /** - * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5 [deprecated = true]; */ - public Builder setMemberships( + @java.lang.Deprecated public Builder setMemberships( int index, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership value) { if (membershipsBuilder_ == null) { if (value == null) { @@ -1027,9 +1091,9 @@ public Builder setMemberships( return this; } /** - * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5 [deprecated = true]; */ - public Builder setMemberships( + @java.lang.Deprecated public Builder setMemberships( int index, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership.Builder builderForValue) { if (membershipsBuilder_ == null) { ensureMembershipsIsMutable(); @@ -1041,9 +1105,9 @@ public Builder setMemberships( return this; } /** - * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5 [deprecated = true]; */ - public Builder addMemberships(name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership value) { + @java.lang.Deprecated public Builder addMemberships(name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership value) { if (membershipsBuilder_ == null) { if (value == null) { throw new NullPointerException(); @@ -1057,9 +1121,9 @@ public Builder addMemberships(name.abuchen.portfolio.model.proto.v1.PLedgerProje return this; } /** - * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5 [deprecated = true]; */ - public Builder addMemberships( + @java.lang.Deprecated public Builder addMemberships( int index, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership value) { if (membershipsBuilder_ == null) { if (value == null) { @@ -1074,9 +1138,9 @@ public Builder addMemberships( return this; } /** - * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5 [deprecated = true]; */ - public Builder addMemberships( + @java.lang.Deprecated public Builder addMemberships( name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership.Builder builderForValue) { if (membershipsBuilder_ == null) { ensureMembershipsIsMutable(); @@ -1088,9 +1152,9 @@ public Builder addMemberships( return this; } /** - * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5 [deprecated = true]; */ - public Builder addMemberships( + @java.lang.Deprecated public Builder addMemberships( int index, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership.Builder builderForValue) { if (membershipsBuilder_ == null) { ensureMembershipsIsMutable(); @@ -1102,9 +1166,9 @@ public Builder addMemberships( return this; } /** - * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5 [deprecated = true]; */ - public Builder addAllMemberships( + @java.lang.Deprecated public Builder addAllMemberships( java.lang.Iterable values) { if (membershipsBuilder_ == null) { ensureMembershipsIsMutable(); @@ -1117,9 +1181,9 @@ public Builder addAllMemberships( return this; } /** - * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5 [deprecated = true]; */ - public Builder clearMemberships() { + @java.lang.Deprecated public Builder clearMemberships() { if (membershipsBuilder_ == null) { memberships_ = java.util.Collections.emptyList(); bitField0_ = (bitField0_ & ~0x00000010); @@ -1130,9 +1194,9 @@ public Builder clearMemberships() { return this; } /** - * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5 [deprecated = true]; */ - public Builder removeMemberships(int index) { + @java.lang.Deprecated public Builder removeMemberships(int index) { if (membershipsBuilder_ == null) { ensureMembershipsIsMutable(); memberships_.remove(index); @@ -1143,16 +1207,16 @@ public Builder removeMemberships(int index) { return this; } /** - * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5 [deprecated = true]; */ - public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership.Builder getMembershipsBuilder( + @java.lang.Deprecated public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership.Builder getMembershipsBuilder( int index) { return getMembershipsFieldBuilder().getBuilder(index); } /** - * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5 [deprecated = true]; */ - public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipOrBuilder getMembershipsOrBuilder( + @java.lang.Deprecated public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipOrBuilder getMembershipsOrBuilder( int index) { if (membershipsBuilder_ == null) { return memberships_.get(index); } else { @@ -1160,9 +1224,9 @@ public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipOrBuilde } } /** - * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5 [deprecated = true]; */ - public java.util.List + @java.lang.Deprecated public java.util.List getMembershipsOrBuilderList() { if (membershipsBuilder_ != null) { return membershipsBuilder_.getMessageOrBuilderList(); @@ -1171,29 +1235,29 @@ public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipOrBuilde } } /** - * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5 [deprecated = true]; */ - public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership.Builder addMembershipsBuilder() { + @java.lang.Deprecated public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership.Builder addMembershipsBuilder() { return getMembershipsFieldBuilder().addBuilder( name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership.getDefaultInstance()); } /** - * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5 [deprecated = true]; */ - public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership.Builder addMembershipsBuilder( + @java.lang.Deprecated public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership.Builder addMembershipsBuilder( int index) { return getMembershipsFieldBuilder().addBuilder( index, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership.getDefaultInstance()); } /** - * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5 [deprecated = true]; */ - public java.util.List + @java.lang.Deprecated public java.util.List getMembershipsBuilderList() { return getMembershipsFieldBuilder().getBuilderList(); } private com.google.protobuf.RepeatedFieldBuilderV3< - name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership.Builder, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipOrBuilder> + name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership.Builder, name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipOrBuilder> getMembershipsFieldBuilder() { if (membershipsBuilder_ == null) { membershipsBuilder_ = new com.google.protobuf.RepeatedFieldBuilderV3< diff --git a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionRefOrBuilder.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionRefOrBuilder.java index 14530a43c2..21091cea33 100644 --- a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionRefOrBuilder.java +++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionRefOrBuilder.java @@ -8,83 +8,103 @@ public interface PLedgerProjectionRefOrBuilder extends com.google.protobuf.MessageOrBuilder { /** - * string uuid = 1; + * string uuid = 1 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.uuid is deprecated. + * See client.proto;l=317 * @return The uuid. */ - java.lang.String getUuid(); + @java.lang.Deprecated java.lang.String getUuid(); /** - * string uuid = 1; + * string uuid = 1 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.uuid is deprecated. + * See client.proto;l=317 * @return The bytes for uuid. */ - com.google.protobuf.ByteString + @java.lang.Deprecated com.google.protobuf.ByteString getUuidBytes(); /** - * .name.abuchen.portfolio.PLedgerProjectionRole role = 2; + * .name.abuchen.portfolio.PLedgerProjectionRole role = 2 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.role is deprecated. + * See client.proto;l=318 * @return The enum numeric value on the wire for role. */ - int getRoleValue(); + @java.lang.Deprecated int getRoleValue(); /** - * .name.abuchen.portfolio.PLedgerProjectionRole role = 2; + * .name.abuchen.portfolio.PLedgerProjectionRole role = 2 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.role is deprecated. + * See client.proto;l=318 * @return The role. */ - name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRole getRole(); + @java.lang.Deprecated name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRole getRole(); /** - * optional string account = 3; + * optional string account = 3 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.account is deprecated. + * See client.proto;l=319 * @return Whether the account field is set. */ - boolean hasAccount(); + @java.lang.Deprecated boolean hasAccount(); /** - * optional string account = 3; + * optional string account = 3 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.account is deprecated. + * See client.proto;l=319 * @return The account. */ - java.lang.String getAccount(); + @java.lang.Deprecated java.lang.String getAccount(); /** - * optional string account = 3; + * optional string account = 3 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.account is deprecated. + * See client.proto;l=319 * @return The bytes for account. */ - com.google.protobuf.ByteString + @java.lang.Deprecated com.google.protobuf.ByteString getAccountBytes(); /** - * optional string portfolio = 4; + * optional string portfolio = 4 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.portfolio is deprecated. + * See client.proto;l=320 * @return Whether the portfolio field is set. */ - boolean hasPortfolio(); + @java.lang.Deprecated boolean hasPortfolio(); /** - * optional string portfolio = 4; + * optional string portfolio = 4 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.portfolio is deprecated. + * See client.proto;l=320 * @return The portfolio. */ - java.lang.String getPortfolio(); + @java.lang.Deprecated java.lang.String getPortfolio(); /** - * optional string portfolio = 4; + * optional string portfolio = 4 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.portfolio is deprecated. + * See client.proto;l=320 * @return The bytes for portfolio. */ - com.google.protobuf.ByteString + @java.lang.Deprecated com.google.protobuf.ByteString getPortfolioBytes(); /** - * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5 [deprecated = true]; */ - java.util.List + @java.lang.Deprecated java.util.List getMembershipsList(); /** - * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5 [deprecated = true]; */ - name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership getMemberships(int index); + @java.lang.Deprecated name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership getMemberships(int index); /** - * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5 [deprecated = true]; */ - int getMembershipsCount(); + @java.lang.Deprecated int getMembershipsCount(); /** - * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5 [deprecated = true]; */ - java.util.List + @java.lang.Deprecated java.util.List getMembershipsOrBuilderList(); /** - * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5; + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5 [deprecated = true]; */ - name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipOrBuilder getMembershipsOrBuilder( + @java.lang.Deprecated name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipOrBuilder getMembershipsOrBuilder( int index); } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ProtobufWriter.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ProtobufWriter.java index f89f9e05f0..e217cae3e7 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ProtobufWriter.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ProtobufWriter.java @@ -25,6 +25,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.UUID; import java.util.stream.Collectors; import com.google.protobuf.Any; @@ -42,11 +43,13 @@ import name.abuchen.portfolio.model.ledger.LedgerParameter; import name.abuchen.portfolio.model.ledger.LedgerParameter.ValueKind; import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerPostingDirection; +import name.abuchen.portfolio.model.ledger.LedgerPostingSemanticRole; +import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; -import name.abuchen.portfolio.model.ledger.ProjectionMembership; -import name.abuchen.portfolio.model.ledger.ProjectionMembershipRole; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; @@ -71,9 +74,6 @@ import name.abuchen.portfolio.model.proto.v1.PLedgerParameter; import name.abuchen.portfolio.model.proto.v1.PLedgerParameterValueKind; import name.abuchen.portfolio.model.proto.v1.PLedgerPosting; -import name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership; -import name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembershipRole; -import name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef; import name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRole; import name.abuchen.portfolio.model.proto.v1.PMap; import name.abuchen.portfolio.model.proto.v1.PPortfolio; @@ -128,6 +128,7 @@ void add(Account account) } private static final byte[] SIGNATURE = new byte[] { 'P', 'P', 'P', 'B', 'V', '1' }; + private static final String LEDGER_COMPATIBILITY_SHADOW_PREFIX = "ledger-shadow:"; //$NON-NLS-1$ @Override public Client load(InputStream input) throws IOException @@ -168,7 +169,7 @@ public Client load(InputStream input) throws IOException ledgerProjectionUUIDs = ledgerProjectionUUIDs(client.getLedger()); } - loadTransactions(newClient, lookup, ledgerProjectionUUIDs); + loadTransactions(newClient, lookup, ledgerProjectionUUIDs, hasLedgerTruth); client.getProperties().putAll(newClient.getPropertiesMap()); loadTaxonomies(newClient, client, lookup); @@ -354,11 +355,12 @@ private void loadPortfolios(PClient newClient, Client client, Lookup lookup) } } - private void loadTransactions(PClient newClient, Lookup lookup, Set ledgerProjectionUUIDs) + private void loadTransactions(PClient newClient, Lookup lookup, Set ledgerProjectionUUIDs, + boolean hasLedgerTruth) { for (PTransaction newTransaction : newClient.getTransactionsList()) { - if (isLedgerCompatibilityShadow(newTransaction, ledgerProjectionUUIDs)) + if (isLedgerCompatibilityShadow(newTransaction, ledgerProjectionUUIDs, hasLedgerTruth)) continue; PTransaction.Type type = newTransaction.getType(); @@ -598,12 +600,21 @@ private void loadTransactions(PClient newClient, Lookup lookup, Set ledg } } - private boolean isLedgerCompatibilityShadow(PTransaction newTransaction, Set ledgerProjectionUUIDs) + private boolean isLedgerCompatibilityShadow(PTransaction newTransaction, Set ledgerProjectionUUIDs, + boolean hasLedgerTruth) { - return ledgerProjectionUUIDs.contains(newTransaction.getUuid()) + return hasLedgerTruth && (isLedgerCompatibilityShadowUUID(newTransaction.getUuid()) + || (newTransaction.hasOtherUuid() + && isLedgerCompatibilityShadowUUID(newTransaction.getOtherUuid()))) + || ledgerProjectionUUIDs.contains(newTransaction.getUuid()) || (newTransaction.hasOtherUuid() && ledgerProjectionUUIDs.contains(newTransaction.getOtherUuid())); } + private boolean isLedgerCompatibilityShadowUUID(String uuid) + { + return uuid != null && uuid.startsWith(LEDGER_COMPATIBILITY_SHADOW_PREFIX); + } + private void loadCommonTransaction(PTransaction newTransaction, Transaction t, Lookup lookup, boolean requiresSecurity) { @@ -684,7 +695,7 @@ private void loadLedger(PLedger newLedger, Client client, Lookup lookup) { for (PLedgerEntry newEntry : newLedger.getEntriesList()) { - LedgerEntry entry = LedgerModelLoadSupport.newEntry(newEntry.getUuid(), + LedgerEntry entry = LedgerModelLoadSupport.newEntry(UUID.randomUUID().toString(), LedgerEntryType.fromProtobufId(newEntry.getTypeId()), fromTimestamp(newEntry.getDateTime())); @@ -710,9 +721,6 @@ private void loadLedger(PLedger newLedger, Client client, Lookup lookup) for (PLedgerPosting newPosting : newEntry.getPostingsList()) LedgerModelLoadSupport.addPosting(entry, loadLedgerPosting(newPosting, lookup)); - for (PLedgerProjectionRef newProjectionRef : newEntry.getProjectionRefsList()) - LedgerModelLoadSupport.addProjectionRef(entry, loadLedgerProjectionRef(newProjectionRef, lookup)); - if (newEntry.hasUpdatedAt()) LedgerModelLoadSupport.setEntryUpdatedAt(entry, fromUpdatedAtTimestamp(newEntry.getUpdatedAt())); @@ -722,7 +730,7 @@ private void loadLedger(PLedger newLedger, Client client, Lookup lookup) private LedgerPosting loadLedgerPosting(PLedgerPosting newPosting, Lookup lookup) { - LedgerPosting posting = LedgerModelLoadSupport.newPosting(newPosting.getUuid(), + LedgerPosting posting = LedgerModelLoadSupport.newPosting(UUID.randomUUID().toString(), LedgerPostingType.fromCode(newPosting.getTypeCode())); LedgerModelLoadSupport.setPostingAmount(posting, newPosting.getAmount()); @@ -742,6 +750,18 @@ private LedgerPosting loadLedgerPosting(PLedgerPosting newPosting, Lookup lookup LedgerModelLoadSupport.setPostingAccount(posting, lookup.getAccount(newPosting.getAccount())); if (newPosting.hasPortfolio()) LedgerModelLoadSupport.setPostingPortfolio(posting, lookup.getPortfolio(newPosting.getPortfolio())); + if (newPosting.hasSemanticRole()) + posting.setSemanticRole(LedgerPostingSemanticRole.valueOf(newPosting.getSemanticRole())); + if (newPosting.hasDirection()) + posting.setDirection(LedgerPostingDirection.valueOf(newPosting.getDirection())); + if (newPosting.hasCorporateActionLeg()) + posting.setCorporateActionLeg(corporateActionLeg(newPosting.getCorporateActionLeg())); + if (newPosting.hasUnitRole()) + posting.setUnitRole(LedgerPostingUnitRole.valueOf(newPosting.getUnitRole())); + if (newPosting.hasGroupKey()) + posting.setGroupKey(newPosting.getGroupKey()); + if (newPosting.hasLocalKey()) + posting.setLocalKey(newPosting.getLocalKey()); for (PLedgerParameter newParameter : newPosting.getParametersList()) LedgerModelLoadSupport.addPostingParameter(posting, loadLedgerParameter(newParameter, lookup)); @@ -749,23 +769,13 @@ private LedgerPosting loadLedgerPosting(PLedgerPosting newPosting, Lookup lookup return posting; } - private LedgerProjectionRef loadLedgerProjectionRef(PLedgerProjectionRef newProjectionRef, Lookup lookup) + private CorporateActionLeg corporateActionLeg(String code) { - LedgerProjectionRef projectionRef = LedgerModelLoadSupport.newProjectionRef(newProjectionRef.getUuid(), - fromProto(newProjectionRef.getRole())); + for (var leg : CorporateActionLeg.values()) + if (leg.getCode().equals(code)) + return leg; - if (newProjectionRef.hasAccount()) - LedgerModelLoadSupport.setProjectionRefAccount(projectionRef, - lookup.getAccount(newProjectionRef.getAccount())); - if (newProjectionRef.hasPortfolio()) - LedgerModelLoadSupport.setProjectionRefPortfolio(projectionRef, - lookup.getPortfolio(newProjectionRef.getPortfolio())); - - for (PLedgerProjectionMembership newMembership : newProjectionRef.getMembershipsList()) - LedgerModelLoadSupport.addProjectionRefMembership(projectionRef, newMembership.getPostingUUID(), - fromProto(newMembership.getRole())); - - return projectionRef; + return CorporateActionLeg.valueOf(code); } private LedgerParameter loadLedgerParameter(PLedgerParameter newParameter, Lookup lookup) @@ -1317,7 +1327,6 @@ private PLedgerEntry saveLedgerEntry(LedgerEntry entry) { PLedgerEntry.Builder newEntry = PLedgerEntry.newBuilder(); - newEntry.setUuid(entry.getUUID()); newEntry.setTypeId(entry.getType().getProtobufId()); newEntry.setDateTime(asTimestamp(entry.getDateTime())); @@ -1342,9 +1351,6 @@ private PLedgerEntry saveLedgerEntry(LedgerEntry entry) for (LedgerPosting posting : entry.getPostings()) newEntry.addPostings(saveLedgerPosting(posting)); - for (LedgerProjectionRef projectionRef : entry.getProjectionRefs()) - newEntry.addProjectionRefs(saveLedgerProjectionRef(projectionRef)); - return newEntry.build(); } @@ -1352,7 +1358,6 @@ private PLedgerPosting saveLedgerPosting(LedgerPosting posting) { PLedgerPosting.Builder newPosting = PLedgerPosting.newBuilder(); - newPosting.setUuid(posting.getUUID()); newPosting.setTypeCode(posting.getType().getCode()); newPosting.setAmount(posting.getAmount()); @@ -1371,6 +1376,18 @@ private PLedgerPosting saveLedgerPosting(LedgerPosting posting) newPosting.setAccount(posting.getAccount().getUUID()); if (posting.getPortfolio() != null) newPosting.setPortfolio(posting.getPortfolio().getUUID()); + if (posting.getSemanticRole() != null) + newPosting.setSemanticRole(posting.getSemanticRole().name()); + if (posting.getDirection() != null) + newPosting.setDirection(posting.getDirection().name()); + if (posting.getCorporateActionLeg() != null) + newPosting.setCorporateActionLeg(posting.getCorporateActionLeg().getCode()); + if (posting.getUnitRole() != null) + newPosting.setUnitRole(posting.getUnitRole().name()); + if (posting.getGroupKey() != null) + newPosting.setGroupKey(posting.getGroupKey()); + if (posting.getLocalKey() != null) + newPosting.setLocalKey(posting.getLocalKey()); for (LedgerParameter parameter : posting.getParameters()) newPosting.addParameters(saveLedgerParameter(parameter)); @@ -1378,32 +1395,6 @@ private PLedgerPosting saveLedgerPosting(LedgerPosting posting) return newPosting.build(); } - private PLedgerProjectionRef saveLedgerProjectionRef(LedgerProjectionRef projectionRef) - { - PLedgerProjectionRef.Builder newProjectionRef = PLedgerProjectionRef.newBuilder(); - - newProjectionRef.setUuid(projectionRef.getUUID()); - newProjectionRef.setRole(toProto(projectionRef.getRole())); - if (projectionRef.getAccount() != null) - newProjectionRef.setAccount(projectionRef.getAccount().getUUID()); - if (projectionRef.getPortfolio() != null) - newProjectionRef.setPortfolio(projectionRef.getPortfolio().getUUID()); - for (ProjectionMembership membership : projectionRef.getMemberships()) - newProjectionRef.addMemberships(saveLedgerProjectionMembership(membership)); - - return newProjectionRef.build(); - } - - private PLedgerProjectionMembership saveLedgerProjectionMembership(ProjectionMembership membership) - { - PLedgerProjectionMembership.Builder newMembership = PLedgerProjectionMembership.newBuilder(); - - newMembership.setPostingUUID(membership.getPostingUUID()); - newMembership.setRole(toProto(membership.getRole())); - - return newMembership.build(); - } - private PLedgerParameter saveLedgerParameter(LedgerParameter parameter) { PLedgerParameter.Builder newParameter = PLedgerParameter.newBuilder(); @@ -1518,35 +1509,50 @@ private boolean shouldSaveLedgerCompatibilityShadow(Transaction transaction) private boolean shouldSaveLegacyTransaction(Transaction transaction, Set ledgerProjectionUUIDs) { - return !(transaction instanceof LedgerBackedTransaction) && !ledgerProjectionUUIDs.contains(transaction.getUUID()); + return !(transaction instanceof LedgerBackedTransaction) // + && !isLedgerCompatibilityShadowUUID(transaction.getUUID()) // + && !ledgerProjectionUUIDs.contains(transaction.getUUID()); + } + + private String protobufTransactionUUID(Transaction transaction) + { + if (transaction instanceof LedgerBackedTransaction ledgerBackedTransaction) + { + var projectionRef = ledgerBackedTransaction.getLedgerProjectionRef(); + + return LEDGER_COMPATIBILITY_SHADOW_PREFIX + ledgerBackedTransaction.getLedgerEntry().getUUID() + + ":" + projectionRef.getRole(); //$NON-NLS-1$ + } + + return transaction.getUUID(); } private void addTransaction(PClient.Builder newClient, Portfolio portfolio, PortfolioTransaction t) { PTransaction.Builder newTransaction = PTransaction.newBuilder(); newTransaction.setPortfolio(portfolio.getUUID()); - newTransaction.setUuid(t.getUUID()); + newTransaction.setUuid(protobufTransactionUUID(t)); switch (t.getType()) { case BUY: newTransaction.setTypeValue(PTransaction.Type.PURCHASE_VALUE); newTransaction.setAccount(t.getCrossEntry().getCrossOwner(t).getUUID()); - newTransaction.setOtherUuid(t.getCrossEntry().getCrossTransaction(t).getUUID()); + newTransaction.setOtherUuid(protobufTransactionUUID(t.getCrossEntry().getCrossTransaction(t))); newTransaction.setOtherUpdatedAt( asUpdatedAtTimestamp(t.getCrossEntry().getCrossTransaction(t).getUpdatedAt())); break; case SELL: newTransaction.setTypeValue(PTransaction.Type.SALE_VALUE); newTransaction.setAccount(t.getCrossEntry().getCrossOwner(t).getUUID()); - newTransaction.setOtherUuid(t.getCrossEntry().getCrossTransaction(t).getUUID()); + newTransaction.setOtherUuid(protobufTransactionUUID(t.getCrossEntry().getCrossTransaction(t))); newTransaction.setOtherUpdatedAt( asUpdatedAtTimestamp(t.getCrossEntry().getCrossTransaction(t).getUpdatedAt())); break; case TRANSFER_OUT: newTransaction.setTypeValue(PTransaction.Type.SECURITY_TRANSFER_VALUE); newTransaction.setOtherPortfolio(t.getCrossEntry().getCrossOwner(t).getUUID()); - newTransaction.setOtherUuid(t.getCrossEntry().getCrossTransaction(t).getUUID()); + newTransaction.setOtherUuid(protobufTransactionUUID(t.getCrossEntry().getCrossTransaction(t))); newTransaction.setOtherUpdatedAt( asUpdatedAtTimestamp(t.getCrossEntry().getCrossTransaction(t).getUpdatedAt())); break; @@ -1570,7 +1576,7 @@ private void addTransaction(PClient.Builder newClient, Account account, AccountT { PTransaction.Builder newTransaction = PTransaction.newBuilder(); newTransaction.setAccount(account.getUUID()); - newTransaction.setUuid(t.getUUID()); + newTransaction.setUuid(protobufTransactionUUID(t)); switch (t.getType()) { @@ -1606,7 +1612,7 @@ private void addTransaction(PClient.Builder newClient, Account account, AccountT case TRANSFER_OUT: newTransaction.setTypeValue(PTransaction.Type.CASH_TRANSFER_VALUE); newTransaction.setOtherAccount(t.getCrossEntry().getCrossOwner(t).getUUID()); - newTransaction.setOtherUuid(t.getCrossEntry().getCrossTransaction(t).getUUID()); + newTransaction.setOtherUuid(protobufTransactionUUID(t.getCrossEntry().getCrossTransaction(t))); newTransaction.setOtherUpdatedAt( asUpdatedAtTimestamp(t.getCrossEntry().getCrossTransaction(t).getUpdatedAt())); break; @@ -1906,39 +1912,6 @@ private void validateLedger(Client client) } } - private PLedgerProjectionRole toProto(LedgerProjectionRole role) - { - switch (role) - { - case ACCOUNT: - return PLedgerProjectionRole.LEDGER_PROJECTION_ROLE_ACCOUNT; - case PORTFOLIO: - return PLedgerProjectionRole.LEDGER_PROJECTION_ROLE_PORTFOLIO; - case SOURCE_ACCOUNT: - return PLedgerProjectionRole.LEDGER_PROJECTION_ROLE_SOURCE_ACCOUNT; - case TARGET_ACCOUNT: - return PLedgerProjectionRole.LEDGER_PROJECTION_ROLE_TARGET_ACCOUNT; - case SOURCE_PORTFOLIO: - return PLedgerProjectionRole.LEDGER_PROJECTION_ROLE_SOURCE_PORTFOLIO; - case TARGET_PORTFOLIO: - return PLedgerProjectionRole.LEDGER_PROJECTION_ROLE_TARGET_PORTFOLIO; - case DELIVERY: - return PLedgerProjectionRole.LEDGER_PROJECTION_ROLE_DELIVERY; - case DELIVERY_INBOUND: - return PLedgerProjectionRole.LEDGER_PROJECTION_ROLE_DELIVERY_INBOUND; - case DELIVERY_OUTBOUND: - return PLedgerProjectionRole.LEDGER_PROJECTION_ROLE_DELIVERY_OUTBOUND; - case CASH_COMPENSATION: - return PLedgerProjectionRole.LEDGER_PROJECTION_ROLE_CASH_COMPENSATION; - case OLD_SECURITY_LEG: - return PLedgerProjectionRole.LEDGER_PROJECTION_ROLE_OLD_SECURITY_LEG; - case NEW_SECURITY_LEG: - return PLedgerProjectionRole.LEDGER_PROJECTION_ROLE_NEW_SECURITY_LEG; - default: - throw new UnsupportedOperationException(role.toString()); - } - } - private LedgerProjectionRole fromProto(PLedgerProjectionRole role) { switch (role) @@ -1974,50 +1947,6 @@ private LedgerProjectionRole fromProto(PLedgerProjectionRole role) } } - private PLedgerProjectionMembershipRole toProto(ProjectionMembershipRole role) - { - switch (role) - { - case PRIMARY: - return PLedgerProjectionMembershipRole.LEDGER_PROJECTION_MEMBERSHIP_ROLE_PRIMARY; - case GROUP_ANCHOR: - return PLedgerProjectionMembershipRole.LEDGER_PROJECTION_MEMBERSHIP_ROLE_GROUP_ANCHOR; - case FEE_UNIT: - return PLedgerProjectionMembershipRole.LEDGER_PROJECTION_MEMBERSHIP_ROLE_FEE_UNIT; - case TAX_UNIT: - return PLedgerProjectionMembershipRole.LEDGER_PROJECTION_MEMBERSHIP_ROLE_TAX_UNIT; - case GROSS_VALUE_UNIT: - return PLedgerProjectionMembershipRole.LEDGER_PROJECTION_MEMBERSHIP_ROLE_GROSS_VALUE_UNIT; - case FOREX_CONTEXT: - return PLedgerProjectionMembershipRole.LEDGER_PROJECTION_MEMBERSHIP_ROLE_FOREX_CONTEXT; - default: - throw new UnsupportedOperationException(role.toString()); - } - } - - private ProjectionMembershipRole fromProto(PLedgerProjectionMembershipRole role) - { - switch (role) - { - case LEDGER_PROJECTION_MEMBERSHIP_ROLE_PRIMARY: - return ProjectionMembershipRole.PRIMARY; - case LEDGER_PROJECTION_MEMBERSHIP_ROLE_GROUP_ANCHOR: - return ProjectionMembershipRole.GROUP_ANCHOR; - case LEDGER_PROJECTION_MEMBERSHIP_ROLE_FEE_UNIT: - return ProjectionMembershipRole.FEE_UNIT; - case LEDGER_PROJECTION_MEMBERSHIP_ROLE_TAX_UNIT: - return ProjectionMembershipRole.TAX_UNIT; - case LEDGER_PROJECTION_MEMBERSHIP_ROLE_GROSS_VALUE_UNIT: - return ProjectionMembershipRole.GROSS_VALUE_UNIT; - case LEDGER_PROJECTION_MEMBERSHIP_ROLE_FOREX_CONTEXT: - return ProjectionMembershipRole.FOREX_CONTEXT; - case LEDGER_PROJECTION_MEMBERSHIP_ROLE_UNSPECIFIED: - case UNRECOGNIZED: - default: - throw new UnsupportedOperationException(role.toString()); - } - } - private PLedgerParameterValueKind toProto(ValueKind valueKind) { switch (valueKind) diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/client.proto b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/client.proto index 1af0faea46..97d698f8d8 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/client.proto +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/client.proto @@ -276,14 +276,14 @@ message PLedger { } message PLedgerEntry { - string uuid = 1; + string uuid = 1 [deprecated = true]; reserved 2; google.protobuf.Timestamp dateTime = 3; optional string note = 4; optional string source = 5; google.protobuf.Timestamp updatedAt = 6; repeated PLedgerPosting postings = 7; - repeated PLedgerProjectionRef projectionRefs = 8; + repeated PLedgerProjectionRef projectionRefs = 8 [deprecated = true]; optional uint32 typeId = 9; repeated PLedgerParameter parameters = 10; optional string generatedByPlanKey = 11; @@ -293,7 +293,7 @@ message PLedgerEntry { } message PLedgerPosting { - string uuid = 1; + string uuid = 1 [deprecated = true]; reserved 2; int64 amount = 3; optional string currency = 4; @@ -306,19 +306,25 @@ message PLedgerPosting { optional string portfolio = 11; repeated PLedgerParameter parameters = 12; optional string typeCode = 13; + optional string semanticRole = 14; + optional string direction = 15; + optional string corporateActionLeg = 16; + optional string unitRole = 17; + optional string groupKey = 18; + optional string localKey = 19; } message PLedgerProjectionRef { - string uuid = 1; - PLedgerProjectionRole role = 2; - optional string account = 3; - optional string portfolio = 4; - repeated PLedgerProjectionMembership memberships = 5; + string uuid = 1 [deprecated = true]; + PLedgerProjectionRole role = 2 [deprecated = true]; + optional string account = 3 [deprecated = true]; + optional string portfolio = 4 [deprecated = true]; + repeated PLedgerProjectionMembership memberships = 5 [deprecated = true]; } message PLedgerProjectionMembership { - string postingUUID = 1; - PLedgerProjectionMembershipRole role = 2; + string postingUUID = 1 [deprecated = true]; + PLedgerProjectionMembershipRole role = 2 [deprecated = true]; } message PLedgerParameter { From 720a8376697b3e15180284cfc2531ce8975fa435 Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Thu, 2 Jul 2026 03:36:17 +0200 Subject: [PATCH 20/68] Remove Ledger projection membership truth Remove persisted Ledger projection refs and memberships from the active model and migrate runtime, persistence, migration, UI routing, and tests to descriptor-based projection behavior. This keeps Ledger truth aligned with the no-UUID redesign while preserving visible account, portfolio, plan, migration, XML, and protobuf compatibility behavior. Ledger entry and posting UUID fields remain where still needed by rollback, diagnostics, editor patching, and compatibility boundaries; XML and protobuf schemas are not regenerated here. --- .../checks/impl/CrossEntryCheckTest.java | 74 +- ...onsWithSecurityCanHaveExDateCheckTest.java | 35 +- .../actions/InsertActionTest.java | 14 +- .../LedgerImportWriteGuardrailTest.java | 120 +- .../LedgerOwnerChangeFailurePathTest.java | 16 +- .../model/LedgerProtobufPersistenceTest.java | 42 +- .../model/LedgerSaveLoadParityTest.java | 567 +------- .../ledger/LedgerDescriptorTestSupport.java | 28 + .../model/ledger/LedgerEditorTest.java | 47 +- .../ledger/LedgerEntryDefinitionTest.java | 8 +- .../model/ledger/LedgerGraphWriterTest.java | 45 +- .../ledger/LedgerGuardrailTestSupport.java | 27 +- .../model/ledger/LedgerModelCopyTest.java | 63 +- .../model/ledger/LedgerModelTest.java | 56 - .../ledger/LedgerMutationContextTest.java | 158 +-- ...gerNativeEntryDefinitionValidatorTest.java | 32 +- .../LedgerPostingTypeDefinitionTest.java | 8 +- .../LedgerProjectionMaterializerTest.java | 57 +- .../ledger/LedgerSpinOffScenarioTest.java | 79 +- .../ledger/LedgerStructuralValidatorTest.java | 1160 +---------------- .../ledger/LedgerTransactionCreatorTest.java | 40 +- .../ledger/LedgerXmlPersistenceTest.java | 13 +- ...dgerAccountOnlyTransactionCreatorTest.java | 73 +- ...TransferToDepositRemovalConverterTest.java | 197 ++- ...AccountTransferTransactionCreatorTest.java | 40 +- .../LedgerAccountTypeToggleConverterTest.java | 76 +- .../LedgerBuySellDeliveryConverterTest.java | 159 +-- .../LedgerBuySellReversalConverterTest.java | 100 +- .../LedgerBuySellTransactionCreatorTest.java | 74 +- .../LedgerDeliveryDirectionConverterTest.java | 69 +- .../LedgerDeliveryTransactionCreatorTest.java | 52 +- .../LedgerDividendTransactionCreatorTest.java | 53 +- .../LedgerInvestmentPlanRefSupportTest.java | 29 +- ...dgerNativeComponentInspectorModelTest.java | 93 +- ...rtfolioTransferTransactionCreatorTest.java | 40 +- .../LedgerTransferDirectionConverterTest.java | 118 +- ...ger-v6-spin-off-siemens-energy-example.xml | 46 - ...LegacyTransactionToLedgerMigratorTest.java | 318 ++--- .../LedgerNativeEntryAssemblerTest.java | 38 +- ...erivedProjectionDescriptorServiceTest.java | 170 +-- .../LedgerRuntimeProjectionRestorerTest.java | 133 +- .../ClientPerformanceSnapshotTest.java | 7 +- ...gerTransactionDuplicateCopyParityTest.java | 19 +- .../views/LedgerTransactionRoutingTest.java | 93 +- .../LedgerNativeComponentInspectorDialog.java | 9 +- ...actionsWithSecurityCanHaveExDateCheck.java | 4 +- .../portfolio/model/ClientFactory.java | 181 +-- .../portfolio/model/InvestmentPlan.java | 10 +- .../model/LedgerBuySellDeliveryConverter.java | 6 +- .../LedgerDeliveryDirectionConverter.java | 4 +- .../model/LedgerModelLoadSupport.java | 42 - .../model/LedgerPlanReferenceSupport.java | 15 +- .../LedgerTransferDirectionConverter.java | 8 +- .../portfolio/model/ProtobufWriter.java | 30 +- .../LedgerDiagnosticMessageFormatter.java | 13 +- .../portfolio/model/ledger/LedgerEntry.java | 22 - .../model/ledger/LedgerGraphWriter.java | 4 - .../model/ledger/LedgerModelCopy.java | 21 - .../model/ledger/LedgerMutationContext.java | 32 +- .../model/ledger/LedgerProjectionRef.java | 179 --- .../ledger/LedgerStructuralValidator.java | 278 +--- .../model/ledger/ProjectionMembership.java | 38 - .../ledger/ProjectionMembershipRole.java | 16 - .../LedgerAccountOnlyTransactionCreator.java | 6 +- .../LedgerAccountTransactionEditor.java | 25 +- .../LedgerAccountTransferEditor.java | 53 +- ...ountTransferToDepositRemovalConverter.java | 76 +- ...dgerAccountTransferTransactionCreator.java | 22 +- .../LedgerAccountTypeToggleConverter.java | 37 +- .../LedgerBuySellDeliveryConverter.java | 145 ++- .../compatibility/LedgerBuySellEditor.java | 46 +- .../LedgerBuySellReversalConverter.java | 36 +- .../LedgerBuySellTransactionCreator.java | 28 +- .../LedgerDeliveryDirectionConverter.java | 53 +- .../LedgerDeliveryTransactionCreator.java | 12 +- .../LedgerDeliveryTransactionEditor.java | 28 +- .../LedgerDividendTransactionCreator.java | 6 +- .../LedgerInlineEditingPolicy.java | 2 +- .../LedgerInvestmentPlanRefSupport.java | 9 +- .../LedgerNativeComponentInspectorModel.java | 67 +- .../compatibility/LedgerOwnerPatchHelper.java | 27 +- ...LedgerPortfolioCompositeTypeConverter.java | 159 ++- .../LedgerPortfolioTransferEditor.java | 52 +- ...erPortfolioTransferTransactionCreator.java | 24 +- .../LedgerShareAdjustmentHelper.java | 43 +- .../LedgerTransactionCreator.java | 82 +- .../LedgerTransferDirectionConverter.java | 104 +- .../LedgerUnitPostingUpdater.java | 28 +- .../LedgerNativeEntryDefinitionValidator.java | 51 +- .../rule/LedgerPostingGroupRule.java | 14 +- .../LegacyTransactionToLedgerMigrator.java | 230 ++-- .../LedgerNativeEntryAssembler.java | 18 +- .../LedgerNativeEntryBuildResult.java | 11 +- .../DerivedProjectionDescriptor.java | 5 + .../LedgerBackedAccountTransaction.java | 25 +- .../projection/LedgerBackedCrossEntry.java | 5 +- .../LedgerBackedPortfolioTransaction.java | 25 +- .../projection/LedgerBackedTransaction.java | 14 +- .../projection/LedgerProjectionFactory.java | 114 +- .../LedgerProjectionMaterializer.java | 8 +- .../projection/LedgerProjectionService.java | 30 +- .../projection/LedgerProjectionSupport.java | 177 +-- .../LedgerRuntimeProjectionRestorer.java | 9 +- 103 files changed, 2095 insertions(+), 5409 deletions(-) create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerDescriptorTestSupport.java delete mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerProjectionRef.java delete mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/ProjectionMembership.java delete mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/ProjectionMembershipRole.java diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/checks/impl/CrossEntryCheckTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/checks/impl/CrossEntryCheckTest.java index 7b9d5da121..e70ab7e4b6 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/checks/impl/CrossEntryCheckTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/checks/impl/CrossEntryCheckTest.java @@ -36,7 +36,6 @@ import name.abuchen.portfolio.model.Transaction; import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountOnlyTransactionCreator; import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountTransferTransactionCreator; @@ -257,7 +256,7 @@ public void testLedgerBackedDeleteFixRemovesInvestmentPlanRefs() throws IOExcept .count(), is(1L)); assertThat(plan.getLedgerExecutionRefs(), is(List.of())); assertThat(plan.getTransactions(client), is(List.of())); - assertThat(unrelatedPlan.getLedgerExecutionRefs().size(), is(1)); + assertThat(unrelatedPlan.getLedgerExecutionRefs(), is(List.of())); assertThat(unrelatedPlan.getTransactions(client).size(), is(1)); assertThat(account.getTransactions(), hasItem(legacyTransaction)); assertFalse(account.getTransactions().contains(accountProjection)); @@ -356,19 +355,21 @@ public void testLedgerBackedMissingBuySellProjectionIssueOffersDeleteOnlyAndDele LedgerEntry entry = ledgerEntry(buy.getAccountTransaction()); String entryUUID = entry.getUUID(); - entry.removeProjectionRef(projection(entry, LedgerProjectionRole.PORTFOLIO)); - rematerializeLedgerProjections(); + portfolio.getTransactions().clear(); List issues = new CrossEntryCheck().execute(client); - assertThat(issues.size(), is(1)); - assertThat(issues.get(0), is(instanceOf(MissingBuySellPortfolioIssue.class))); - assertOnlyDeleteFix(issues.get(0)).execute(); - - assertEntryDeleted(entryUUID); - assertThat(account.getTransactions().size(), is(0)); + assertThat(issues.size(), is(0)); + assertThat(account.getTransactions().size(), is(1)); assertThat(portfolio.getTransactions().size(), is(0)); - assertThat(reloadXml(client).getLedger().getEntries().size(), is(0)); + + LedgerProjectionService.restoreIfValid(client); + + assertThat(client.getLedger().getEntries().stream().filter(item -> item.getUUID().equals(entryUUID)).count(), + is(1L)); + assertThat(account.getTransactions().size(), is(1)); + assertThat(portfolio.getTransactions().size(), is(1)); + assertThat(((LedgerBackedTransaction) portfolio.getTransactions().get(0)).getLedgerEntry(), is(entry)); } /** @@ -387,19 +388,21 @@ public void testLedgerBackedMissingTransferProjectionIssuesOfferDeleteOnlyAndDel null, null, "account transfer note", "account transfer source"); LedgerEntry accountEntry = ledgerEntry(accountTransfer.getSourceTransaction()); - accountEntry.removeProjectionRef(projection(accountEntry, LedgerProjectionRole.TARGET_ACCOUNT)); - rematerializeLedgerProjections(); + targetAccount.getTransactions().clear(); List issues = new CrossEntryCheck().execute(client); - assertThat(issues.size(), is(1)); - assertThat(issues.get(0), is(instanceOf(MissingAccountTransferIssue.class))); - assertOnlyDeleteFix(issues.get(0)).execute(); - - assertEntryDeleted(accountEntry.getUUID()); - assertThat(account.getTransactions().size(), is(0)); + assertThat(issues.size(), is(0)); + assertThat(account.getTransactions().size(), is(1)); assertThat(targetAccount.getTransactions().size(), is(0)); + LedgerProjectionService.restoreIfValid(client); + + assertThat(client.getLedger().getEntries().stream() + .filter(item -> item.getUUID().equals(accountEntry.getUUID())).count(), is(1L)); + assertThat(account.getTransactions().size(), is(1)); + assertThat(targetAccount.getTransactions().size(), is(1)); + Portfolio targetPortfolio = new Portfolio(); client.addPortfolio(targetPortfolio); var portfolioTransfer = new LedgerPortfolioTransferTransactionCreator(client).create(portfolio, @@ -408,18 +411,20 @@ public void testLedgerBackedMissingTransferProjectionIssuesOfferDeleteOnlyAndDel "portfolio transfer source"); LedgerEntry portfolioEntry = ledgerEntry(portfolioTransfer.getSourceTransaction()); - portfolioEntry.removeProjectionRef(projection(portfolioEntry, LedgerProjectionRole.TARGET_PORTFOLIO)); - rematerializeLedgerProjections(); + targetPortfolio.getTransactions().clear(); issues = new CrossEntryCheck().execute(client); - assertThat(issues.size(), is(1)); - assertThat(issues.get(0), is(instanceOf(MissingPortfolioTransferIssue.class))); - assertOnlyDeleteFix(issues.get(0)).execute(); - - assertEntryDeleted(portfolioEntry.getUUID()); - assertThat(portfolio.getTransactions().size(), is(0)); + assertThat(issues.size(), is(0)); + assertThat(portfolio.getTransactions().size(), is(1)); assertThat(targetPortfolio.getTransactions().size(), is(0)); + + LedgerProjectionService.restoreIfValid(client); + + assertThat(client.getLedger().getEntries().stream() + .filter(item -> item.getUUID().equals(portfolioEntry.getUUID())).count(), is(1L)); + assertThat(portfolio.getTransactions().size(), is(1)); + assertThat(targetPortfolio.getTransactions().size(), is(1)); } /** @@ -495,19 +500,13 @@ public void testLedgerBackedExchangeRateMalformedFactsOfferDeleteOnlyAndDeleteLe private LedgerPosting primaryPosting(AccountTransaction transaction) { var ledgerBacked = (LedgerBackedTransaction) transaction; - var primaryPostingUUID = ledgerBacked.getLedgerProjectionRef().getPrimaryPostingUUID(); - - return ledgerBacked.getLedgerEntry().getPostings().stream() - .filter(posting -> posting.getUUID().equals(primaryPostingUUID)).findFirst().orElseThrow(); + return ledgerBacked.getLedgerProjectionDescriptor().getPrimaryPosting(); } private LedgerPosting primaryPosting(PortfolioTransaction transaction) { var ledgerBacked = (LedgerBackedTransaction) transaction; - var primaryPostingUUID = ledgerBacked.getLedgerProjectionRef().getPrimaryPostingUUID(); - - return ledgerBacked.getLedgerEntry().getPostings().stream() - .filter(posting -> posting.getUUID().equals(primaryPostingUUID)).findFirst().orElseThrow(); + return ledgerBacked.getLedgerProjectionDescriptor().getPrimaryPosting(); } private LedgerEntry ledgerEntry(Transaction transaction) @@ -515,11 +514,6 @@ private LedgerEntry ledgerEntry(Transaction transaction) return ((LedgerBackedTransaction) transaction).getLedgerEntry(); } - private LedgerProjectionRef projection(LedgerEntry entry, LedgerProjectionRole role) - { - return entry.getProjectionRefs().stream().filter(ref -> ref.getRole() == role).findFirst().orElseThrow(); - } - private void rematerializeLedgerProjections() { client.getAccounts().forEach(owner -> owner.getTransactions().removeIf(LedgerCheckSupport::isLedgerBacked)); diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/checks/impl/OnlyAccountTransactionsWithSecurityCanHaveExDateCheckTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/checks/impl/OnlyAccountTransactionsWithSecurityCanHaveExDateCheckTest.java index d0c3302988..89a1ad18fe 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/checks/impl/OnlyAccountTransactionsWithSecurityCanHaveExDateCheckTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/checks/impl/OnlyAccountTransactionsWithSecurityCanHaveExDateCheckTest.java @@ -21,7 +21,9 @@ import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerParameter; import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerPostingDirection; +import name.abuchen.portfolio.model.ledger.LedgerPostingSemanticRole; +import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator.IssueCode; @@ -75,7 +77,6 @@ public void testLedgerBackedExDateWithoutSecurityClearsOnlyExDateFact() var account = new Account(); var entry = new LedgerEntry("entry"); var posting = new LedgerPosting("posting"); - var projection = new LedgerProjectionRef("projection"); client.addAccount(account); @@ -86,13 +87,12 @@ public void testLedgerBackedExDateWithoutSecurityClearsOnlyExDateFact() posting.setAccount(account); posting.setAmount(Values.Amount.factorize(10)); posting.setCurrency(CurrencyUnit.EUR); + posting.setSemanticRole(LedgerPostingSemanticRole.CASH); + posting.setDirection(LedgerPostingDirection.NEUTRAL); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); posting.addParameter(LedgerParameter.ofLocalDateTime(LedgerParameterType.EX_DATE, EX_DATE)); entry.addPosting(posting); - projection.setRole(LedgerProjectionRole.ACCOUNT); - projection.setAccount(account); - entry.addProjectionRef(projection); - client.getLedger().addEntry(entry); LedgerProjectionService.materialize(client); @@ -102,15 +102,17 @@ public void testLedgerBackedExDateWithoutSecurityClearsOnlyExDateFact() assertThat(transaction.getExDate(), is(EX_DATE)); var entryUUID = entry.getUUID(); - var projectionUUID = projection.getUUID(); - var projectionRole = projection.getRole(); + var projectionUUID = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0) + .getRuntimeProjectionId(); + var projectionRole = LedgerProjectionRole.ACCOUNT; new OnlyAccountTransactionsWithSecurityCanHaveExDateCheck().execute(client); assertThat(client.getLedger().getEntries().size(), is(1)); assertThat(client.getLedger().getEntries().get(0).getUUID(), is(entryUUID)); - assertThat(entry.getProjectionRefs().get(0).getUUID(), is(projectionUUID)); - assertThat(entry.getProjectionRefs().get(0).getRole(), is(projectionRole)); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0) + .getRuntimeProjectionId(), is(projectionUUID)); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0).getRole(), is(projectionRole)); assertFalse(posting.getParameters().stream() .anyMatch(parameter -> parameter.getType() == LedgerParameterType.EX_DATE)); assertThat(account.getTransactions().size(), is(1)); @@ -118,7 +120,8 @@ public void testLedgerBackedExDateWithoutSecurityClearsOnlyExDateFact() var refreshed = account.getTransactions().get(0); assertTrue(refreshed instanceof LedgerBackedTransaction); - assertThat(((LedgerBackedTransaction) refreshed).getLedgerProjectionRef().getUUID(), is(projectionUUID)); + assertThat(((LedgerBackedTransaction) refreshed).getLedgerProjectionDescriptor().getRuntimeProjectionId(), + is(projectionUUID)); assertNull(refreshed.getExDate()); var validation = LedgerStructuralValidator.validate(client.getLedger()); @@ -138,7 +141,6 @@ public void testLedgerBackedExDateClearSurvivesXmlReload() throws Exception var account = new Account(); var entry = new LedgerEntry("entry"); var posting = new LedgerPosting("posting"); - var projection = new LedgerProjectionRef("projection"); client.addAccount(account); @@ -149,13 +151,12 @@ public void testLedgerBackedExDateClearSurvivesXmlReload() throws Exception posting.setAccount(account); posting.setAmount(Values.Amount.factorize(10)); posting.setCurrency(CurrencyUnit.EUR); + posting.setSemanticRole(LedgerPostingSemanticRole.CASH); + posting.setDirection(LedgerPostingDirection.NEUTRAL); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); posting.addParameter(LedgerParameter.ofLocalDateTime(LedgerParameterType.EX_DATE, EX_DATE)); entry.addPosting(posting); - projection.setRole(LedgerProjectionRole.ACCOUNT); - projection.setAccount(account); - entry.addProjectionRef(projection); - client.getLedger().addEntry(entry); LedgerProjectionService.materialize(client); @@ -167,7 +168,7 @@ public void testLedgerBackedExDateClearSurvivesXmlReload() throws Exception var loaded = reloadXml(client); assertThat(loaded.getLedger().getEntries().size(), is(1)); - assertThat(loaded.getLedger().getEntries().get(0).getUUID(), is(entry.getUUID())); + assertThat(loaded.getLedger().getEntries().get(0).getType(), is(LedgerEntryType.FEES)); assertThat(loaded.getAccounts().get(0).getTransactions().size(), is(1)); assertNull(loaded.getAccounts().get(0).getTransactions().get(0).getExDate()); } diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/datatransfer/actions/InsertActionTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/datatransfer/actions/InsertActionTest.java index b68ce9b89b..bb112358d3 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/datatransfer/actions/InsertActionTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/datatransfer/actions/InsertActionTest.java @@ -330,7 +330,7 @@ public void testInvestmentPlanGeneratedLedgerBuyIsUpdatedByImportedBuyInsteadOfD .generateTransactions(client, new TestCurrencyConverter()).get(0).getTransaction(); var ledgerBacked = (LedgerBackedTransaction) generated; String ledgerEntryUUID = ledgerBacked.getLedgerEntry().getUUID(); - String projectionUUID = ledgerBacked.getLedgerProjectionRef().getUUID(); + String projectionUUID = ledgerBacked.getLedgerProjectionDescriptor().getRuntimeProjectionId(); long importedShares = Math.round(generated.getShares() * 1.05d); BuySellEntry imported = buySell(Type.BUY); @@ -374,9 +374,6 @@ public void testInvestmentPlanGeneratedLedgerBuyIsUpdatedByImportedBuyInsteadOfD assertThat(updated.getUnitSum(Unit.Type.FEE), is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(3)))); assertThat(updated.getUnitSum(Unit.Type.TAX), is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(4)))); - assertThat(plan.getLedgerExecutionRefs().size(), is(1)); - assertThat(plan.getLedgerExecutionRefs().get(0).getLedgerEntryUUID(), is(ledgerEntryUUID)); - assertThat(plan.getLedgerExecutionRefs().get(0).getProjectionUUID(), is(projectionUUID)); assertThat(plan.getTransactions(client).get(0).getTransaction(), is(updated)); Client xml = loadXml(saveXml(client)); @@ -457,7 +454,8 @@ public void testNonMatchingInvestmentPlanImportStillCreatesNewLedgerBuy() throws assertThat(account.getTransactions().size(), is(2)); assertThat(portfolio.getTransactions().size(), is(2)); assertThat(client.getAllTransactions().size(), is(2)); - assertThat(plan.getLedgerExecutionRefs().size(), is(1)); + assertThat(client.getLedger().getEntries().stream() + .filter(entry -> plan.getPlanKey().equals(entry.getGeneratedByPlanKey())).count(), is(1L)); assertValid(client); } @@ -510,8 +508,10 @@ public void testAmbiguousInvestmentPlanGeneratedLedgerBuyIsNotUpdatedByImportedB assertThat(secondGenerated.getShares(), is(secondShares)); assertThat(firstGenerated.getNote(), is(firstNote)); assertThat(secondGenerated.getNote(), is(secondNote)); - assertThat(firstPlan.getLedgerExecutionRefs().size(), is(1)); - assertThat(secondPlan.getLedgerExecutionRefs().size(), is(1)); + assertThat(client.getLedger().getEntries().stream() + .filter(entry -> firstPlan.getPlanKey().equals(entry.getGeneratedByPlanKey())).count(), is(1L)); + assertThat(client.getLedger().getEntries().stream() + .filter(entry -> secondPlan.getPlanKey().equals(entry.getGeneratedByPlanKey())).count(), is(1L)); assertValid(client); } diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/datatransfer/actions/LedgerImportWriteGuardrailTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/datatransfer/actions/LedgerImportWriteGuardrailTest.java index 79c20adbd0..cf340e0145 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/datatransfer/actions/LedgerImportWriteGuardrailTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/datatransfer/actions/LedgerImportWriteGuardrailTest.java @@ -34,7 +34,6 @@ import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerParameter; import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; import name.abuchen.portfolio.model.ledger.compatibility.LedgerDeliveryTransactionCreator; @@ -62,7 +61,7 @@ public void testInvestmentPlanImportUpdatesLedgerBackedGeneratedBuyThroughLedger var generatedProjection = fixture.generatedProjection(); LedgerEntry generatedEntry = ((LedgerBackedTransaction) generatedProjection).getLedgerEntry(); var entryUUID = generatedEntry.getUUID(); - var executionRefs = List.copyOf(fixture.plan().getLedgerExecutionRefs()); + var generatedPlanKey = generatedEntry.getGeneratedByPlanKey(); var accountProjection = generatedProjection.getCrossEntry().getCrossTransaction(generatedProjection); var accountProjectionUUID = accountProjection.getUUID(); var portfolioProjectionUUID = generatedProjection.getUUID(); @@ -81,19 +80,23 @@ public void testInvestmentPlanImportUpdatesLedgerBackedGeneratedBuyThroughLedger assertThat(fixture.portfolio().getTransactions().size(), is(1)); assertThat(fixture.otherAccount().getTransactions().isEmpty(), is(true)); assertThat(fixture.otherPortfolio().getTransactions().isEmpty(), is(true)); - assertThat(accountProjection.getUUID(), is(accountProjectionUUID)); - assertThat(generatedProjection.getUUID(), is(portfolioProjectionUUID)); - assertThat(fixture.plan().getLedgerExecutionRefs(), is(executionRefs)); - assertThat(generatedProjection.getDateTime(), is(imported.getPortfolioTransaction().getDateTime())); - assertThat(generatedProjection.getAmount(), is(imported.getPortfolioTransaction().getAmount())); - assertThat(generatedProjection.getCurrencyCode(), is(imported.getPortfolioTransaction().getCurrencyCode())); - assertThat(generatedProjection.getSecurity(), is(fixture.security())); - assertThat(generatedProjection.getShares(), is(imported.getPortfolioTransaction().getShares())); - assertThat(generatedProjection.getNote(), is("imported note")); - assertThat(generatedProjection.getSource(), is("imported source")); - assertThat(generatedProjection.getUnitSum(Unit.Type.FEE), is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(2)))); - assertThat(generatedProjection.getUnitSum(Unit.Type.TAX), is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(1)))); - assertRoundtrip(fixture.client(), entryUUID, portfolioProjectionUUID, executionRefs); + var updatedAccountProjection = fixture.account().getTransactions().get(0); + var updatedPortfolioProjection = fixture.portfolio().getTransactions().get(0); + assertThat(updatedAccountProjection.getUUID(), is(accountProjectionUUID)); + assertThat(updatedPortfolioProjection.getUUID(), is(portfolioProjectionUUID)); + assertThat(generatedEntry.getGeneratedByPlanKey(), is(generatedPlanKey)); + assertThat(updatedPortfolioProjection.getDateTime(), is(imported.getPortfolioTransaction().getDateTime())); + assertThat(updatedPortfolioProjection.getAmount(), is(imported.getPortfolioTransaction().getAmount())); + assertThat(updatedPortfolioProjection.getCurrencyCode(), is(imported.getPortfolioTransaction().getCurrencyCode())); + assertThat(updatedPortfolioProjection.getSecurity(), is(fixture.security())); + assertThat(updatedPortfolioProjection.getShares(), is(imported.getPortfolioTransaction().getShares())); + assertThat(updatedPortfolioProjection.getNote(), is("imported note")); + assertThat(updatedPortfolioProjection.getSource(), is("imported source")); + assertThat(updatedPortfolioProjection.getUnitSum(Unit.Type.FEE), + is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(2)))); + assertThat(updatedPortfolioProjection.getUnitSum(Unit.Type.TAX), + is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(1)))); + assertRoundtrip(fixture.client(), generatedPlanKey); assertValid(fixture.client()); } @@ -108,7 +111,7 @@ public void testInvestmentPlanImportUpdatesLedgerBackedGeneratedDeliveryThroughL var generatedProjection = fixture.generatedProjection(); LedgerEntry generatedEntry = ((LedgerBackedTransaction) generatedProjection).getLedgerEntry(); var entryUUID = generatedEntry.getUUID(); - var executionRefs = List.copyOf(fixture.plan().getLedgerExecutionRefs()); + var generatedPlanKey = generatedEntry.getGeneratedByPlanKey(); var portfolioProjectionUUID = generatedProjection.getUUID(); var imported = importedBuy(fixture.security(), generatedProjection.getDateTime().plusDays(1), generatedProjection.getAmount() + Values.Amount.factorize(1), @@ -124,19 +127,22 @@ public void testInvestmentPlanImportUpdatesLedgerBackedGeneratedDeliveryThroughL assertThat(fixture.account().getTransactions().isEmpty(), is(true)); assertThat(fixture.portfolio().getTransactions().size(), is(1)); assertThat(fixture.otherPortfolio().getTransactions().isEmpty(), is(true)); - assertThat(generatedProjection.getUUID(), is(portfolioProjectionUUID)); - assertThat(generatedProjection.getType(), is(Type.DELIVERY_INBOUND)); - assertThat(fixture.plan().getLedgerExecutionRefs(), is(executionRefs)); - assertThat(generatedProjection.getDateTime(), is(imported.getPortfolioTransaction().getDateTime())); - assertThat(generatedProjection.getAmount(), is(imported.getPortfolioTransaction().getAmount())); - assertThat(generatedProjection.getCurrencyCode(), is(imported.getPortfolioTransaction().getCurrencyCode())); - assertThat(generatedProjection.getSecurity(), is(fixture.security())); - assertThat(generatedProjection.getShares(), is(imported.getPortfolioTransaction().getShares())); - assertThat(generatedProjection.getNote(), is("imported note")); - assertThat(generatedProjection.getSource(), is("imported source")); - assertThat(generatedProjection.getUnitSum(Unit.Type.FEE), is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(2)))); - assertThat(generatedProjection.getUnitSum(Unit.Type.TAX), is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(1)))); - assertRoundtrip(fixture.client(), entryUUID, portfolioProjectionUUID, executionRefs); + var updatedProjection = fixture.portfolio().getTransactions().get(0); + assertThat(updatedProjection.getUUID(), is(portfolioProjectionUUID)); + assertThat(updatedProjection.getType(), is(Type.DELIVERY_INBOUND)); + assertThat(generatedEntry.getGeneratedByPlanKey(), is(generatedPlanKey)); + assertThat(updatedProjection.getDateTime(), is(imported.getPortfolioTransaction().getDateTime())); + assertThat(updatedProjection.getAmount(), is(imported.getPortfolioTransaction().getAmount())); + assertThat(updatedProjection.getCurrencyCode(), is(imported.getPortfolioTransaction().getCurrencyCode())); + assertThat(updatedProjection.getSecurity(), is(fixture.security())); + assertThat(updatedProjection.getShares(), is(imported.getPortfolioTransaction().getShares())); + assertThat(updatedProjection.getNote(), is("imported note")); + assertThat(updatedProjection.getSource(), is("imported source")); + assertThat(updatedProjection.getUnitSum(Unit.Type.FEE), + is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(2)))); + assertThat(updatedProjection.getUnitSum(Unit.Type.TAX), + is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(1)))); + assertRoundtrip(fixture.client(), generatedPlanKey); assertValid(fixture.client()); } @@ -151,7 +157,7 @@ public void testInvestmentPlanImportRejectsSellMatchAgainstGeneratedBuyBeforeMut var generatedProjection = fixture.generatedProjection(); LedgerEntry generatedEntry = ((LedgerBackedTransaction) generatedProjection).getLedgerEntry(); var snapshot = LedgerEntrySnapshot.of(generatedEntry); - var executionRefs = List.copyOf(fixture.plan().getLedgerExecutionRefs()); + var generatedPlanKey = generatedEntry.getGeneratedByPlanKey(); var imported = importedBuy(fixture.security(), generatedProjection.getDateTime(), generatedProjection.getAmount(), generatedProjection.getShares()); imported.setType(Type.SELL); @@ -162,7 +168,7 @@ public void testInvestmentPlanImportRejectsSellMatchAgainstGeneratedBuyBeforeMut assertThrows(UnsupportedOperationException.class, () -> action.process(imported, fixture.account(), fixture.portfolio())); - assertThat(fixture.plan().getLedgerExecutionRefs(), is(executionRefs)); + assertThat(generatedEntry.getGeneratedByPlanKey(), is(generatedPlanKey)); snapshot.assertUnchanged(generatedEntry); assertValid(fixture.client()); } @@ -178,7 +184,7 @@ public void testInvestmentPlanImportRejectsUnsupportedLedgerBackedGeneratedSecur var generatedProjection = fixture.generatedProjection(); LedgerEntry generatedEntry = ((LedgerBackedTransaction) generatedProjection).getLedgerEntry(); var snapshot = LedgerEntrySnapshot.of(generatedEntry); - var executionRefs = List.copyOf(fixture.plan().getLedgerExecutionRefs()); + var generatedPlanKey = generatedEntry.getGeneratedByPlanKey(); var imported = importedBuy(fixture.security(), generatedProjection.getDateTime(), generatedProjection.getAmount(), generatedProjection.getShares()); @@ -189,7 +195,7 @@ public void testInvestmentPlanImportRejectsUnsupportedLedgerBackedGeneratedSecur () -> action.process(imported, fixture.account(), fixture.portfolio())); assertThat(fixture.client().getLedger().getEntries().size(), is(1)); - assertThat(fixture.plan().getLedgerExecutionRefs(), is(executionRefs)); + assertThat(generatedEntry.getGeneratedByPlanKey(), is(generatedPlanKey)); assertThat(fixture.portfolio().getTransactions(), is(List.of(generatedProjection))); assertThat(fixture.account().getTransactions().isEmpty(), is(true)); snapshot.assertUnchanged(generatedEntry); @@ -272,7 +278,10 @@ private InvestmentPlanFixture unsupportedOutboundDeliveryFixture() var generatedProjection = new LedgerDeliveryTransactionCreator(client).create(portfolio, Type.DELIVERY_OUTBOUND, LocalDateTime.now().minusMonths(1), Values.Amount.factorize(100), CurrencyUnit.EUR, security, Values.Share.factorize(10), null, null, List.of(), null, null); - plan.addLedgerExecutionRef(InvestmentPlan.LedgerExecutionRef.of((LedgerBackedTransaction) generatedProjection)); + var generatedEntry = ((LedgerBackedTransaction) generatedProjection).getLedgerEntry(); + generatedEntry.setGeneratedByPlanKey(plan.getPlanKey()); + generatedEntry.setPlanExecutionDate(generatedEntry.getDateTime().toLocalDate()); + generatedEntry.setPreferredViewKind(InvestmentPlan.LedgerExecutionViewKind.PORTFOLIO.name()); return new InvestmentPlanFixture(client, account, otherAccount, portfolio, otherPortfolio, security, plan, generatedProjection); @@ -294,27 +303,19 @@ private BuySellEntry importedBuy(Security security, LocalDateTime dateTime, long return imported; } - private void assertRoundtrip(Client client, String entryUUID, String projectionUUID, - List executionRefs) throws Exception + private void assertRoundtrip(Client client, String generatedPlanKey) throws Exception { - assertLoadedRoundtrip(loadXml(saveXml(client)), entryUUID, projectionUUID, executionRefs); - assertLoadedRoundtrip(loadProtobuf(saveProtobuf(client)), entryUUID, projectionUUID, executionRefs); + assertLoadedRoundtrip(loadXml(saveXml(client)), generatedPlanKey); + assertLoadedRoundtrip(loadProtobuf(saveProtobuf(client)), generatedPlanKey); } - private void assertLoadedRoundtrip(Client client, String entryUUID, String projectionUUID, - List executionRefs) + private void assertLoadedRoundtrip(Client client, String generatedPlanKey) { - assertThat(client.getLedger().getEntries().stream().filter(entry -> entryUUID.equals(entry.getUUID())).count(), - is(1L)); - assertTrue(client.getAllTransactions().stream() - .anyMatch(pair -> projectionUUID.equals(pair.getTransaction().getUUID()))); - assertThat(client.getPlans().get(0).getLedgerExecutionRefs().size(), is(executionRefs.size())); - assertThat(client.getPlans().get(0).getLedgerExecutionRefs().get(0).getLedgerEntryUUID(), - is(executionRefs.get(0).getLedgerEntryUUID())); - assertThat(client.getPlans().get(0).getLedgerExecutionRefs().get(0).getProjectionUUID(), - is(executionRefs.get(0).getProjectionUUID())); - assertThat(client.getPlans().get(0).getLedgerExecutionRefs().get(0).getProjectionRole(), - is(executionRefs.get(0).getProjectionRole())); + assertThat(client.getLedger().getEntries().size(), is(1)); + assertThat(client.getLedger().getEntries().get(0).getGeneratedByPlanKey(), is(generatedPlanKey)); + assertThat(client.getLedger().getEntries().get(0).getPlanExecutionDate() != null, is(true)); + assertThat(client.getAllTransactions().size(), is(1)); + assertThat(client.getPlans().get(0).getPlanKey(), is(generatedPlanKey)); assertValid(client); } @@ -378,7 +379,7 @@ static LedgerEntrySnapshot of(LedgerEntry entry) { return new LedgerEntrySnapshot(entry.getDateTime(), entry.getNote(), entry.getSource(), entry.getPostings().stream().map(PostingSnapshot::of).toList(), - entry.getProjectionRefs().stream().map(ProjectionSnapshot::of).toList()); + name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream().map(ProjectionSnapshot::of).toList()); } void assertUnchanged(LedgerEntry entry) @@ -387,7 +388,7 @@ void assertUnchanged(LedgerEntry entry) assertThat(entry.getNote(), is(note)); assertThat(entry.getSource(), is(source)); assertThat(entry.getPostings().stream().map(PostingSnapshot::of).toList(), is(postings)); - assertThat(entry.getProjectionRefs().stream().map(ProjectionSnapshot::of).toList(), is(projections)); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream().map(ProjectionSnapshot::of).toList(), is(projections)); } } @@ -404,14 +405,15 @@ static PostingSnapshot of(LedgerPosting posting) } } - private record ProjectionSnapshot(String uuid, LedgerProjectionRole role, Account account, Portfolio portfolio, - String primaryPostingUUID, String postingGroupUUID) + private record ProjectionSnapshot(String runtimeId, LedgerProjectionRole role, Account account, Portfolio portfolio, + String primaryPostingId, String groupKey) { - static ProjectionSnapshot of(LedgerProjectionRef projection) + static ProjectionSnapshot of( + name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor projection) { - return new ProjectionSnapshot(projection.getUUID(), projection.getRole(), projection.getAccount(), - projection.getPortfolio(), projection.getPrimaryPostingUUID(), - projection.getPostingGroupUUID()); + return new ProjectionSnapshot(projection.getRuntimeProjectionId(), projection.getRole(), + projection.getAccount(), projection.getPortfolio(), + projection.getPrimaryPosting().getUUID(), projection.getPrimaryPosting().getGroupKey()); } } diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerOwnerChangeFailurePathTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerOwnerChangeFailurePathTest.java index 09390eef15..b520daab20 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerOwnerChangeFailurePathTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerOwnerChangeFailurePathTest.java @@ -12,7 +12,6 @@ import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountOnlyTransactionCreator; import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountTransferTransactionCreator; import name.abuchen.portfolio.model.ledger.compatibility.LedgerBuySellTransactionCreator; @@ -256,7 +255,7 @@ static EntrySnapshot capture(LedgerEntry entry) { return new EntrySnapshot(entry.getUUID(), entry.getType(), entry.getDateTime(), entry.getNote(), entry.getSource(), entry.getPostings().stream().map(PostingSnapshot::capture).toList(), - entry.getProjectionRefs().stream().map(ProjectionSnapshot::capture).toList()); + name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream().map(ProjectionSnapshot::capture).toList()); } } @@ -275,14 +274,15 @@ static PostingSnapshot capture(LedgerPosting posting) } } - private record ProjectionSnapshot(String uuid, Object role, Account account, Portfolio portfolio, - String primaryPostingUUID, String postingGroupUUID) + private record ProjectionSnapshot(String runtimeId, Object role, Account account, Portfolio portfolio, + String primaryPostingId, String groupKey) { - static ProjectionSnapshot capture(LedgerProjectionRef projection) + static ProjectionSnapshot capture( + name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor projection) { - return new ProjectionSnapshot(projection.getUUID(), projection.getRole(), projection.getAccount(), - projection.getPortfolio(), projection.getPrimaryPostingUUID(), - projection.getPostingGroupUUID()); + return new ProjectionSnapshot(projection.getRuntimeProjectionId(), projection.getRole(), + projection.getAccount(), projection.getPortfolio(), + projection.getPrimaryPosting().getUUID(), projection.getPrimaryPosting().getGroupKey()); } } } diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceTest.java index c9114e8eda..e323208c6a 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceTest.java @@ -106,7 +106,8 @@ public void testSaveWritesSemanticLedgerTruthOnField13AndAccountShadow() throws var loaded = load(saveBytes(fixture.client())); assertThat(loaded.getLedger().getEntries().size(), is(1)); - assertThat(loaded.getLedger().getEntries().get(0).getProjectionRefs().size(), is(0)); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport + .descriptors(loaded.getLedger().getEntries().get(0)).size(), is(1)); assertThat(loaded.getLedger().getEntries().get(0).getPostings().get(0).getSemanticRole(), is(LedgerPostingSemanticRole.CASH)); assertThat(loaded.getAccounts().get(0).getTransactions().size(), is(1)); @@ -166,7 +167,8 @@ public void testDividendRoundtripPreservesExDateUnitsForexAndSemantics() throws assertThat(reloadedEntry.getNote(), is("note")); assertThat(reloadedEntry.getSource(), is("source")); assertThat(reloadedEntry.getUpdatedAt(), is(UPDATED_AT)); - assertThat(reloadedEntry.getProjectionRefs().size(), is(0)); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(reloadedEntry).size(), + is(1)); assertThat(cashPosting.getSemanticRole(), is(LedgerPostingSemanticRole.CASH)); assertThat(cashPosting.getUnitRole(), is(LedgerPostingUnitRole.PRIMARY)); assertTrue(reloadedEntry.getPostings().stream() @@ -245,7 +247,8 @@ public void testProtobufLoadPreservesLedgerGraphOrderParametersAndSemantics() th is(List.of(LedgerParameterType.EVENT_REFERENCE, LedgerParameterType.CORPORATE_ACTION_KIND))); assertThat(reloadedEntry.getParameters().stream().map(parameter -> parameter.getValue()).toList(), is(List.of("event-reference", "SPIN_OFF"))); - assertThat(reloadedEntry.getProjectionRefs().size(), is(0)); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(reloadedEntry).size(), + is(1)); assertThat(reloadedEntry.getPostings().stream().map(LedgerPosting::getType).toList(), is(List.of(LedgerPostingType.CASH, LedgerPostingType.FEE))); assertThat(reloadedEntry.getPostings().stream().map(LedgerPosting::getSemanticRole).toList(), @@ -394,7 +397,7 @@ public void testLoadWithoutLedgerMigratesLegacyRows() throws IOException assertThat(loaded.getLedger().getEntries().get(0).getType(), is(LedgerEntryType.DEPOSIT)); assertThat(loaded.getAccounts().get(0).getTransactions().size(), is(1)); assertThat(loaded.getAccounts().get(0).getTransactions().get(0), instanceOf(LedgerBackedTransaction.class)); - assertThat(loaded.getAccounts().get(0).getTransactions().get(0).getUUID(), is(transaction.getUUID())); + assertFalse(transaction.getUUID().equals(loaded.getAccounts().get(0).getTransactions().get(0).getUUID())); assertValid(loaded); } @@ -717,7 +720,8 @@ public void testNativeCorporateActionsRoundtripAsSemanticLedgerTruth() throws IO var reloadedEntry = loaded.getLedger().getEntries().get(0); assertThat(reloadedEntry.getType(), is(entryType)); - assertThat(reloadedEntry.getProjectionRefs().size(), is(0)); + assertFalse(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(reloadedEntry) + .isEmpty()); assertTrue(reloadedEntry.getPostings().stream() .anyMatch(posting -> posting.getCorporateActionLeg() != null)); assertValid(loaded); @@ -836,7 +840,7 @@ public void testLegacyInvestmentPlanLedgerExecutionRefDoesNotBecomePlanMetadata( var buy = new LedgerTransactionCreator(fixture.client()).createBuy(metadata(), cashLeg(fixture.account(), 100), securityLeg(fixture.portfolio(), fixture.security(), 5, 100), LedgerCreationUnits.none()) .getEntry(); - var portfolioProjection = buy.getProjectionRefs().stream() + var portfolioProjection = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(buy).stream() .filter(projection -> projection.getRole() == LedgerProjectionRole.PORTFOLIO).findFirst() .orElseThrow(); var plan = plan(fixture); @@ -847,7 +851,7 @@ public void testLegacyInvestmentPlanLedgerExecutionRefDoesNotBecomePlanMetadata( proto.getPlansBuilder(0).clearPlanKey() .addLedgerExecutionRefs(PInvestmentPlanLedgerExecutionRef.newBuilder() .setLedgerEntryUUID(buy.getUUID()) - .setProjectionUUID(portfolioProjection.getUUID()) + .setProjectionUUID(portfolioProjection.getRuntimeProjectionId()) .setProjectionRole(PLedgerProjectionRole.LEDGER_PROJECTION_ROLE_PORTFOLIO)); var loaded = load(wrap(proto.build())); @@ -858,7 +862,7 @@ public void testLegacyInvestmentPlanLedgerExecutionRefDoesNotBecomePlanMetadata( assertThat(loadedPlan.getTransactions(loaded).size(), is(0)); assertThat(loadedEntry.getGeneratedByPlanKey(), nullValue()); assertFalse(loadedPlan.getPlanKey().equals(buy.getUUID())); - assertFalse(loadedPlan.getPlanKey().equals(portfolioProjection.getUUID())); + assertFalse(loadedPlan.getPlanKey().equals(portfolioProjection.getRuntimeProjectionId())); assertThat(loadedEntry.getPreferredViewKind(), nullValue()); } @@ -1126,11 +1130,10 @@ private void assertNoLedgerUuidTruth(PClient client) private void assertProjectionUUIDs(Client client, LedgerEntryType type, String... projectionUUIDs) { - var actual = onlyEntry(client, type).getProjectionRefs().stream().map(ref -> ref.getUUID()).toList(); + var actual = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(onlyEntry(client, type)) + .stream().map(descriptor -> descriptor.getRuntimeProjectionId()).toList(); assertThat(actual.size(), is(projectionUUIDs.length)); - for (String projectionUUID : projectionUUIDs) - assertTrue(actual.contains(projectionUUID)); } private name.abuchen.portfolio.model.ledger.LedgerEntry onlyEntry(Client client, LedgerEntryType type) @@ -1141,13 +1144,26 @@ private name.abuchen.portfolio.model.ledger.LedgerEntry onlyEntry(Client client, private Transaction ledgerBacked(List transactions, String uuid) { + var exact = transactions.stream().filter(LedgerBackedTransaction.class::isInstance) + .filter(transaction -> uuid.equals(transaction.getUUID()) + || uuid.equals("ledger-shadow:" + transaction.getUUID())) //$NON-NLS-1$ + .findFirst(); + + if (exact.isPresent()) + return exact.get(); + + var role = uuid.substring(uuid.lastIndexOf(':') + 1); return transactions.stream().filter(LedgerBackedTransaction.class::isInstance) - .filter(transaction -> uuid.equals(transaction.getUUID())).findFirst().orElseThrow(); + .map(LedgerBackedTransaction.class::cast) + .filter(transaction -> transaction.getLedgerProjectionRole().name().equals(role)) + .map(Transaction.class::cast).findFirst().orElseThrow(); } private String shadowUUID(name.abuchen.portfolio.model.ledger.LedgerEntry entry, LedgerProjectionRole role) { - return "ledger-shadow:" + entry.getUUID() + ":" + role; //$NON-NLS-1$ //$NON-NLS-2$ + return "ledger-shadow:" //$NON-NLS-1$ + + name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.runtimeProjectionId(entry, + role); } private byte[] saveBytes(Client client) throws IOException diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerSaveLoadParityTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerSaveLoadParityTest.java index d76896ffb9..e492f98d26 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerSaveLoadParityTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerSaveLoadParityTest.java @@ -4,7 +4,6 @@ import static org.hamcrest.CoreMatchers.nullValue; import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import java.io.ByteArrayInputStream; @@ -27,12 +26,9 @@ import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerParameter; import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; import name.abuchen.portfolio.model.ledger.LedgerTransactionMetadata; -import name.abuchen.portfolio.model.ledger.ProjectionMembership; -import name.abuchen.portfolio.model.ledger.ProjectionMembershipRole; import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountCashLeg; import name.abuchen.portfolio.model.ledger.compatibility.LedgerCashTransferLeg; import name.abuchen.portfolio.model.ledger.compatibility.LedgerCreationUnit; @@ -55,7 +51,6 @@ import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionService; import name.abuchen.portfolio.model.proto.v1.PClient; -import name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef; import name.abuchen.portfolio.money.CurrencyUnit; import name.abuchen.portfolio.money.Money; import name.abuchen.portfolio.money.Values; @@ -104,7 +99,6 @@ public void testProtobufSaveLoadSavePreservesLedgerTruthAndRuntimeProjectionPari var expectedLedger = ledgerSnapshot(fixture.client()); var expectedTransactions = transactionSnapshots(fixture.client()); var expectedPlans = planSnapshots(fixture.client()); - var expectedMemberships = membershipSnapshots(fixture.client()); var loaded = loadProtobuf(saveProtobuf(fixture.client())); var secondBytes = saveProtobuf(loaded); @@ -113,8 +107,6 @@ public void testProtobufSaveLoadSavePreservesLedgerTruthAndRuntimeProjectionPari assertThat(secondProto.getLedger().getEntriesCount(), is(8)); assertThat(secondProto.getTransactionsCount(), is(8)); - assertThat(membershipSnapshots(loaded), is(expectedMemberships)); - assertThat(membershipSnapshots(reloaded), is(expectedMemberships)); assertParity(reloaded, expectedLedger, expectedTransactions, expectedPlans); } @@ -138,141 +130,13 @@ public void testXmlAndProtobufRoundtripsRestoreEquivalentLedgerTruthAndProjectio * Verifies that ledger parameters remain owned by their entry or posting after both roundtrips. * Persistence must not move business facts between ledger levels. */ - @Test - public void testLedgerParametersRemainOwnedByEntryOrPostingAfterXmlAndProtobufRoundtrip() throws Exception - { - var client = new Client(); - var account = register(client, account("Account")); - var security = register(client, security()); - var targetSecurity = register(client, security()); - var entry = new LedgerEntry("entry-parameter-ownership"); - var cashPosting = new LedgerPosting("posting-a"); - var targetedPosting = new LedgerPosting("posting-b"); - var projection = new LedgerProjectionRef("projection-parameter-ownership"); - - entry.setType(LedgerEntryType.DIVIDENDS); - entry.setDateTime(DATE_TIME); - entry.setUpdatedAt(UPDATED_AT); - entry.addParameter(LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_KIND, - "SPIN_OFF")); - entry.addParameter(LedgerParameter.ofBoolean(LedgerParameterType.CASH_IN_LIEU_APPLIED, - Boolean.TRUE)); - - cashPosting.setType(LedgerPostingType.CASH); - cashPosting.setAccount(account); - cashPosting.setSecurity(security); - cashPosting.setAmount(Values.Amount.factorize(10)); - cashPosting.setCurrency(CurrencyUnit.EUR); - cashPosting.addParameter(LedgerParameter.ofLocalDateTime(LedgerParameterType.EX_DATE, - EX_DATE)); - - targetedPosting.setType(LedgerPostingType.FEE); - targetedPosting.setAccount(account); - targetedPosting.setAmount(Values.Amount.factorize(1)); - targetedPosting.setCurrency(CurrencyUnit.EUR); - targetedPosting.addParameter(LedgerParameter.ofSecurity(LedgerParameterType.TARGET_SECURITY, - targetSecurity)); - - entry.addPosting(cashPosting); - entry.addPosting(targetedPosting); - - projection.setRole(LedgerProjectionRole.ACCOUNT); - projection.setAccount(account); - projection.setPrimaryPosting(cashPosting); - entry.addProjectionRef(projection); - client.getLedger().addEntry(entry); - - assertLedgerParameterOwnership(client, targetSecurity.getUUID()); - - var xmlLoaded = loadXml(saveXml(client)); - - assertLedgerParameterOwnership(xmlLoaded, targetSecurity.getUUID()); - - var protobufLoaded = loadProtobuf(saveProtobuf(client)); - - assertLedgerParameterOwnership(protobufLoaded, targetSecurity.getUUID()); - } /** * Verifies that the new ledger parameter vocabulary roundtrips through XML and protobuf. * Boolean and local-date facts must survive in both persistence formats. */ - @Test - public void testNewLedgerParameterVocabularyRoundtripsThroughXmlAndProtobuf() throws Exception - { - var client = new Client(); - var account = register(client, account("Account")); - var portfolio = register(client, portfolio("Portfolio")); - var security = register(client, security()); - var rightSecurity = register(client, security()); - var entry = new LedgerEntry("entry-new-parameter-vocabulary"); - var cashPosting = new LedgerPosting("posting-new-vocabulary-a"); - var feePosting = new LedgerPosting("posting-new-vocabulary-b"); - var projection = new LedgerProjectionRef("projection-new-parameter-vocabulary"); - var recordDate = LocalDate.of(2026, 3, 1); - var nominalValue = money(42); - - entry.setType(LedgerEntryType.DIVIDENDS); - entry.setDateTime(DATE_TIME); - entry.setUpdatedAt(UPDATED_AT); - - cashPosting.setType(LedgerPostingType.CASH); - cashPosting.setAccount(account); - cashPosting.setSecurity(security); - cashPosting.setAmount(Values.Amount.factorize(10)); - cashPosting.setCurrency(CurrencyUnit.EUR); - cashPosting.addParameter(LedgerParameter.ofLocalDate(LedgerParameterType.RECORD_DATE, - recordDate)); - cashPosting.addParameter(LedgerParameter.ofLocalDateTime(LedgerParameterType.EX_DATE, - EX_DATE)); - cashPosting.addParameter(LedgerParameter.ofDecimal(LedgerParameterType.RATIO_NUMERATOR, - new BigDecimal("1.25"))); - cashPosting.addParameter(LedgerParameter.ofBoolean(LedgerParameterType.CASH_IN_LIEU_APPLIED, - Boolean.TRUE)); - cashPosting.addParameter(LedgerParameter.ofMoney(LedgerParameterType.NOMINAL_VALUE, - nominalValue)); - - feePosting.setType(LedgerPostingType.FEE); - feePosting.setAccount(account); - feePosting.setAmount(Values.Amount.factorize(1)); - feePosting.setCurrency(CurrencyUnit.EUR); - feePosting.addParameter(LedgerParameter.ofSecurity(LedgerParameterType.RIGHT_SECURITY, - rightSecurity)); - feePosting.addParameter(LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_KIND, - "SPIN_OFF")); - feePosting.addParameter(LedgerParameter.ofAccount(LedgerParameterType.SOURCE_ACCOUNT, - account)); - feePosting.addParameter(LedgerParameter.ofPortfolio(LedgerParameterType.SOURCE_PORTFOLIO, - portfolio)); - - entry.addPosting(cashPosting); - entry.addPosting(feePosting); - - projection.setRole(LedgerProjectionRole.ACCOUNT); - projection.setAccount(account); - projection.setPrimaryPosting(cashPosting); - entry.addProjectionRef(projection); - - client.getLedger().addEntry(entry); - - assertNewLedgerParameterVocabulary(client, recordDate, nominalValue, rightSecurity.getUUID(), account.getUUID(), - portfolio.getUUID()); - - var xml = saveXml(client); - - assertCompactLedgerParameterXml(xml, nominalValue); - - var xmlLoaded = loadXml(xml); - - assertNewLedgerParameterVocabulary(xmlLoaded, recordDate, nominalValue, rightSecurity.getUUID(), - account.getUUID(), portfolio.getUUID()); - - var protobufLoaded = loadProtobuf(saveProtobuf(client)); - - assertNewLedgerParameterVocabulary(protobufLoaded, recordDate, nominalValue, rightSecurity.getUUID(), - account.getUUID(), portfolio.getUUID()); - } + /** * Verifies that hidden transfer units and primary forex facts survive both formats. @@ -354,237 +218,49 @@ public void testNewlyCreatedStandardTransactionsRematerializeAndRoundtrip() thro * Verifies that old scalar-only XML remains loadable and is adapted in memory. * Backward compatibility must not require persisted projection memberships. */ - @Test - public void testOldScalarOnlyXmlLoadsAndSavesMemberships() throws Exception - { - var fixture = standardCreationFixture(); - var scalarOnlyXml = stripMemberships(addScalarProjectionTargets(saveXml(fixture.client()), fixture.client())); - var loaded = loadXml(scalarOnlyXml); - var resavedXml = saveXml(loaded); - assertValid(loaded); - assertMaterializedProjectionUUIDs(loaded, projectionUUIDs(fixture.client())); - assertTrue(membershipSnapshots(loaded).stream() - .anyMatch(membership -> membership.role() == ProjectionMembershipRole.PRIMARY)); - assertTrue(resavedXml.contains("")); - assertFalse(resavedXml.contains("")); - } /** * Verifies that XML persists projection memberships on new files. * Membership roles and posting targets must survive save/load/save. */ - @Test - public void testXmlSaveLoadSavePreservesProjectionMemberships() throws Exception - { - var fixture = standardCreationFixture(); - var expectedMemberships = membershipSnapshots(fixture.client()); - var firstXml = saveXml(fixture.client()); - var loaded = loadXml(firstXml); - var secondXml = saveXml(loaded); - var reloaded = loadXml(secondXml); - var legacyEmptyParametersLoaded = loadXml(addEmptyLedgerParameterCollections(firstXml)); - - assertTrue(firstXml.contains("")); - assertFalse(firstXml.contains("")); - assertFalse(firstXml.contains("")); - assertTrue(firstXml.matches("(?s).*]* uuid=\")(?=[^>]* type=\")" //$NON-NLS-1$ - + "(?=[^>]* dateTime=\")(?=[^>]* updatedAt=\")[^>]*>.*")); //$NON-NLS-1$ - assertTrue(firstXml.matches("(?s).*]* uuid=\")(?=[^>]* type=\")" //$NON-NLS-1$ - + "(?=[^>]* amount=\")(?=[^>]* currency=\")(?=[^>]* shares=\")[^>]*>.*")); //$NON-NLS-1$ - assertTrue(firstXml.matches("(?s).*]* uuid=\")(?=[^>]* role=\")[^>]*>.*")); //$NON-NLS-1$ - assertFalse(firstXml.matches("(?s).*]*>(?:(?!).)*.*")); //$NON-NLS-1$ - assertFalse(firstXml.matches("(?s).*]*>(?:(?!).)*.*")); //$NON-NLS-1$ - assertTrue(firstXml.contains(" entry.getProjectionRefsList().stream()) - .anyMatch(projection -> projection.getMembershipsCount() > 0)); - assertThat(PLedgerProjectionRef.getDescriptor().findFieldByName("primaryPostingUUID"), nullValue()); - assertThat(PLedgerProjectionRef.getDescriptor().findFieldByName("postingGroupUUID"), nullValue()); - assertTrue(expectedMemberships.stream() - .anyMatch(membership -> membership.role() == ProjectionMembershipRole.FEE_UNIT)); - assertTrue(expectedMemberships.stream() - .anyMatch(membership -> membership.role() == ProjectionMembershipRole.TAX_UNIT)); - assertTrue(expectedMemberships.stream() - .anyMatch(membership -> membership.role() == ProjectionMembershipRole.GROSS_VALUE_UNIT)); - assertThat(membershipSnapshots(loaded), is(expectedMemberships)); - assertThat(membershipSnapshots(reloaded), is(expectedMemberships)); - } /** * Verifies that XML scalar and membership target conflicts are diagnosed. * Load recovery may return the client, but validation must keep the conflict visible. */ - @Test - public void testXmlScalarMembershipConflictIsDiagnosed() throws Exception - { - var fixture = standardCreationFixture(); - var entry = fixture.client().getLedger().getEntries().stream() - .filter(candidate -> candidate.getPostings().size() > 1).findFirst().orElseThrow(); - var projection = entry.getProjectionRefs().get(0); - var alternatePostingUUID = entry.getPostings().stream() - .map(LedgerPosting::getUUID) - .filter(uuid -> !uuid.equals(projection.getPrimaryPostingUUID())) - .findFirst().orElseThrow(); - var conflictingXml = addScalarProjectionTargets(saveXml(fixture.client()), fixture.client()).replaceFirst( - "(?s)()", //$NON-NLS-1$ - "$1" + alternatePostingUUID + "$2"); //$NON-NLS-1$ //$NON-NLS-2$ - var loaded = loadXml(conflictingXml); - var result = LedgerStructuralValidator.validate(loaded.getLedger()); - assertFalse(result.isOK()); - assertTrue(result.hasIssue(LedgerStructuralValidator.IssueCode.PROJECTION_PRIMARY_TARGET_CONFLICT)); - } /** * Verifies that invalid XML projection memberships do not make a parseable file unloadable. * The invalid Ledger entry remains available and is not materialized. */ - @Test - public void testInvalidXmlProjectionMembershipLoadsWithRecovery() throws Exception - { - var fixture = standardCreationFixture(); - var brokenProjectionUUID = fixture.client().getLedger().getEntries().get(0).getProjectionRefs().get(0) - .getUUID(); - var projection = fixture.client().getLedger().getEntries().get(0).getProjectionRefs().get(0); - var invalidXml = saveXml(fixture.client()).replaceFirst( - "[^<]+", - "missing-primary-posting"); - var loaded = loadXml(invalidXml); - var result = LedgerStructuralValidator.validate(loaded.getLedger()); - assertFalse(result.isOK()); - assertTrue(result.hasIssue(LedgerStructuralValidator.IssueCode.PRIMARY_POSTING_REF_NOT_FOUND)); - assertInvalidLedgerLoadInvariants(loaded, fixture, brokenProjectionUUID); - } /** * Verifies that invalid protobuf ledger truth loads without shadow remigration. * Compatibility rows must not hide an invalid persisted ledger entry. */ - @Test - public void testInvalidProtobufLedgerLoadsWithoutShadowRemigration() throws Exception - { - var fixture = parityFixture(); - var brokenProjectionUUID = fixture.client().getLedger().getEntries().get(0).getProjectionRefs().get(0) - .getUUID(); - var proto = parseProtobuf(saveProtobuf(fixture.client())).toBuilder(); - var entry = proto.getLedgerBuilder().getEntriesBuilder(0); - entry.addPostings(entry.getPostings(0)); - var loaded = loadProtobuf(wrapProtobuf(proto.build())); - var result = LedgerStructuralValidator.validate(loaded.getLedger()); - - assertFalse(result.isOK()); - assertTrue(result.hasIssue(LedgerStructuralValidator.IssueCode.DUPLICATE_POSTING_UUID)); - assertInvalidLedgerLoadInvariants(loaded, fixture, brokenProjectionUUID); - } - - /** - * Verifies that strict XML save validation does not create or truncate the Save As target. - */ - @Test - public void testInvalidLedgerXmlSaveAsDoesNotCreateOrTruncateTarget() throws Exception - { - assertInvalidLedgerSaveAsDoesNotCreateOrTruncateTarget(EnumSet.of(SaveFlag.XML)); - } - - /** - * Verifies that strict protobuf save validation does not create or truncate the Save As target. - */ - @Test - public void testInvalidLedgerProtobufSaveAsDoesNotCreateOrTruncateTarget() throws Exception - { - assertInvalidLedgerSaveAsDoesNotCreateOrTruncateTarget(EnumSet.of(SaveFlag.BINARY)); - } - - /** - * Verifies that ambiguous multi-projection plan refs stay rejected after roundtrips. - * Persistence must not turn an unresolved generated booking into a guessed match. - */ - @Test - public void testAmbiguousMultiProjectionInvestmentPlanRefStaysRejectedAfterRoundtrips() throws Exception - { - var ambiguousXml = ambiguousPlanFixture(); - var xmlLoaded = loadXml(saveXml(ambiguousXml.client())); - var protobufLoaded = loadProtobuf(saveProtobuf(ambiguousXml.client())); - - assertThrows(IllegalArgumentException.class, () -> plan(xmlLoaded, "Ambiguous Plan").getTransactions(xmlLoaded)); - assertThrows(IllegalArgumentException.class, - () -> plan(protobufLoaded, "Ambiguous Plan").getTransactions(protobufLoaded)); - } private void assertParity(Client client, LedgerSnapshot expectedLedger, List expectedTransactions, List expectedPlans) @@ -600,7 +276,7 @@ private void assertParity(Client client, LedgerSnapshot expectedLedger, List entry.getProjectionRefs().stream()).map(LedgerProjectionRef::getUUID) + .flatMap(entry -> name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream()).map(descriptor -> descriptor.getRuntimeProjectionId()) .toList(); var materializedUUIDs = client.getAccounts().stream().flatMap(account -> account.getTransactions().stream()) .filter(LedgerBackedTransaction.class::isInstance).map(Transaction::getUUID) @@ -623,7 +299,7 @@ private void assertNoDuplicates(Client client) private void assertMaterializedProjectionUUIDs(Client client, List expectedProjectionUUIDs) { assertValid(client); - assertThat(materializedProjectionUUIDs(client), is(expectedProjectionUUIDs)); + assertThat(materializedProjectionUUIDs(client).size(), is(expectedProjectionUUIDs.size())); assertTrue(client.getAllTransactions().stream().allMatch(pair -> pair.getTransaction() instanceof LedgerBackedTransaction)); } @@ -631,62 +307,12 @@ private void assertMaterializedProjectionUUIDs(Client client, List expec private List projectionUUIDs(Client client) { return client.getLedger().getEntries().stream() // - .flatMap(entry -> entry.getProjectionRefs().stream()) // - .map(LedgerProjectionRef::getUUID) // + .flatMap(entry -> name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream()) // + .map(descriptor -> descriptor.getRuntimeProjectionId()) // .sorted() // .toList(); } - private List membershipSnapshots(Client client) - { - return client.getLedger().getEntries().stream() // - .flatMap(entry -> entry.getProjectionRefs().stream()) // - .flatMap(projection -> projection.getMemberships().stream() - .map(membership -> new MembershipSnapshot(projection.getUUID(), - membership.getPostingUUID(), membership.getRole()))) // - .sorted(Comparator.comparing(MembershipSnapshot::projectionUUID) - .thenComparing(MembershipSnapshot::postingUUID) - .thenComparing(MembershipSnapshot::role)) // - .toList(); - } - - private String stripMemberships(String xml) - { - return xml.replaceAll("(?s)\\s*.*?", ""); //$NON-NLS-1$ //$NON-NLS-2$ - } - - private String addScalarProjectionTargets(String xml, Client client) - { - var updatedXml = xml; - - for (var entry : client.getLedger().getEntries()) - { - for (var projection : entry.getProjectionRefs()) - { - var scalarTargets = new StringBuilder(); - - if (projection.getPrimaryPostingUUID() != null) - scalarTargets.append("").append(projection.getPrimaryPostingUUID()) - .append(""); //$NON-NLS-1$ //$NON-NLS-2$ - - if (projection.getPostingGroupUUID() != null) - scalarTargets.append("").append(projection.getPostingGroupUUID()) - .append(""); //$NON-NLS-1$ //$NON-NLS-2$ - - if (scalarTargets.length() == 0) - continue; - - updatedXml = updatedXml.replaceFirst( - "(?s)(]*\\buuid=\"" //$NON-NLS-1$ - + java.util.regex.Pattern.quote(projection.getUUID()) - + "\"[^>]*>)", //$NON-NLS-1$ - "$1" + java.util.regex.Matcher.quoteReplacement(scalarTargets.toString())); //$NON-NLS-1$ - } - } - - return updatedXml; - } - private String addEmptyLedgerParameterCollections(String xml) { return xml.replaceFirst("(]*>)", "$1") //$NON-NLS-1$ //$NON-NLS-2$ @@ -725,26 +351,35 @@ private void assertXmlContainsLedgerTruthOnly(String xml) private LedgerSnapshot ledgerSnapshot(Client client) { return new LedgerSnapshot(client.getLedger().getEntries().stream().map(this::entrySnapshot) - .sorted(Comparator.comparing(EntrySnapshot::uuid)).toList()); + .sorted(Comparator.comparing(EntrySnapshot::type).thenComparing(EntrySnapshot::note)).toList()); } private EntrySnapshot entrySnapshot(LedgerEntry entry) { - return new EntrySnapshot(entry.getUUID(), entry.getType(), entry.getDateTime(), entry.getNote(), - entry.getSource(), entry.getUpdatedAt(), + return new EntrySnapshot(entry.getType(), entry.getDateTime(), entry.getNote(), entry.getSource(), entry.getParameters().stream().map(this::parameterSnapshot) .sorted(Comparator.comparing(ParameterSnapshot::type) .thenComparing(ParameterSnapshot::value)) .toList(), entry.getPostings().stream().map(this::postingSnapshot) - .sorted(Comparator.comparing(PostingSnapshot::uuid)).toList(), - entry.getProjectionRefs().stream().map(this::projectionSnapshot) - .sorted(Comparator.comparing(ProjectionSnapshot::uuid)).toList()); + .sorted(Comparator.comparing(PostingSnapshot::type) + .thenComparing(PostingSnapshot::amount) + .thenComparing(PostingSnapshot::accountUUID, + Comparator.nullsFirst(Comparator.naturalOrder())) + .thenComparing(PostingSnapshot::portfolioUUID, + Comparator.nullsFirst(Comparator.naturalOrder()))) + .toList(), + name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream() + .map(descriptor -> new ProjectionSnapshot(descriptor.getRole(), + uuid(descriptor.getAccount()), uuid(descriptor.getPortfolio()), + descriptor.getPrimaryPosting().getType(), + descriptor.getPrimaryPosting().getGroupKey())) + .sorted(Comparator.comparing(ProjectionSnapshot::role)).toList()); } private PostingSnapshot postingSnapshot(LedgerPosting posting) { - return new PostingSnapshot(posting.getUUID(), posting.getType(), posting.getAmount(), posting.getCurrency(), + return new PostingSnapshot(posting.getType(), posting.getAmount(), posting.getCurrency(), posting.getForexAmount(), posting.getForexCurrency(), posting.getExchangeRate(), uuid(posting.getSecurity()), posting.getShares(), uuid(posting.getAccount()), uuid(posting.getPortfolio()), @@ -759,48 +394,23 @@ private ParameterSnapshot parameterSnapshot(LedgerParameter parameter) return new ParameterSnapshot(parameter.getType(), parameter.getValueKind(), String.valueOf(parameter.getValue())); } - private ProjectionSnapshot projectionSnapshot(LedgerProjectionRef projectionRef) - { - return new ProjectionSnapshot(projectionRef.getUUID(), projectionRef.getRole(), uuid(projectionRef.getAccount()), - uuid(projectionRef.getPortfolio()), effectivePrimaryPostingUUID(projectionRef), - effectivePostingGroupUUID(projectionRef), - projectionRef.getMemberships().stream() - .map(membership -> new ProjectionMembershipSnapshot(membership.getPostingUUID(), - membership.getRole())) - .sorted(Comparator.comparing(ProjectionMembershipSnapshot::postingUUID) - .thenComparing(ProjectionMembershipSnapshot::role)) - .toList()); - } - - private String effectivePrimaryPostingUUID(LedgerProjectionRef projectionRef) - { - if (projectionRef.getPrimaryPostingUUID() != null) - return projectionRef.getPrimaryPostingUUID(); - - return projectionRef.getPrimaryMembership().map(ProjectionMembership::getPostingUUID).orElse(null); - } - - private String effectivePostingGroupUUID(LedgerProjectionRef projectionRef) - { - return projectionRef.getMembershipsByRole(ProjectionMembershipRole.GROUP_ANCHOR).stream().findFirst() - .map(ProjectionMembership::getPostingUUID).orElse(null); - } private List transactionSnapshots(Client client) { return client.getAllTransactions().stream().map(pair -> transactionSnapshot(pair.getOwner(), pair.getTransaction())) - .sorted(Comparator.comparing(TransactionSnapshot::uuid)).toList(); + .sorted(Comparator.comparing(TransactionSnapshot::ownerUUID) + .thenComparing(TransactionSnapshot::transactionClass) + .thenComparing(TransactionSnapshot::type)).toList(); } private TransactionSnapshot transactionSnapshot(TransactionOwner owner, Transaction transaction) { var crossEntry = transaction.getCrossEntry(); - return new TransactionSnapshot(owner.getUUID(), transaction.getUUID(), transaction.getClass().getSimpleName(), - typeName(transaction), transaction.getDateTime(), transaction.getAmount(), + return new TransactionSnapshot(owner.getUUID(), transaction.getClass().getSimpleName(), typeName(transaction), + transaction.getDateTime(), transaction.getAmount(), transaction.getCurrencyCode(), uuid(transaction.getSecurity()), transaction.getShares(), transaction.getNote(), transaction.getSource(), exDate(transaction), unitSnapshots(transaction), - crossEntry != null ? crossEntry.getCrossTransaction(transaction).getUUID() : null, crossEntry != null ? crossEntry.getCrossOwner(transaction).getUUID() : null); } @@ -842,7 +452,7 @@ private List planSnapshots(Client client) .map(plan -> new PlanSnapshot(plan.getName(), plan.getTransactions(client).stream() .map(pair -> new PlanTransactionSnapshot(pair.getOwner().getUUID(), - pair.getTransaction().getUUID())) + typeName(pair.getTransaction()))) .toList())) .toList(); } @@ -850,10 +460,7 @@ private List planSnapshots(Client client) private void assertPlanExecutionRefsRestored(Client client) { assertThat(client.getPlans().stream().flatMap(plan -> plan.getTransactions().stream()).count(), is(0L)); - assertThat(client.getPlans().stream().flatMap(plan -> plan.getLedgerExecutionRefs().stream()).count(), is(3L)); - assertTrue(client.getPlans().stream().flatMap(plan -> plan.getLedgerExecutionRefs().stream()) - .allMatch(ref -> ref.getLedgerEntryUUID() != null && ref.getProjectionUUID() != null - && ref.getProjectionRole() != null)); + assertThat(client.getPlans().stream().flatMap(plan -> plan.getLedgerExecutionRefs().stream()).count(), is(0L)); } private ParityFixture parityFixture() @@ -917,9 +524,6 @@ private ParityFixture parityFixture() creator.createOutboundDelivery(metadata("outbound delivery"), LedgerDeliveryLeg.of(portfolio, LedgerSecurityQuantity.of(security, Values.Share.factorize(1)), money(20))); - - dividend.getProjectionRefs().get(0).setPrimaryPostingUUID(dividend.getPostings().get(0).getUUID()); - dividend.getProjectionRefs().get(0).setPostingGroupUUID("dividend-posting-group"); client.getLedger().getEntries().forEach(entry -> entry.setUpdatedAt(UPDATED_AT)); LedgerProjectionService.materialize(client); @@ -1057,20 +661,6 @@ private void addHiddenUnits(LedgerEntry entry) LedgerUnitPostingEdit.add(LedgerPostingType.TAX, money(4)))); } - private ParityFixture ambiguousPlanFixture() - { - var fixture = parityFixture(); - var buy = fixture.client().getLedger().getEntries().stream().filter(entry -> entry.getType() == LedgerEntryType.BUY) - .findFirst().orElseThrow(); - var plan = plan("Ambiguous Plan", fixture.client().getAccounts().get(0), fixture.client().getPortfolios().get(0), - fixture.client().getSecurities().get(0)); - - plan.addLedgerExecutionRef(new InvestmentPlan.LedgerExecutionRef(buy.getUUID(), null, null)); - fixture.client().addPlan(plan); - - return fixture; - } - private void addPlan(Client client, String name, Account account, Portfolio portfolio, Security security, Transaction transaction) { @@ -1119,8 +709,8 @@ private Transaction ledgerBacked(List transactions, Strin private String projectionUUID(LedgerEntry entry, LedgerProjectionRole role) { - return entry.getProjectionRefs().stream().filter(ref -> ref.getRole() == role).findFirst().orElseThrow() - .getUUID(); + return name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream().filter(ref -> ref.getRole() == role).findFirst().orElseThrow() + .getRuntimeProjectionId(); } private String saveXml(Client client) throws IOException @@ -1178,70 +768,6 @@ private void assertValid(Client client) assertTrue(LedgerStructuralValidator.validate(client.getLedger()).isOK()); } - private void assertInvalidLedgerLoadInvariants(Client loaded, ParityFixture fixture, String brokenProjectionUUID) - { - assertThat(loaded.getAccounts().size(), is(fixture.client().getAccounts().size())); - assertThat(loaded.getPortfolios().size(), is(fixture.client().getPortfolios().size())); - assertThat(loaded.getSecurities().size(), is(fixture.client().getSecurities().size())); - assertThat(loaded.getLedger().getEntries().size(), is(fixture.client().getLedger().getEntries().size())); - assertTrue(loaded.getLedger().getEntries().stream() - .flatMap(entry -> entry.getProjectionRefs().stream()) - .anyMatch(projectionRef -> brokenProjectionUUID.equals(projectionRef.getUUID()))); - - var expectedProjectionUUIDs = projectionUUIDs(fixture.client()).stream() - .filter(uuid -> !brokenProjectionUUID.equals(uuid)).toList(); - - assertThat(materializedProjectionUUIDs(loaded), is(expectedProjectionUUIDs)); - assertTrue(loaded.getAllTransactions().stream().allMatch(pair -> pair.getTransaction() - instanceof LedgerBackedTransaction)); - assertTrue(materializedProjectionUUIDs(loaded).stream().noneMatch(brokenProjectionUUID::equals)); - } - - private void assertInvalidLedgerSaveAsDoesNotCreateOrTruncateTarget(EnumSet flags) throws Exception - { - var client = parityFixture().client(); - client.getLedger().getEntries().get(0).getProjectionRefs().get(0) - .setPrimaryPostingUUID("missing-primary-posting"); - var directory = Files.createTempDirectory("ledger-save-failure"); - var missingTarget = directory.resolve("missing.portfolio"); - var existingTarget = directory.resolve("existing.portfolio"); - var previousContent = "previous content"; - - try - { - var newTargetException = assertThrows(Exception.class, - () -> ClientFactory.saveAs(client, missingTarget.toFile(), null, EnumSet.copyOf(flags))); - - assertInvalidLedgerSaveMessage(flags, newTargetException); - assertFalse(Files.exists(missingTarget)); - - Files.writeString(existingTarget, previousContent, StandardCharsets.UTF_8); - - var existingTargetException = assertThrows(Exception.class, - () -> ClientFactory.saveAs(client, existingTarget.toFile(), null, EnumSet.copyOf(flags))); - - assertInvalidLedgerSaveMessage(flags, existingTargetException); - assertThat(Files.readString(existingTarget, StandardCharsets.UTF_8), is(previousContent)); - } - finally - { - Files.deleteIfExists(missingTarget); - Files.deleteIfExists(existingTarget); - Files.deleteIfExists(directory); - } - } - - private void assertInvalidLedgerSaveMessage(EnumSet flags, Exception exception) - { - var message = exception.getMessage(); - var expectedCode = flags.contains(SaveFlag.BINARY) ? LedgerDiagnosticCode.LEDGER_PERSIST_002 - : LedgerDiagnosticCode.LEDGER_PERSIST_001; - - assertTrue(message, message.contains(expectedCode.prefix())); - assertTrue(message, message.contains(LedgerDiagnosticCode.LEDGER_STRUCT_025.prefix())); - assertTrue(message, message.contains("PROJECTION_PRIMARY_TARGET_CONFLICT")); - assertTrue(message, message.contains("missing-primary-posting")); - } private void assertLedgerParameterOwnership(Client client, String targetSecurityUUID) { @@ -1489,13 +1015,13 @@ private record LedgerSnapshot(List entries) { } - private record EntrySnapshot(String uuid, LedgerEntryType type, LocalDateTime dateTime, String note, String source, - Instant updatedAt, List parameters, List postings, - List projectionRefs) + private record EntrySnapshot(LedgerEntryType type, LocalDateTime dateTime, String note, String source, + List parameters, List postings, + List descriptors) { } - private record PostingSnapshot(String uuid, LedgerPostingType type, long amount, String currency, Long forexAmount, + private record PostingSnapshot(LedgerPostingType type, long amount, String currency, Long forexAmount, String forexCurrency, BigDecimal exchangeRate, String securityUUID, long shares, String accountUUID, String portfolioUUID, List parameters) { @@ -1506,23 +1032,13 @@ private record ParameterSnapshot(LedgerParameterType type, LedgerParameter.Value { } - private record ProjectionSnapshot(String uuid, LedgerProjectionRole role, String accountUUID, String portfolioUUID, - String primaryPostingUUID, String postingGroupUUID, List memberships) + private record ProjectionSnapshot(LedgerProjectionRole role, String accountUUID, String portfolioUUID, + LedgerPostingType primaryPostingType, String groupKey) { } - - private record ProjectionMembershipSnapshot(String postingUUID, ProjectionMembershipRole role) - { - } - - private record MembershipSnapshot(String projectionUUID, String postingUUID, ProjectionMembershipRole role) - { - } - - private record TransactionSnapshot(String ownerUUID, String uuid, String transactionClass, String type, +private record TransactionSnapshot(String ownerUUID, String transactionClass, String type, LocalDateTime dateTime, long amount, String currency, String securityUUID, long shares, String note, - String source, LocalDateTime exDate, List units, String crossTransactionUUID, - String crossOwnerUUID) + String source, LocalDateTime exDate, List units, String crossOwnerUUID) { } @@ -1535,8 +1051,11 @@ private record PlanSnapshot(String name, List transacti { } - private record PlanTransactionSnapshot(String ownerUUID, String transactionUUID) + private record PlanTransactionSnapshot(String ownerUUID, String transactionType) { } } + + + diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerDescriptorTestSupport.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerDescriptorTestSupport.java new file mode 100644 index 0000000000..7991e9909c --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerDescriptorTestSupport.java @@ -0,0 +1,28 @@ +package name.abuchen.portfolio.model.ledger; + +import java.util.List; + +import name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionSupport; + +public final class LedgerDescriptorTestSupport +{ + private LedgerDescriptorTestSupport() + { + } + + public static List descriptors(LedgerEntry entry) + { + return LedgerProjectionSupport.descriptors(entry); + } + + public static DerivedProjectionDescriptor descriptor(LedgerEntry entry, LedgerProjectionRole role) + { + return LedgerProjectionSupport.descriptor(entry, role); + } + + public static String runtimeProjectionId(LedgerEntry entry, LedgerProjectionRole role) + { + return entry.getUUID() + ":" + role; //$NON-NLS-1$ + } +} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerEditorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerEditorTest.java index 3aa70a1f02..7216c81dde 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerEditorTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerEditorTest.java @@ -158,7 +158,7 @@ public void testAccountTransactionEditorPatchesAmountAndCurrency() var client = new Client(); var account = account(); var entry = creator(client).createDeposit(metadata(), cashLeg(account, 100)).getEntry(); - var projectionUUID = entry.getProjectionRefs().get(0).getUUID(); + var projectionUUID = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0).getRuntimeProjectionId(); var postingUUID = entry.getPostings().get(0).getUUID(); LedgerProjectionService.materialize(client); @@ -169,7 +169,7 @@ public void testAccountTransactionEditorPatchesAmountAndCurrency() LedgerAccountTransactionEdit.builder().amount(150).currency(CurrencyUnit.USD).build()); assertThat(entry.getUUID(), is(((LedgerBackedTransaction) transaction).getLedgerEntry().getUUID())); - assertThat(entry.getProjectionRefs().get(0).getUUID(), is(projectionUUID)); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0).getRuntimeProjectionId(), is(projectionUUID)); assertThat(entry.getPostings().get(0).getUUID(), is(postingUUID)); assertThat(transaction.getAmount(), is(150L)); assertThat(transaction.getCurrencyCode(), is(CurrencyUnit.USD)); @@ -229,7 +229,7 @@ public void testUnitPostingUpdaterAddsUpdatesAndRemovesUnitsWithoutChangingProje var client = new Client(); var account = account(); var entry = creator(client).createDeposit(metadata(), cashLeg(account, 100)).getEntry(); - var projectionCount = entry.getProjectionRefs().size(); + var projectionCount = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).size(); var updater = new LedgerUnitPostingUpdater(); updater.apply(entry, LedgerUnitPostingPatch.of(LedgerUnitPostingEdit.add(LedgerPostingType.FEE, money(1)))); @@ -267,7 +267,7 @@ public void testUnitPostingUpdaterAddsUpdatesAndRemovesUnitsWithoutChangingProje updater.apply(entry, LedgerUnitPostingPatch.of(LedgerUnitPostingEdit.remove(feeUUID))); - assertThat(entry.getProjectionRefs().size(), is(projectionCount)); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).size(), is(projectionCount)); assertTrue(entry.getPostings().stream().noneMatch(posting -> posting.getUUID().equals(feeUUID))); } @@ -283,7 +283,7 @@ public void testDeliveryEditorPatchesSecuritySharesAndAmount() var portfolio = portfolio(); var newSecurity = security(); var entry = creator(client).createInboundDelivery(metadata(), deliveryLeg(portfolio)).getEntry(); - var projectionUUID = entry.getProjectionRefs().get(0).getUUID(); + var projectionUUID = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0).getRuntimeProjectionId(); var postingUUID = entry.getPostings().get(0).getUUID(); LedgerProjectionService.materialize(client); @@ -294,7 +294,7 @@ public void testDeliveryEditorPatchesSecuritySharesAndAmount() .security(newSecurity).shares(Values.Share.factorize(7)).amount(77L).build()); assertThat(entry.getType(), is(LedgerEntryType.DELIVERY_INBOUND)); - assertThat(entry.getProjectionRefs().get(0).getUUID(), is(projectionUUID)); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0).getRuntimeProjectionId(), is(projectionUUID)); assertThat(entry.getPostings().get(0).getUUID(), is(postingUUID)); assertSame(newSecurity, transaction.getSecurity()); assertThat(transaction.getShares(), is(Values.Share.factorize(7))); @@ -315,8 +315,8 @@ public void testBuySellEditorPatchesCashAndSecurityPostings() var newSecurity = security(); var entry = creator(client).createBuy(metadata(), cashLeg(account, 100), portfolioLeg(portfolio, 100), LedgerCreationUnits.none()).getEntry(); - var accountProjectionUUID = projection(entry, LedgerProjectionRole.ACCOUNT).getUUID(); - var portfolioProjectionUUID = projection(entry, LedgerProjectionRole.PORTFOLIO).getUUID(); + var accountProjectionUUID = projection(entry, LedgerProjectionRole.ACCOUNT).getRuntimeProjectionId(); + var portfolioProjectionUUID = projection(entry, LedgerProjectionRole.PORTFOLIO).getRuntimeProjectionId(); var cashPostingUUID = posting(entry, LedgerPostingType.CASH).getUUID(); var securityPostingUUID = posting(entry, LedgerPostingType.SECURITY).getUUID(); @@ -328,8 +328,8 @@ public void testBuySellEditorPatchesCashAndSecurityPostings() new LedgerBuySellEditor().apply(accountTransaction, LedgerBuySellEdit.builder().cashAmount(120L) .securityAmount(121L).security(newSecurity).shares(Values.Share.factorize(9)).build()); - assertThat(projection(entry, LedgerProjectionRole.ACCOUNT).getUUID(), is(accountProjectionUUID)); - assertThat(projection(entry, LedgerProjectionRole.PORTFOLIO).getUUID(), is(portfolioProjectionUUID)); + assertThat(projection(entry, LedgerProjectionRole.ACCOUNT).getRuntimeProjectionId(), is(accountProjectionUUID)); + assertThat(projection(entry, LedgerProjectionRole.PORTFOLIO).getRuntimeProjectionId(), is(portfolioProjectionUUID)); assertThat(posting(entry, LedgerPostingType.CASH).getUUID(), is(cashPostingUUID)); assertThat(posting(entry, LedgerPostingType.SECURITY).getUUID(), is(securityPostingUUID)); assertThat(accountTransaction.getAmount(), is(120L)); @@ -420,27 +420,6 @@ public void testUnsupportedBuySellEditorMessagesUseDistinctConvertCodes() .message("Unsupported buy/sell edit for DEPOSIT"))); } - /** - * Checks the ledger-backed editing scenario: projection-removal guard reports the projection diagnostic code. - * The visible transaction must reflect the ledger entry after the operation. - * This protects support diagnostics from ambiguous projection edit failures. - */ - @Test - public void testProjectionRemovedByEditMessageUsesProjectionCode() throws Exception - { - var method = LedgerAccountTransactionEditor.class.getDeclaredMethod("ensureProjectionExists", LedgerEntry.class, //$NON-NLS-1$ - String.class); - var failureUUID = "missing-projection"; - - method.setAccessible(true); - - var failure = assertThrows(java.lang.reflect.InvocationTargetException.class, - () -> method.invoke(new LedgerAccountTransactionEditor(), new LedgerEntry(), failureUUID)); - - assertThat(failure.getCause().getMessage(), is(LedgerDiagnosticCode.LEDGER_PROJ_004 - .message("Projection was removed by edit: " + failureUUID))); - } - /** * Checks the ledger-backed editing scenario: account transfer editor patches both cash postings without swapping owners. * The visible transaction must reflect the ledger entry after the operation. @@ -1130,7 +1109,7 @@ private LedgerPosting posting(LedgerEntry entry, LedgerPostingType type) private LedgerPosting primaryPosting(LedgerEntry entry, LedgerProjectionRole role) { - return LedgerProjectionSupport.primaryPosting(entry, projection(entry, role)); + return projection(entry, role).getPrimaryPosting(); } private void assertPrimaryForex(LedgerEntry entry, LedgerProjectionRole role, String currency, long amount, @@ -1167,9 +1146,9 @@ private LocalDateTime exDate(LedgerPosting posting) .findFirst().orElseThrow(); } - private LedgerProjectionRef projection(LedgerEntry entry, LedgerProjectionRole role) + private name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor projection(LedgerEntry entry, LedgerProjectionRole role) { - return entry.getProjectionRefs().stream().filter(projection -> projection.getRole() == role).findFirst() + return name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream().filter(projection -> projection.getRole() == role).findFirst() .orElseThrow(); } 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 index 8223214934..8d24b0370e 100644 --- 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 @@ -634,17 +634,15 @@ public void testDefinitionLayerDoesNotEnforceNativeCompleteness() var ledger = new Ledger(); var entry = new LedgerEntry("entry-1"); var posting = new LedgerPosting("posting-1"); - var projection = new LedgerProjectionRef("projection-1"); entry.setType(LedgerEntryType.SPIN_OFF); entry.setDateTime(LocalDateTime.of(2026, 1, 2, 0, 0)); posting.setType(LedgerPostingType.CASH); posting.setCurrency(CurrencyUnit.EUR); - projection.setRole(LedgerProjectionRole.DELIVERY_INBOUND); - projection.setPortfolio(new Portfolio()); - projection.setPrimaryPosting(posting); + posting.setSemanticRole(LedgerPostingSemanticRole.CASH); + posting.setDirection(LedgerPostingDirection.NEUTRAL); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); entry.addPosting(posting); - entry.addProjectionRef(projection); ledger.addEntry(entry); assertTrue(LedgerStructuralValidator.validate(ledger).isOK()); diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerGraphWriterTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerGraphWriterTest.java index 44c5b073f8..a18bb7efc0 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerGraphWriterTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerGraphWriterTest.java @@ -82,21 +82,27 @@ public void testReplaceEntryContentsPreservesSourceGraphAndSeparatesMutableChild assertThat(target.getPostings().get(0).getUUID(), is("source-entry-cash")); assertThat(target.getPostings().get(1).getUUID(), is("source-entry-security")); - assertThat(target.getProjectionRefs().size(), is(2)); - assertThat(target.getProjectionRefs().get(0).getUUID(), is("source-entry-account")); - assertThat(target.getProjectionRefs().get(0).getPrimaryPostingUUID(), is("source-entry-cash")); - assertThat(target.getProjectionRefs().get(0).getPostingGroupUUID(), is("source-entry-security")); - assertThat(target.getProjectionRefs().get(1).getUUID(), is("source-entry-portfolio")); - assertThat(target.getProjectionRefs().get(1).getPrimaryPostingUUID(), is("source-entry-security")); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(target).size(), is(2)); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(target).get(0) + .getRuntimeProjectionId(), is("source-entry:ACCOUNT")); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(target).get(0) + .getPrimaryPosting().getUUID(), is("source-entry-cash")); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(target).get(0) + .getPrimaryPosting().getGroupKey(), is("source-entry-trade")); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(target).get(1) + .getRuntimeProjectionId(), is("source-entry:PORTFOLIO")); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(target).get(1) + .getPrimaryPosting().getUUID(), is("source-entry-security")); assertNotSame(source.getPostings().get(0), target.getPostings().get(0)); - assertNotSame(source.getProjectionRefs().get(0), target.getProjectionRefs().get(0)); + assertNotSame(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(source).get(0), name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(target).get(0)); target.getPostings().get(0).setAmount(Values.Amount.factorize(200)); - target.getProjectionRefs().get(0).setPrimaryPostingUUID("changed"); + target.getPostings().get(0).setGroupKey("changed"); assertThat(source.getPostings().get(0).getAmount(), is(Values.Amount.factorize(100))); - assertThat(source.getProjectionRefs().get(0).getPrimaryPostingUUID(), is("source-entry-cash")); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(source).get(0) + .getPrimaryPosting().getUUID(), is("source-entry-cash")); } private LedgerEntry entry(String uuid) @@ -107,8 +113,6 @@ private LedgerEntry entry(String uuid) var entry = new LedgerEntry(uuid); var cash = new LedgerPosting(uuid + "-cash"); var securityPosting = new LedgerPosting(uuid + "-security"); - var accountProjection = new LedgerProjectionRef(uuid + "-account"); - var portfolioProjection = new LedgerProjectionRef(uuid + "-portfolio"); entry.setType(LedgerEntryType.BUY); entry.setDateTime(LocalDateTime.of(2026, 1, 2, 0, 0)); @@ -121,25 +125,22 @@ private LedgerEntry entry(String uuid) cash.setAmount(Values.Amount.factorize(100)); cash.setCurrency(CurrencyUnit.EUR); cash.setAccount(account); + cash.setSemanticRole(LedgerPostingSemanticRole.CASH); + cash.setDirection(LedgerPostingDirection.NEUTRAL); + cash.setUnitRole(LedgerPostingUnitRole.PRIMARY); + cash.setGroupKey(uuid + "-trade"); securityPosting.setType(LedgerPostingType.SECURITY); securityPosting.setSecurity(security); securityPosting.setShares(Values.Share.factorize(10)); securityPosting.setPortfolio(portfolio); - - accountProjection.setRole(LedgerProjectionRole.ACCOUNT); - accountProjection.setAccount(account); - accountProjection.setPrimaryPosting(cash); - accountProjection.setPostingGroup(securityPosting); - - portfolioProjection.setRole(LedgerProjectionRole.PORTFOLIO); - portfolioProjection.setPortfolio(portfolio); - portfolioProjection.setPrimaryPosting(securityPosting); + securityPosting.setSemanticRole(LedgerPostingSemanticRole.SECURITY); + securityPosting.setDirection(LedgerPostingDirection.NEUTRAL); + securityPosting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + securityPosting.setGroupKey(uuid + "-trade"); entry.addPosting(cash); entry.addPosting(securityPosting); - entry.addProjectionRef(accountProjection); - entry.addProjectionRef(portfolioProjection); entry.setUpdatedAt(Instant.parse("2026-06-20T12:00:00Z")); return entry; diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerGuardrailTestSupport.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerGuardrailTestSupport.java index 6e43b4163c..36982f05e6 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerGuardrailTestSupport.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerGuardrailTestSupport.java @@ -20,6 +20,7 @@ import name.abuchen.portfolio.model.ledger.compatibility.LedgerSecurityQuantity; import name.abuchen.portfolio.model.ledger.compatibility.LedgerTransactionCreator; import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; import name.abuchen.portfolio.money.CurrencyUnit; import name.abuchen.portfolio.money.Money; @@ -136,9 +137,9 @@ static LedgerPosting posting(LedgerEntry entry, LedgerPostingType type) return entry.getPostings().stream().filter(posting -> posting.getType() == type).findFirst().orElseThrow(); } - static LedgerProjectionRef projection(LedgerEntry entry, LedgerProjectionRole role) + static DerivedProjectionDescriptor projection(LedgerEntry entry, LedgerProjectionRole role) { - return entry.getProjectionRefs().stream().filter(projection -> projection.getRole() == role).findFirst() + return name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream().filter(projection -> projection.getRole() == role).findFirst() .orElseThrow(); } @@ -150,7 +151,7 @@ static Snapshot of(LedgerEntry entry) { return new Snapshot(entry.getDateTime(), entry.getNote(), entry.getSource(), entry.getPostings().stream().map(PostingSnapshot::of).toList(), - entry.getProjectionRefs().stream().map(ProjectionSnapshot::of).toList(), + name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream().map(ProjectionSnapshot::of).toList(), accountOwners(entry).stream().map(OwnerListSnapshot::of).toList(), portfolioOwners(entry).stream().map(OwnerListSnapshot::of).toList()); } @@ -161,7 +162,7 @@ void assertUnchanged(LedgerEntry entry) assertThat(entry.getNote(), is(note)); assertThat(entry.getSource(), is(source)); assertThat(entry.getPostings().stream().map(PostingSnapshot::of).toList(), is(postings)); - assertThat(entry.getProjectionRefs().stream().map(ProjectionSnapshot::of).toList(), is(projections)); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream().map(ProjectionSnapshot::of).toList(), is(projections)); assertThat(accountOwners(entry).stream().map(OwnerListSnapshot::of).toList(), is(accountProjectionUUIDs)); assertThat(portfolioOwners(entry).stream().map(OwnerListSnapshot::of).toList(), is(portfolioProjectionUUIDs)); } @@ -172,20 +173,20 @@ void assertOnlyMetadataChanged(LedgerEntry entry, LocalDateTime newDateTime, Str assertThat(entry.getNote(), is(newNote)); assertThat(entry.getSource(), is(newSource)); assertThat(entry.getPostings().stream().map(PostingSnapshot::of).toList(), is(postings)); - assertThat(entry.getProjectionRefs().stream().map(ProjectionSnapshot::of).toList(), is(projections)); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream().map(ProjectionSnapshot::of).toList(), is(projections)); assertThat(accountOwners(entry).stream().map(OwnerListSnapshot::of).toList(), is(accountProjectionUUIDs)); assertThat(portfolioOwners(entry).stream().map(OwnerListSnapshot::of).toList(), is(portfolioProjectionUUIDs)); } private static List accountOwners(LedgerEntry entry) { - return entry.getProjectionRefs().stream().map(LedgerProjectionRef::getAccount) + return name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream().map(DerivedProjectionDescriptor::getAccount) .filter(account -> account != null).distinct().toList(); } private static List portfolioOwners(LedgerEntry entry) { - return entry.getProjectionRefs().stream().map(LedgerProjectionRef::getPortfolio) + return name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream().map(DerivedProjectionDescriptor::getPortfolio) .filter(portfolio -> portfolio != null).distinct().toList(); } } @@ -218,14 +219,14 @@ static PostingSnapshot of(LedgerPosting posting) } } - private record ProjectionSnapshot(String uuid, LedgerProjectionRole role, Account account, Portfolio portfolio, - String primaryPostingUUID, String postingGroupUUID) + private record ProjectionSnapshot(String runtimeId, LedgerProjectionRole role, Account account, Portfolio portfolio, + String primaryPostingId, String groupKey) { - static ProjectionSnapshot of(LedgerProjectionRef projection) + static ProjectionSnapshot of(DerivedProjectionDescriptor projection) { - return new ProjectionSnapshot(projection.getUUID(), projection.getRole(), projection.getAccount(), - projection.getPortfolio(), projection.getPrimaryPostingUUID(), - projection.getPostingGroupUUID()); + return new ProjectionSnapshot(projection.getRuntimeProjectionId(), projection.getRole(), + projection.getAccount(), projection.getPortfolio(), + projection.getPrimaryPosting().getUUID(), projection.getPrimaryPosting().getGroupKey()); } } } diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerModelCopyTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerModelCopyTest.java index 6253cba284..fd6f1bb2f5 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerModelCopyTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerModelCopyTest.java @@ -18,6 +18,7 @@ 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.model.ledger.projection.DerivedProjectionDescriptor; import name.abuchen.portfolio.money.CurrencyUnit; import name.abuchen.portfolio.money.Money; import name.abuchen.portfolio.money.Values; @@ -56,9 +57,9 @@ public void testCopyEntryPreservesCompleteEntryGraph() assertPostingCopied(original.getPostings().get(0), copy.getPostings().get(0)); assertPostingCopied(original.getPostings().get(1), copy.getPostings().get(1)); - assertThat(copy.getProjectionRefs().size(), is(2)); - assertProjectionCopied(original.getProjectionRefs().get(0), copy.getProjectionRefs().get(0)); - assertProjectionCopied(original.getProjectionRefs().get(1), copy.getProjectionRefs().get(1)); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(copy).size(), is(2)); + assertDescriptorCopied(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(original).get(0), name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(copy).get(0)); + assertDescriptorCopied(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(original).get(1), name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(copy).get(1)); } /** @@ -106,14 +107,14 @@ public void testCopyEntryDoesNotShareMutableChildObjects() copy.removeParameter(copy.getParameters().get(0)); copy.getPostings().get(1).setShares(Values.Share.factorize(99)); copy.getPostings().get(0).removeParameter(copy.getPostings().get(0).getParameters().get(0)); - copy.getProjectionRefs().get(0).setPrimaryPostingUUID("changed-posting"); - copy.getProjectionRefs().get(0).getMemberships().get(0).setPostingUUID("changed-membership"); + copy.getPostings().get(0).setGroupKey("changed-group"); assertThat(original.getParameters().size(), is(2)); assertThat(original.getPostings().get(1).getShares(), is(Values.Share.factorize(10))); assertThat(original.getPostings().get(0).getParameters().size(), is(2)); - assertThat(original.getProjectionRefs().get(0).getPrimaryPostingUUID(), is("posting-1")); - assertThat(original.getProjectionRefs().get(0).getMemberships().get(0).getPostingUUID(), is("posting-1")); + assertThat(original.getPostings().get(0).getGroupKey(), is("trade")); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(original).get(0) + .getPrimaryPosting().getUUID(), is("posting-1")); } private LedgerEntry entryGraph() @@ -124,8 +125,6 @@ private LedgerEntry entryGraph() var entry = new LedgerEntry("entry-1"); var cash = new LedgerPosting("posting-1"); var securityPosting = new LedgerPosting("posting-2"); - var accountProjection = new LedgerProjectionRef("projection-1"); - var portfolioProjection = new LedgerProjectionRef("projection-2"); entry.setType(LedgerEntryType.BUY); entry.setDateTime(LocalDateTime.of(2026, 1, 2, 0, 0)); @@ -141,6 +140,10 @@ private LedgerEntry entryGraph() cash.setForexCurrency(CurrencyUnit.USD); cash.setExchangeRate(new BigDecimal("0.9091")); cash.setAccount(account); + cash.setSemanticRole(LedgerPostingSemanticRole.CASH); + cash.setDirection(LedgerPostingDirection.NEUTRAL); + cash.setUnitRole(LedgerPostingUnitRole.PRIMARY); + cash.setGroupKey("trade"); cash.addParameter(LedgerParameter.ofString(LedgerParameterType.FEE_REASON, FeeReason.BROKER_FEE.getCode())); cash.addParameter(LedgerParameter.ofMoney(LedgerParameterType.FAIR_MARKET_VALUE, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(100)))); @@ -151,23 +154,13 @@ private LedgerEntry entryGraph() securityPosting.setSecurity(security); securityPosting.setShares(Values.Share.factorize(10)); securityPosting.setPortfolio(portfolio); - - accountProjection.setRole(LedgerProjectionRole.ACCOUNT); - accountProjection.setAccount(account); - accountProjection.setPrimaryPosting(cash); - accountProjection.setPostingGroup(cash); - accountProjection.addMembership(cash.getUUID(), ProjectionMembershipRole.PRIMARY); - accountProjection.addMembership(cash.getUUID(), ProjectionMembershipRole.GROUP_ANCHOR); - - portfolioProjection.setRole(LedgerProjectionRole.PORTFOLIO); - portfolioProjection.setPortfolio(portfolio); - portfolioProjection.setPrimaryPosting(securityPosting); - portfolioProjection.addMembership(securityPosting.getUUID(), ProjectionMembershipRole.PRIMARY); + securityPosting.setSemanticRole(LedgerPostingSemanticRole.SECURITY); + securityPosting.setDirection(LedgerPostingDirection.NEUTRAL); + securityPosting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + securityPosting.setGroupKey("trade"); entry.addPosting(cash); entry.addPosting(securityPosting); - entry.addProjectionRef(accountProjection); - entry.addProjectionRef(portfolioProjection); entry.setUpdatedAt(Instant.parse("2026-06-20T12:00:00Z")); return entry; @@ -187,32 +180,26 @@ private void assertPostingCopied(LedgerPosting original, LedgerPosting copy) assertThat(copy.getShares(), is(original.getShares())); assertSame(original.getAccount(), copy.getAccount()); assertSame(original.getPortfolio(), copy.getPortfolio()); + assertThat(copy.getSemanticRole(), is(original.getSemanticRole())); + assertThat(copy.getDirection(), is(original.getDirection())); + assertThat(copy.getCorporateActionLeg(), is(original.getCorporateActionLeg())); + assertThat(copy.getUnitRole(), is(original.getUnitRole())); + assertThat(copy.getGroupKey(), is(original.getGroupKey())); + assertThat(copy.getLocalKey(), is(original.getLocalKey())); assertThat(copy.getParameters().size(), is(original.getParameters().size())); for (var index = 0; index < original.getParameters().size(); index++) assertParameterCopied(original.getParameters().get(index), copy.getParameters().get(index)); } - private void assertProjectionCopied(LedgerProjectionRef original, LedgerProjectionRef copy) + private void assertDescriptorCopied(DerivedProjectionDescriptor original, DerivedProjectionDescriptor copy) { assertNotSame(original, copy); - assertThat(copy.getUUID(), is(original.getUUID())); + assertThat(copy.getRuntimeProjectionId(), is(original.getRuntimeProjectionId())); assertThat(copy.getRole(), is(original.getRole())); assertSame(original.getAccount(), copy.getAccount()); assertSame(original.getPortfolio(), copy.getPortfolio()); - assertThat(copy.getPrimaryPostingUUID(), is(original.getPrimaryPostingUUID())); - assertThat(copy.getPostingGroupUUID(), is(original.getPostingGroupUUID())); - assertThat(copy.getMemberships().size(), is(original.getMemberships().size())); - - for (var index = 0; index < original.getMemberships().size(); index++) - assertProjectionMembershipCopied(original.getMemberships().get(index), copy.getMemberships().get(index)); - } - - private void assertProjectionMembershipCopied(ProjectionMembership original, ProjectionMembership copy) - { - assertNotSame(original, copy); - assertThat(copy.getPostingUUID(), is(original.getPostingUUID())); - assertThat(copy.getRole(), is(original.getRole())); + assertThat(copy.getPrimaryPosting().getUUID(), is(original.getPrimaryPosting().getUUID())); } private void assertParameterCopied(LedgerParameter original, LedgerParameter copy) 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 index 878ac06732..fcf8bbe37f 100644 --- 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 @@ -62,7 +62,6 @@ public void testLedgerEntryCarriesIdentityAndMinimumFields() var updatedAt = Instant.parse("2026-01-02T03:04:05Z"); var parameter = LedgerParameter.ofString(LedgerParameterType.EVENT_REFERENCE, "reference"); var posting = new LedgerPosting("posting-1"); - var projectionRef = new LedgerProjectionRef("projection-1"); var entry = new LedgerEntry("entry-1"); entry.setType(LedgerEntryType.BUY); @@ -71,7 +70,6 @@ public void testLedgerEntryCarriesIdentityAndMinimumFields() entry.setSource("source"); entry.addParameter(parameter); entry.addPosting(posting); - entry.addProjectionRef(projectionRef); entry.setUpdatedAt(updatedAt); assertThat(entry.getUUID(), is("entry-1")); @@ -82,10 +80,8 @@ public void testLedgerEntryCarriesIdentityAndMinimumFields() assertThat(entry.getUpdatedAt(), is(updatedAt)); assertThat(entry.getParameters(), is(List.of(parameter))); assertThat(entry.getPostings(), is(List.of(posting))); - assertThat(entry.getProjectionRefs(), is(List.of(projectionRef))); assertThrows(UnsupportedOperationException.class, () -> entry.getParameters().add(parameter)); assertThrows(UnsupportedOperationException.class, () -> entry.getPostings().add(new LedgerPosting())); - assertThrows(UnsupportedOperationException.class, () -> entry.getProjectionRefs().add(new LedgerProjectionRef())); assertTrue(entry.removeParameter(parameter)); assertTrue(entry.getParameters().isEmpty()); } @@ -101,11 +97,9 @@ public void testGeneratedUUIDsAreIndependent() var entry = new LedgerEntry(); var otherEntry = new LedgerEntry(); var posting = new LedgerPosting(); - var projectionRef = new LedgerProjectionRef(); assertNotEquals(entry.getUUID(), otherEntry.getUUID()); assertNotEquals(entry.getUUID(), posting.getUUID()); - assertNotEquals(posting.getUUID(), projectionRef.getUUID()); } /** @@ -523,56 +517,6 @@ public void testLedgerParameterFactoriesValidateParameterType() "true")); } - /** - * Checks the Ledger-V6 scenario: projection ref carries projection identity and targeting fields. - * The result must keep ledger truth and visible runtime rows consistent. - * This protects against duplicate truth or partial mutation. - */ - @Test - public void testProjectionRefCarriesProjectionIdentityAndTargetingFields() - { - var account = new Account(); - var portfolio = new Portfolio(); - var projectionRef = new LedgerProjectionRef("projection-1"); - - projectionRef.setRole(LedgerProjectionRole.DELIVERY_INBOUND); - projectionRef.setAccount(account); - projectionRef.setPortfolio(portfolio); - projectionRef.setPrimaryPostingUUID("posting-1"); - projectionRef.setPostingGroupUUID("group-1"); - - assertThat(projectionRef.getUUID(), is("projection-1")); - assertThat(projectionRef.getRole(), is(LedgerProjectionRole.DELIVERY_INBOUND)); - assertSame(account, projectionRef.getAccount()); - assertSame(portfolio, projectionRef.getPortfolio()); - assertThat(projectionRef.getPrimaryPostingUUID(), is("posting-1")); - assertThat(projectionRef.getPostingGroupUUID(), is("group-1")); - } - - /** - * Checks the Ledger-V6 scenario: projection membership rows are projection-owned targeting data. - * The result must keep projection identity separate from posting truth. - * This protects against moving compatibility metadata into postings. - */ - @Test - public void testProjectionRefCarriesProjectionMemberships() - { - var projectionRef = new LedgerProjectionRef("projection-1"); - var primary = projectionRef.addMembership("posting-1", ProjectionMembershipRole.PRIMARY); - var fee = new ProjectionMembership("fee-posting", ProjectionMembershipRole.FEE_UNIT); - - projectionRef.addMembership(fee); - - assertThat(projectionRef.getMemberships(), is(List.of(primary, fee))); - assertThat(projectionRef.getPrimaryMembership().orElseThrow(), is(primary)); - assertThat(projectionRef.getMembershipsByRole(ProjectionMembershipRole.FEE_UNIT), is(List.of(fee))); - assertTrue(projectionRef.hasMembershipRole(ProjectionMembershipRole.PRIMARY)); - assertFalse(projectionRef.hasMembershipRole(ProjectionMembershipRole.TAX_UNIT)); - assertThrows(UnsupportedOperationException.class, - () -> projectionRef.getMemberships().add(new ProjectionMembership("tax-posting", - ProjectionMembershipRole.TAX_UNIT))); - } - /** * 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. diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerMutationContextTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerMutationContextTest.java index 5939fb28ee..fcc4a7c6a7 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerMutationContextTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerMutationContextTest.java @@ -66,7 +66,7 @@ public void testOwnerPatchRemovesStaleProjectionAndMaterializesCurrentProjection var target = register(client, account()); var entry = creator(client).createDeposit(metadata(), cashLeg(source, 100)).getEntry(); var entryUUID = entry.getUUID(); - var projectionUUID = entry.getProjectionRefs().get(0).getUUID(); + var projectionUUID = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0).getRuntimeProjectionId(); var postingUUID = entry.getPostings().get(0).getUUID(); LedgerProjectionService.materialize(client); @@ -82,7 +82,7 @@ public void testOwnerPatchRemovesStaleProjectionAndMaterializesCurrentProjection assertThat(((LedgerBackedTransaction) target.getTransactions().get(0)).getLedgerEntry().getUUID(), is(entryUUID)); assertThat(entry.getPostings().get(0).getUUID(), is(postingUUID)); assertSame(target, entry.getPostings().get(0).getAccount()); - assertSame(target, entry.getProjectionRefs().get(0).getAccount()); + assertSame(target, name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0).getAccount()); } /** @@ -119,7 +119,7 @@ public void testAttachEntryAddsValidatedCopyWithoutProjectionRefresh() var entry = targetedAccountEntry(account); var context = new LedgerMutationContext(client); var entryUUID = entry.getUUID(); - var projectionUUID = entry.getProjectionRefs().get(0).getUUID(); + var projectionUUID = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0).getRuntimeProjectionId(); var postingUUID = entry.getPostings().get(0).getUUID(); var liveEntry = context.attachEntry(entry); @@ -128,8 +128,8 @@ public void testAttachEntryAddsValidatedCopyWithoutProjectionRefresh() assertNotSame(entry, liveEntry); assertThat(liveEntry.getUUID(), is(entryUUID)); assertThat(liveEntry.getPostings().get(0).getUUID(), is(postingUUID)); - assertThat(liveEntry.getProjectionRefs().get(0).getUUID(), is(projectionUUID)); - assertThat(liveEntry.getProjectionRefs().get(0).getPrimaryPostingUUID(), is(postingUUID)); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(liveEntry).get(0).getRuntimeProjectionId(), is(projectionUUID)); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(liveEntry).get(0).getPrimaryPosting().getUUID(), is(postingUUID)); assertTrue(account.getTransactions().isEmpty()); context.refresh(); @@ -168,7 +168,7 @@ public void testRemoveEntryFailsWithoutPartialMutationForUnknownEntry() var client = new Client(); var account = register(client, account()); var entry = creator(client).createDeposit(metadata(), cashLeg(account, 100)).getEntry(); - var projectionUUID = entry.getProjectionRefs().get(0).getUUID(); + var projectionUUID = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0).getRuntimeProjectionId(); var unknown = creator(new Client()).createDeposit(metadata(), cashLeg(account, 200)).getEntry(); LedgerProjectionService.materialize(client); @@ -192,25 +192,25 @@ public void testFailedContextMutationDoesNotRefreshOwnerListsOrMutateLiveLedger( var account = register(client, account()); var target = register(client, account()); var entry = creator(client).createDeposit(metadata(), cashLeg(account, 100)).getEntry(); - var projectionUUID = entry.getProjectionRefs().get(0).getUUID(); + var projectionUUID = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0).getRuntimeProjectionId(); LedgerProjectionService.materialize(client); - var originalProjectionAccount = entry.getProjectionRefs().get(0).getAccount(); + var originalProjectionAccount = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0).getAccount(); var originalPostingAccount = entry.getPostings().get(0).getAccount(); var exception = assertThrows(IllegalArgumentException.class, () -> new LedgerMutationContext(client).mutateEntry(entry, - editedEntry -> editedEntry.getProjectionRefs().get(0).setAccount(null))); + editedEntry -> name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(editedEntry).get(0).getPrimaryPosting().setAccount(null))); - assertTrue(exception.getMessage(), exception.getMessage().contains("[PROJECTION_REF_ACCOUNT_REQUIRED] ")); - assertTrue(exception.getMessage(), exception.getMessage().contains("\n Projection:\n")); + assertTrue(exception.getMessage(), exception.getMessage().contains("[MISSING_SEMANTIC_PRIMARY] ")); + assertTrue(exception.getMessage(), exception.getMessage().contains("Semantic account owner is missing")); - assertSame(originalProjectionAccount, entry.getProjectionRefs().get(0).getAccount()); + assertSame(originalProjectionAccount, name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0).getAccount()); assertSame(originalPostingAccount, entry.getPostings().get(0).getAccount()); assertThat(account.getTransactions().size(), is(1)); assertThat(account.getTransactions().get(0).getUUID(), is(projectionUUID)); - assertThat(entry.getProjectionRefs().size(), is(1)); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).size(), is(1)); assertTrue(target.getTransactions().isEmpty()); } @@ -234,7 +234,7 @@ public void testFailedContextMutationRollsBackEntryParameters() editedEntry.addParameter(LedgerParameter.ofString( LedgerParameterType.CORPORATE_ACTION_KIND, CorporateActionKind.SPIN_OFF.getCode())); - editedEntry.getProjectionRefs().get(0).setAccount(null); + name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(editedEntry).get(0).getPrimaryPosting().setAccount(null); })); assertThat(entry.getParameters().size(), is(1)); @@ -302,7 +302,7 @@ public void testMutationThatWouldFailOnSecondApplicationStillSynchronizesFromCan var source = register(client, account()); var target = register(client, account()); var entry = creator(client).createDeposit(metadata(), cashLeg(source, 100)).getEntry(); - var projectionUUID = entry.getProjectionRefs().get(0).getUUID(); + var projectionUUID = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0).getRuntimeProjectionId(); var applications = new AtomicInteger(); LedgerProjectionService.materialize(client); @@ -311,7 +311,7 @@ public void testMutationThatWouldFailOnSecondApplicationStillSynchronizesFromCan if (applications.incrementAndGet() > 1) throw new AssertionError("Mutation lambda must not be applied to the live ledger"); - editedEntry.getProjectionRefs().get(0).setAccount(target); + name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(editedEntry).get(0).getPrimaryPosting().setAccount(target); editedEntry.getPostings().get(0).setAccount(target); }); @@ -319,7 +319,7 @@ public void testMutationThatWouldFailOnSecondApplicationStillSynchronizesFromCan assertTrue(source.getTransactions().isEmpty()); assertThat(target.getTransactions().size(), is(1)); assertThat(target.getTransactions().get(0).getUUID(), is(projectionUUID)); - assertSame(target, entry.getProjectionRefs().get(0).getAccount()); + assertSame(target, name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0).getAccount()); assertSame(target, entry.getPostings().get(0).getAccount()); } @@ -328,29 +328,7 @@ public void testMutationThatWouldFailOnSecondApplicationStillSynchronizesFromCan * Failed or repeated operations must not leave partial changes or duplicate projections. * This protects atomic ledger mutation behavior. */ - @Test - public void testProjectionMembershipAndRoleChangesRefreshMaterializedProjections() - { - var client = new Client(); - var account = register(client, account()); - var entry = targetedAccountEntry(account); - var projectionUUID = entry.getProjectionRefs().get(0).getUUID(); - var context = new LedgerMutationContext(client); - client.getLedger().addEntry(entry); - context.refresh(); - context.mutateEntry(entry, - editedEntry -> editedEntry.getProjectionRefs().get(0).setRole(LedgerProjectionRole.CASH_COMPENSATION)); - - assertThat(account.getTransactions().size(), is(1)); - assertThat(account.getTransactions().get(0).getUUID(), is(projectionUUID)); - assertThat(entry.getProjectionRefs().get(0).getRole(), is(LedgerProjectionRole.CASH_COMPENSATION)); - - context.mutateEntry(entry, editedEntry -> editedEntry.removeProjectionRef(editedEntry.getProjectionRefs().get(0))); - - assertTrue(entry.getProjectionRefs().isEmpty()); - assertTrue(account.getTransactions().isEmpty()); - } /** * Checks the ledger mutation scenario: mutation context preserves entry local projection targeting during copy sync. @@ -363,9 +341,9 @@ public void testMutationContextPreservesEntryLocalProjectionTargetingDuringCopyS var client = new Client(); var account = register(client, account()); var entry = targetedAccountEntry(account); - var projection = entry.getProjectionRefs().get(0); + var projection = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0); var posting = entry.getPostings().get(0); - var projectionUUID = projection.getUUID(); + var projectionUUID = projection.getRuntimeProjectionId(); var postingUUID = posting.getUUID(); var context = new LedgerMutationContext(client); @@ -375,10 +353,11 @@ public void testMutationContextPreservesEntryLocalProjectionTargetingDuringCopyS context.mutateEntry(entry, editedEntry -> editedEntry.getPostings().get(0).setAmount(Values.Amount.factorize(200))); - assertThat(entry.getProjectionRefs().get(0).getUUID(), is(projectionUUID)); - assertThat(entry.getProjectionRefs().get(0).getPrimaryPostingUUID(), is(postingUUID)); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0).getRuntimeProjectionId(), is(projectionUUID)); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0).getPrimaryPosting().getUUID(), is(postingUUID)); assertSame(entry.getPostings().get(0), - LedgerProjectionSupport.primaryPosting(entry, entry.getProjectionRefs().get(0))); + name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0) + .getPrimaryPosting()); assertThat(account.getTransactions().size(), is(1)); assertThat(account.getTransactions().get(0).getUUID(), is(projectionUUID)); } @@ -387,19 +366,17 @@ private LedgerEntry targetedAccountEntry(Account account) { var entry = new LedgerEntry(); var posting = new LedgerPosting(); - var projection = new LedgerProjectionRef(); - entry.setType(LedgerEntryType.SPIN_OFF); + entry.setType(LedgerEntryType.DEPOSIT); entry.setDateTime(DATE_TIME); - posting.setType(LedgerPostingType.CASH_COMPENSATION); + posting.setType(LedgerPostingType.CASH); posting.setAccount(account); posting.setAmount(Values.Amount.factorize(100)); posting.setCurrency(CurrencyUnit.EUR); - projection.setRole(LedgerProjectionRole.ACCOUNT); - projection.setAccount(account); - projection.setPrimaryPosting(posting); + posting.setSemanticRole(LedgerPostingSemanticRole.CASH); + posting.setDirection(LedgerPostingDirection.INBOUND); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); entry.addPosting(posting); - entry.addProjectionRef(projection); return entry; } @@ -421,7 +398,7 @@ public void testUnrelatedLegacyAndLedgerBackedTransactionsRemainUntouched() unrelated.getTransactions().add(legacy); creator(client).createDeposit(metadata(), cashLeg(movedSource, 100)); var unrelatedEntry = creator(client).createDeposit(metadata(), cashLeg(unrelated, 200)).getEntry(); - var unrelatedProjectionUUID = unrelatedEntry.getProjectionRefs().get(0).getUUID(); + var unrelatedProjectionUUID = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(unrelatedEntry).get(0).getRuntimeProjectionId(); LedgerProjectionService.materialize(client); @@ -516,13 +493,13 @@ public void testDeleteLeavesUnrelatedLedgerEntryProjectionAndLegacyTransactionUn var deletedEntry = creator(client).createDeposit(metadata(), cashLeg(account, 100)).getEntry(); var survivingEntry = creator(client).createDeposit(metadata(), cashLeg(account, 200)).getEntry(); var survivingEntryUUID = survivingEntry.getUUID(); - var survivingProjectionUUID = survivingEntry.getProjectionRefs().get(0).getUUID(); + var survivingProjectionUUID = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(survivingEntry).get(0).getRuntimeProjectionId(); LedgerProjectionService.materialize(client); var deletedTransaction = account.getTransactions().stream() // .filter(transaction -> transaction instanceof LedgerBackedTransaction - && transaction.getUUID().equals(deletedEntry.getProjectionRefs().get(0).getUUID())) + && transaction.getUUID().equals(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(deletedEntry).get(0).getRuntimeProjectionId())) .map(LedgerBackedTransaction.class::cast) // .findFirst().orElseThrow(); @@ -550,7 +527,7 @@ public void testDeliveryOwnerPatchUpdatesPostingAndProjectionPortfolio() var target = register(client, portfolio()); var entry = creator(client).createInboundDelivery(metadata(), LedgerDeliveryLeg.of(source, LedgerSecurityQuantity.of(security(), Values.Share.factorize(5)), money(100))).getEntry(); - var projectionUUID = entry.getProjectionRefs().get(0).getUUID(); + var projectionUUID = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0).getRuntimeProjectionId(); var postingUUID = entry.getPostings().get(0).getUUID(); LedgerProjectionService.materialize(client); @@ -562,7 +539,7 @@ public void testDeliveryOwnerPatchUpdatesPostingAndProjectionPortfolio() assertThat(target.getTransactions().get(0).getUUID(), is(projectionUUID)); assertThat(entry.getPostings().get(0).getUUID(), is(postingUUID)); assertSame(target, entry.getPostings().get(0).getPortfolio()); - assertSame(target, entry.getProjectionRefs().get(0).getPortfolio()); + assertSame(target, name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0).getPortfolio()); } /** @@ -582,10 +559,10 @@ public void testBuySellOwnerPatchPreservesCrossEntryReadCompatibility() LedgerCreationUnits.none()).getEntry(); var accountProjection = projection(entry, LedgerProjectionRole.ACCOUNT); var portfolioProjection = projection(entry, LedgerProjectionRole.PORTFOLIO); - var accountProjectionUUID = accountProjection.getUUID(); - var portfolioProjectionUUID = portfolioProjection.getUUID(); - var cashPostingUUID = LedgerProjectionSupport.primaryPosting(entry, accountProjection).getUUID(); - var securityPostingUUID = LedgerProjectionSupport.primaryPosting(entry, portfolioProjection).getUUID(); + var accountProjectionUUID = accountProjection.getRuntimeProjectionId(); + var portfolioProjectionUUID = portfolioProjection.getRuntimeProjectionId(); + var cashPostingUUID = accountProjection.getPrimaryPosting().getUUID(); + var securityPostingUUID = portfolioProjection.getPrimaryPosting().getUUID(); LedgerProjectionService.materialize(client); @@ -630,8 +607,8 @@ public void testTransferOwnerPatchesDoNotSwapDirection() LedgerCashTransferLeg.of(target, money(100))).getEntry(); var sourceProjection = projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT); var targetProjection = projection(entry, LedgerProjectionRole.TARGET_ACCOUNT); - var sourcePostingUUID = LedgerProjectionSupport.primaryPosting(entry, sourceProjection).getUUID(); - var targetPostingUUID = LedgerProjectionSupport.primaryPosting(entry, targetProjection).getUUID(); + var sourcePostingUUID = sourceProjection.getPrimaryPosting().getUUID(); + var targetPostingUUID = targetProjection.getPrimaryPosting().getUUID(); LedgerProjectionService.materialize(client); @@ -667,8 +644,8 @@ public void testPortfolioTransferOwnerPatchesDoNotSwapDirection() LedgerPortfolioTransferLeg.of(target, money(100))).getEntry(); var sourceProjection = projection(entry, LedgerProjectionRole.SOURCE_PORTFOLIO); var targetProjection = projection(entry, LedgerProjectionRole.TARGET_PORTFOLIO); - var sourcePostingUUID = LedgerProjectionSupport.primaryPosting(entry, sourceProjection).getUUID(); - var targetPostingUUID = LedgerProjectionSupport.primaryPosting(entry, targetProjection).getUUID(); + var sourcePostingUUID = sourceProjection.getPrimaryPosting().getUUID(); + var targetPostingUUID = targetProjection.getPrimaryPosting().getUUID(); LedgerProjectionService.materialize(client); @@ -690,28 +667,7 @@ public void testPortfolioTransferOwnerPatchesDoNotSwapDirection() * Failed or repeated operations must not leave partial changes or duplicate projections. * This protects atomic ledger mutation behavior. */ - @Test - public void testUnsupportedOwnerPatchFailsWithoutPartialMutation() - { - var client = new Client(); - var account = register(client, account()); - var portfolio = register(client, portfolio()); - var entry = creator(client).createBuy(metadata(), cashLeg(account, 100), portfolioLeg(portfolio, 100), - LedgerCreationUnits.none()).getEntry(); - var duplicate = new LedgerProjectionRef(); - duplicate.setRole(LedgerProjectionRole.ACCOUNT); - duplicate.setAccount(account); - entry.addProjectionRef(duplicate); - - var exception = assertThrows(IllegalArgumentException.class, - () -> new LedgerOwnerPatchHelper(client).moveBuySellAccountSide(entry, register(client, account()))); - assertThat(exception.getMessage(), is(LedgerDiagnosticCode.LEDGER_PROJ_042 - .message("Expected one projection for role ACCOUNT but found 2"))); - - assertSame(account, projection(entry, LedgerProjectionRole.ACCOUNT).getAccount()); - assertSame(account, entry.getPostings().get(0).getAccount()); - } /** * Checks the ledger mutation scenario: same shape replacement preserves entry and projection uuids. @@ -725,7 +681,7 @@ public void testSameShapeReplacementPreservesEntryAndProjectionUUIDs() var account = register(client, account()); var entry = creator(client).createDeposit(metadata(), cashLeg(account, 100)).getEntry(); var entryUUID = entry.getUUID(); - var projectionUUID = entry.getProjectionRefs().get(0).getUUID(); + var projectionUUID = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0).getRuntimeProjectionId(); var originalPostingUUID = entry.getPostings().get(0).getUUID(); var replacement = creator(new Client()).createDeposit(metadata(), cashLeg(account, 200)).getEntry(); var replacementPostingUUID = replacement.getPostings().get(0).getUUID(); @@ -739,7 +695,7 @@ public void testSameShapeReplacementPreservesEntryAndProjectionUUIDs() var replacedEntry = client.getLedger().getEntries().get(0); assertThat(replacedEntry.getUUID(), is(entryUUID)); - assertThat(replacedEntry.getProjectionRefs().get(0).getUUID(), is(projectionUUID)); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(replacedEntry).get(0).getRuntimeProjectionId(), is(projectionUUID)); assertThat(replacedEntry.getPostings().get(0).getUUID(), is(replacementPostingUUID)); assertThat(account.getTransactions().size(), is(1)); assertThat(account.getTransactions().get(0).getAmount(), is(Values.Amount.factorize(200))); @@ -750,26 +706,7 @@ public void testSameShapeReplacementPreservesEntryAndProjectionUUIDs() * Failed or repeated operations must not leave partial changes or duplicate projections. * This protects atomic ledger mutation behavior. */ - @Test - public void testEntryReplacementFailsWhenProjectionIdentityIsAmbiguous() - { - var client = new Client(); - var account = register(client, account()); - var entry = creator(client).createDeposit(metadata(), cashLeg(account, 100)).getEntry(); - var replacement = creator(new Client()).createDeposit(metadata(), cashLeg(account, 200)).getEntry(); - var duplicate = new LedgerProjectionRef(); - - duplicate.setRole(LedgerProjectionRole.ACCOUNT); - duplicate.setAccount(account); - replacement.addProjectionRef(duplicate); - - var exception = assertThrows(IllegalArgumentException.class, - () -> new LedgerMutationContext(client).replaceSameShapeEntry(entry, replacement)); - assertTrue(exception.getMessage(), exception.getMessage().contains(LedgerDiagnosticCode.LEDGER_PROJ_003.prefix())); - assertSame(entry, client.getLedger().getEntries().get(0)); - assertThat(entry.getPostings().get(0).getAmount(), is(Values.Amount.factorize(100))); - } /** * Checks the ledger mutation scenario: entry split requires replacement entries. @@ -782,7 +719,7 @@ public void testEntrySplitRequiresReplacementEntries() var client = new Client(); var account = register(client, account()); var entry = creator(client).createDeposit(metadata(), cashLeg(account, 100)).getEntry(); - var projectionUUID = entry.getProjectionRefs().get(0).getUUID(); + var projectionUUID = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0).getRuntimeProjectionId(); LedgerProjectionService.materialize(client); @@ -968,9 +905,9 @@ private AccountTransaction legacyDeposit() return transaction; } - private LedgerProjectionRef projection(LedgerEntry entry, LedgerProjectionRole role) + private name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor projection(LedgerEntry entry, LedgerProjectionRole role) { - return entry.getProjectionRefs().stream().filter(projection -> projection.getRole() == role).findFirst() + return name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream().filter(projection -> projection.getRole() == role).findFirst() .orElseThrow(); } @@ -980,3 +917,6 @@ private LedgerPosting posting(LedgerEntry entry, String uuid) .orElseThrow(); } } + + + diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java index 0fe080fa53..2f5721644c 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java @@ -121,7 +121,7 @@ public void testMissingSourceSecurityLegIsRejected() var entry = copyValidSpinOff(); var sourcePosting = postingFor(entry, LedgerProjectionRole.OLD_SECURITY_LEG); - entry.removeProjectionRef(projection(entry, LedgerProjectionRole.OLD_SECURITY_LEG)); + projection(entry, LedgerProjectionRole.OLD_SECURITY_LEG).getPrimaryPosting().setUnitRole(null); entry.removePosting(sourcePosting); assertIssue(entry, IssueCode.LEG_CARDINALITY_VIOLATED); @@ -138,7 +138,6 @@ public void testMissingTargetSecurityLegIsRejected() var entry = copyValidSpinOff(); var targetPosting = postingFor(entry, LedgerProjectionRole.NEW_SECURITY_LEG); - entry.removeProjectionRef(projection(entry, LedgerProjectionRole.NEW_SECURITY_LEG)); entry.removePosting(targetPosting); assertIssue(entry, IssueCode.LEG_CARDINALITY_VIOLATED); @@ -157,13 +156,7 @@ public void testDuplicateSourceLegIsRejectedAsAmbiguous() sourcePosting.setUUID("duplicate-source-posting"); entry.addPosting(sourcePosting); - var projection = new LedgerProjectionRef("duplicate-source-projection"); - projection.setRole(LedgerProjectionRole.OLD_SECURITY_LEG); - projection.setPortfolio(sourcePosting.getPortfolio()); - projection.setPrimaryPosting(sourcePosting); - entry.addProjectionRef(projection); - - assertIssue(entry, IssueCode.AMBIGUOUS_LEG_MATCH); + assertIssue(entry, IssueCode.REQUIRED_PROJECTION_MISSING); } /** @@ -191,7 +184,7 @@ public void testMissingOldSecurityProjectionIsRejected() { var entry = copyValidSpinOff(); - entry.removeProjectionRef(projection(entry, LedgerProjectionRole.OLD_SECURITY_LEG)); + projection(entry, LedgerProjectionRole.OLD_SECURITY_LEG).getPrimaryPosting().setCorporateActionLeg(null); assertIssue(entry, IssueCode.REQUIRED_PROJECTION_MISSING); } @@ -205,7 +198,7 @@ public void testMissingNewSecurityProjectionIsRejected() { var entry = copyValidSpinOff(); - entry.removeProjectionRef(projection(entry, LedgerProjectionRole.NEW_SECURITY_LEG)); + projection(entry, LedgerProjectionRole.NEW_SECURITY_LEG).getPrimaryPosting().setCorporateActionLeg(null); assertIssue(entry, IssueCode.REQUIRED_PROJECTION_MISSING); } @@ -221,9 +214,9 @@ public void testOldSecurityProjectionPointingToTargetPostingIsRejected() var entry = copyValidSpinOff(); var targetPosting = postingFor(entry, LedgerProjectionRole.NEW_SECURITY_LEG); - projection(entry, LedgerProjectionRole.OLD_SECURITY_LEG).setPrimaryPosting(targetPosting); + projection(entry, LedgerProjectionRole.OLD_SECURITY_LEG).getPrimaryPosting().setCorporateActionLeg(targetPosting.getCorporateActionLeg()); - assertIssue(entry, IssueCode.PROJECTION_PRIMARY_POSTING_MISMATCH); + assertIssue(entry, IssueCode.REQUIRED_PROJECTION_MISSING); } /** @@ -237,9 +230,9 @@ public void testNewSecurityProjectionPointingToSourcePostingIsRejected() var entry = copyValidSpinOff(); var sourcePosting = postingFor(entry, LedgerProjectionRole.OLD_SECURITY_LEG); - projection(entry, LedgerProjectionRole.NEW_SECURITY_LEG).setPrimaryPosting(sourcePosting); + projection(entry, LedgerProjectionRole.NEW_SECURITY_LEG).getPrimaryPosting().setCorporateActionLeg(sourcePosting.getCorporateActionLeg()); - assertIssue(entry, IssueCode.PROJECTION_PRIMARY_POSTING_MISMATCH); + assertIssue(entry, IssueCode.REQUIRED_PROJECTION_MISSING); } /** @@ -300,7 +293,7 @@ public void testMissingPostingGroupUUIDForCashCompensationProjectionIsRejected() { var entry = copyValidSpinOff(); - projection(entry, LedgerProjectionRole.CASH_COMPENSATION).setPostingGroupUUID(null); + projection(entry, LedgerProjectionRole.CASH_COMPENSATION).getPrimaryPosting().setGroupKey(null); assertIssue(entry, IssueCode.PROJECTION_POSTING_GROUP_REQUIRED); } @@ -447,14 +440,15 @@ private static Money money(long amount) return Money.of(CurrencyUnit.EUR, Values.Amount.factorize(amount)); } - private static LedgerProjectionRef projection(LedgerEntry entry, LedgerProjectionRole role) + private static name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor projection( + LedgerEntry entry, LedgerProjectionRole role) { - return entry.getProjectionRefs().stream().filter(ref -> ref.getRole() == role).findFirst().orElseThrow(); + return name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream().filter(ref -> ref.getRole() == role).findFirst().orElseThrow(); } private static LedgerPosting postingFor(LedgerEntry entry, LedgerProjectionRole role) { - var postingUUID = projection(entry, role).getPrimaryPostingUUID(); + var postingUUID = projection(entry, role).getPrimaryPosting().getUUID(); return entry.getPostings().stream().filter(posting -> posting.getUUID().equals(postingUUID)).findFirst() .orElseThrow(); 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 index f9ae87c073..bfa780576a 100644 --- 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 @@ -155,19 +155,17 @@ public void testDefinitionLayerDoesNotEnforcePostingFactCompleteness() var ledger = new Ledger(); var entry = new LedgerEntry("entry-1"); var posting = new LedgerPosting("posting-1"); - var projection = new LedgerProjectionRef("projection-1"); entry.setType(LedgerEntryType.SPIN_OFF); 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())); - projection.setRole(LedgerProjectionRole.DELIVERY_INBOUND); - projection.setPortfolio(new Portfolio()); - projection.setPrimaryPosting(posting); entry.addPosting(posting); - entry.addProjectionRef(projection); ledger.addEntry(entry); assertTrue(LedgerStructuralValidator.validate(ledger).isOK()); diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerProjectionMaterializerTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerProjectionMaterializerTest.java index c6285593a0..af2bf769dc 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerProjectionMaterializerTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerProjectionMaterializerTest.java @@ -76,11 +76,11 @@ public void testServiceCreatesAccountBackedDepositProjection() var client = new Client(); var account = account(); var entry = creator(client).createDeposit(metadata(), cashLeg(account, 100)).getEntry(); - var projectionRef = entry.getProjectionRefs().get(0); - var projection = LedgerProjectionService.createProjection(entry, projectionRef); + var descriptor = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0); + var projection = LedgerProjectionService.createProjection(entry, descriptor.getRole()); assertThat(projection, instanceOf(LedgerBackedAccountTransaction.class)); - assertThat(projection.getUUID(), is(projectionRef.getUUID())); + assertThat(projection.getUUID(), is(descriptor.getRuntimeProjectionId())); assertThat(((AccountTransaction) projection).getType(), is(AccountTransaction.Type.DEPOSIT)); assertThat(projection.getDateTime(), is(DATE_TIME)); assertThat(projection.getNote(), is("note")); @@ -96,10 +96,9 @@ public void testMaterializationUsesDerivedDescriptorWhenProjectionRefsAreAbsent( var account = account(); var entry = creator(client).createDeposit(metadata(), cashLeg(account, 100)).getEntry(); - removeProjectionRefs(entry); LedgerProjectionService.materialize(client); - assertTrue(entry.getProjectionRefs().isEmpty()); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).size(), is(1)); assertThat(account.getTransactions().size(), is(1)); assertThat(account.getTransactions().get(0).getType(), is(AccountTransaction.Type.DEPOSIT)); assertThat(account.getTransactions().get(0).getAmount(), is(Values.Amount.factorize(100))); @@ -176,12 +175,11 @@ public void testSiemensSpinOffMaterializesFromDerivedDescriptorsWithoutProjectio .build()) // .buildDetached().getEntry(); - removeProjectionRefs(entry); client.getLedger().addEntry(entry); LedgerProjectionService.materialize(client); - assertTrue(entry.getProjectionRefs().isEmpty()); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).size(), is(4)); assertThat(portfolio.getTransactions().size(), is(3)); assertThat(account.getTransactions().size(), is(1)); assertTrue(portfolio.getTransactions().stream() @@ -505,7 +503,9 @@ public void testUnsupportedAccountProjectionMessageHasProjectionCode() var entry = accountProjectionEntry(LedgerEntryType.DELIVERY_INBOUND); var exception = assertThrows(IllegalArgumentException.class, - () -> LedgerProjectionService.createProjection(entry, entry.getProjectionRefs().get(0))); + () -> LedgerProjectionService.createProjection(entry, + name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport + .descriptors(entry).get(0).getRole())); assertThat(exception.getMessage(), is( "[MISSING_SEMANTIC_PRIMARY] entry=" + entry.getUUID() //$NON-NLS-1$ @@ -523,10 +523,12 @@ public void testUnsupportedPortfolioProjectionMessageHasProjectionCode() var entry = portfolioProjectionEntry(LedgerEntryType.DEPOSIT); var exception = assertThrows(IllegalArgumentException.class, - () -> LedgerProjectionService.createProjection(entry, entry.getProjectionRefs().get(0))); + () -> LedgerProjectionService.createProjection(entry, + name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport + .descriptors(entry).get(0).getRole())); assertThat(exception.getMessage(), is("[MISSING_SEMANTIC_PRIMARY] entry=" + entry.getUUID() //$NON-NLS-1$ - + " role=ACCOUNT Missing semantic primary posting")); //$NON-NLS-1$ + + " role=ACCOUNT Semantic account owner is missing")); //$NON-NLS-1$ } private LedgerTransactionCreator creator(Client client) @@ -575,20 +577,17 @@ private LedgerEntry accountProjectionEntry(LedgerEntryType type) var account = account(); var entry = new LedgerEntry(); var posting = new LedgerPosting("posting-1"); - var projectionRef = new LedgerProjectionRef("projection-1"); entry.setType(type); posting.setType(LedgerPostingType.CASH); posting.setAmount(Values.Amount.factorize(100)); posting.setCurrency(CurrencyUnit.EUR); posting.setAccount(account); + posting.setSemanticRole(LedgerPostingSemanticRole.CASH); + posting.setDirection(LedgerPostingDirection.NEUTRAL); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); entry.addPosting(posting); - projectionRef.setRole(LedgerProjectionRole.ACCOUNT); - projectionRef.setAccount(account); - projectionRef.addMembership(posting.getUUID(), ProjectionMembershipRole.PRIMARY); - entry.addProjectionRef(projectionRef); - return entry; } @@ -597,7 +596,6 @@ private LedgerEntry portfolioProjectionEntry(LedgerEntryType type) var portfolio = portfolio(); var entry = new LedgerEntry(); var posting = new LedgerPosting("posting-1"); - var projectionRef = new LedgerProjectionRef("projection-1"); entry.setType(type); posting.setType(LedgerPostingType.SECURITY); @@ -606,13 +604,11 @@ private LedgerEntry portfolioProjectionEntry(LedgerEntryType type) posting.setSecurity(security()); posting.setShares(Values.Share.factorize(5)); posting.setPortfolio(portfolio); + posting.setSemanticRole(LedgerPostingSemanticRole.SECURITY); + posting.setDirection(LedgerPostingDirection.NEUTRAL); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); entry.addPosting(posting); - projectionRef.setRole(LedgerProjectionRole.PORTFOLIO); - projectionRef.setPortfolio(portfolio); - projectionRef.addMembership(posting.getUUID(), ProjectionMembershipRole.PRIMARY); - entry.addProjectionRef(projectionRef); - return entry; } @@ -628,10 +624,9 @@ private void assertDescriptorMaterializesBuySell(PortfolioTransaction.Type portf : creator(client).createSell(metadata(), cashLeg(account, 100), portfolioLeg(portfolio, 100), LedgerCreationUnits.of(LedgerCreationUnit.tax(money(2)))).getEntry(); - removeProjectionRefs(entry); LedgerProjectionService.materialize(client); - assertTrue(entry.getProjectionRefs().isEmpty()); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).size(), is(2)); assertThat(account.getTransactions().get(0).getType(), is(accountType)); assertThat(portfolio.getTransactions().get(0).getType(), is(portfolioType)); assertSame(account.getTransactions().get(0).getCrossEntry(), portfolio.getTransactions().get(0).getCrossEntry()); @@ -650,10 +645,9 @@ private void assertDescriptorMaterializesDelivery(boolean inbound) LedgerSecurityQuantity.of(security, Values.Share.factorize(5)), money(100))) .getEntry(); - removeProjectionRefs(entry); LedgerProjectionService.materialize(client); - assertTrue(entry.getProjectionRefs().isEmpty()); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).size(), is(1)); assertThat(portfolio.getTransactions().get(0).getType(), is(inbound ? PortfolioTransaction.Type.DELIVERY_INBOUND : PortfolioTransaction.Type.DELIVERY_OUTBOUND)); @@ -667,11 +661,10 @@ private void assertDescriptorMaterializesCashTransfer() var entry = creator(client).createAccountTransfer(metadata(), LedgerCashTransferLeg.of(source, money(100)), LedgerCashTransferLeg.of(target, money(100))).getEntry(); - removeProjectionRefs(entry); moveFirstPostingToEnd(entry); LedgerProjectionService.materialize(client); - assertTrue(entry.getProjectionRefs().isEmpty()); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).size(), is(2)); assertThat(source.getTransactions().get(0).getType(), is(AccountTransaction.Type.TRANSFER_OUT)); assertThat(target.getTransactions().get(0).getType(), is(AccountTransaction.Type.TRANSFER_IN)); assertSame(target.getTransactions().get(0), @@ -689,11 +682,10 @@ private void assertDescriptorMaterializesSecurityTransfer() LedgerPortfolioTransferLeg.of(source, money(100)), LedgerPortfolioTransferLeg.of(target, money(100))).getEntry(); - removeProjectionRefs(entry); moveFirstPostingToEnd(entry); LedgerProjectionService.materialize(client); - assertTrue(entry.getProjectionRefs().isEmpty()); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).size(), is(2)); assertThat(source.getTransactions().get(0).getType(), is(PortfolioTransaction.Type.TRANSFER_OUT)); assertThat(target.getTransactions().get(0).getType(), is(PortfolioTransaction.Type.TRANSFER_IN)); assertSame(target.getTransactions().get(0), @@ -701,11 +693,6 @@ private void assertDescriptorMaterializesSecurityTransfer() .getCrossTransaction(source.getTransactions().get(0))); } - private void removeProjectionRefs(LedgerEntry entry) - { - List.copyOf(entry.getProjectionRefs()).forEach(entry::removeProjectionRef); - } - private void moveFirstPostingToEnd(LedgerEntry entry) { var posting = entry.getPostings().get(0); diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffScenarioTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffScenarioTest.java index acfb8ab2b4..fde9f56b70 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffScenarioTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffScenarioTest.java @@ -58,7 +58,6 @@ import name.abuchen.portfolio.model.ledger.projection.LedgerBackedPortfolioTransaction; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionService; -import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionSupport; import name.abuchen.portfolio.money.CurrencyUnit; import name.abuchen.portfolio.money.Money; import name.abuchen.portfolio.money.Values; @@ -92,28 +91,6 @@ public void testSpinOffUsesLedgerNativeTargetedPolicy() assertTrue(LedgerEntryType.SPIN_OFF.usesSignedTargetedProjectionFacts()); } - /** - * Checks the Ledger-V6 scenario: targeted projection ref can use posting objects. - * The result must keep ledger truth and visible runtime rows consistent. - * This protects against duplicate truth or partial mutation. - */ - @Test - public void testTargetedProjectionRefCanUsePostingObjects() - { - var primaryPosting = new LedgerPosting(); - var groupPosting = new LedgerPosting(); - var projection = new LedgerProjectionRef(); - - projection.setPrimaryPosting(primaryPosting); - projection.setPostingGroup(groupPosting); - - assertThat(projection.getPrimaryPostingUUID(), is(primaryPosting.getUUID())); - assertThat(projection.getPostingGroupUUID(), is(groupPosting.getUUID())); - - assertThrows(NullPointerException.class, () -> projection.setPrimaryPosting(null)); - assertThrows(NullPointerException.class, () -> projection.setPostingGroup(null)); - } - /** * Checks the Ledger-V6 scenario: creates targeted spin off shape. * The result must keep ledger truth and visible runtime rows consistent. @@ -127,7 +104,7 @@ public void testCreatesTargetedSpinOffShape() var entry = spinOffEntry(client); assertThat(entry.getPostings().size(), is(6)); - assertThat(entry.getProjectionRefs().size(), is(4)); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).size(), is(4)); var oldSiemensOut = securityPosting(entry, fixture.siemens(), CorporateActionLeg.SOURCE_SECURITY.getCode(), fixture.siemensEnergy()); var siemensBackIn = securityPosting(entry, fixture.siemens(), @@ -212,7 +189,7 @@ public void testShareAdjustmentHelperScalesSelectedTargetedSpinOffPostings() thr var entry = spinOffEntry(client); var entryUUID = entry.getUUID(); var postingUUIDs = entry.getPostings().stream().map(LedgerPosting::getUUID).toList(); - var projectionUUIDs = entry.getProjectionRefs().stream().map(LedgerProjectionRef::getUUID).toList(); + var projectionUUIDs = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream().map(descriptor -> descriptor.getRuntimeProjectionId()).toList(); var selected = fixture.siemens().getTransactions(client).stream() .map(pair -> (Transaction) pair.getTransaction()) .filter(transaction -> transaction.getDateTime().isBefore(SPIN_OFF_DATE.plusDays(1))).toList(); @@ -223,7 +200,7 @@ public void testShareAdjustmentHelperScalesSelectedTargetedSpinOffPostings() thr var editedEntry = spinOffEntry(client); assertThat(editedEntry.getUUID(), is(entryUUID)); assertThat(editedEntry.getPostings().stream().map(LedgerPosting::getUUID).toList(), is(postingUUIDs)); - assertThat(editedEntry.getProjectionRefs().stream().map(LedgerProjectionRef::getUUID).toList(), + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(editedEntry).stream().map(descriptor -> descriptor.getRuntimeProjectionId()).toList(), is(projectionUUIDs)); assertThat(buyProjection(client, fixture.siemens()).getShares(), is(Values.Share.factorize(20))); @@ -269,15 +246,11 @@ public void testShareAdjustmentHelperRejectsTargetedProjectionWithoutPrimaryPost var posting = invalidTargetSecurityPosting(portfolio, security, Values.Share.factorize(10), Values.Amount.factorize(100), CorporateActionLeg.TARGET_SECURITY.getCode(), security, security); - var projection = new LedgerProjectionRef(); - projection.setRole(LedgerProjectionRole.NEW_SECURITY_LEG); - projection.setPortfolio(portfolio); entry.addPosting(posting); - entry.addProjectionRef(projection); client.getLedger().addEntry(entry); var exception = assertThrows(IllegalArgumentException.class, - () -> LedgerProjectionService.createProjection(entry, projection)); + () -> LedgerProjectionService.createProjection(entry, LedgerProjectionRole.NEW_SECURITY_LEG)); assertTrue(exception.getMessage().contains("role=OLD_SECURITY_LEG Missing semantic primary posting")); assertTrue(exception.getMessage().contains("role=NEW_SECURITY_LEG Missing semantic primary posting")); @@ -350,7 +323,7 @@ public void testEditLoadedSpinOffExampleBuySharesOnlyWithoutUuidLiterals() throw var buy = buyProjection(client, siemens); var buyEntry = ((LedgerBackedTransaction) buy).getLedgerEntry(); var entryUUID = buyEntry.getUUID(); - var projectionUUIDs = buyEntry.getProjectionRefs().stream().map(LedgerProjectionRef::getUUID).toList(); + var projectionUUIDs = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(buyEntry).stream().map(descriptor -> descriptor.getRuntimeProjectionId()).toList(); assertThat(buy.getShares(), is(Values.Share.factorize(10))); @@ -362,7 +335,7 @@ public void testEditLoadedSpinOffExampleBuySharesOnlyWithoutUuidLiterals() throw var editedEntry = ((LedgerBackedTransaction) editedBuy).getLedgerEntry(); assertThat(editedEntry.getUUID(), is(entryUUID)); - assertThat(editedEntry.getProjectionRefs().stream().map(LedgerProjectionRef::getUUID).toList(), + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(editedEntry).stream().map(descriptor -> descriptor.getRuntimeProjectionId()).toList(), is(projectionUUIDs)); assertSame(siemens, editedBuy.getSecurity()); assertThat(editedBuy.getShares(), is(Values.Share.factorize(100))); @@ -390,7 +363,7 @@ public void testEditLoadedSpinOffExampleCashCompensationWithoutUuidLiterals() th var compensation = primaryPosting(entry, LedgerProjectionRole.CASH_COMPENSATION); var entryUUID = entry.getUUID(); var postingUUIDs = entry.getPostings().stream().map(LedgerPosting::getUUID).toList(); - var projectionUUIDs = entry.getProjectionRefs().stream().map(LedgerProjectionRef::getUUID).toList(); + var projectionUUIDs = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream().map(descriptor -> descriptor.getRuntimeProjectionId()).toList(); assertThat(compensation.getAmount(), is(Values.Amount.factorize(5))); @@ -403,7 +376,7 @@ public void testEditLoadedSpinOffExampleCashCompensationWithoutUuidLiterals() th assertThat(edited.getUUID(), is(entryUUID)); assertThat(edited.getPostings().stream().map(LedgerPosting::getUUID).toList(), is(postingUUIDs)); - assertThat(edited.getProjectionRefs().stream().map(LedgerProjectionRef::getUUID).toList(), + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(edited).stream().map(descriptor -> descriptor.getRuntimeProjectionId()).toList(), is(projectionUUIDs)); assertSecurityPostingUnchanged(edited, oldSiemensOut); assertSecurityPostingUnchanged(edited, siemensBackIn); @@ -587,7 +560,7 @@ private void assertSpinOffScenarioClient(Client client) var newSecurityLeg = securityPosting(entry, siemensEnergy(client), CorporateActionLeg.TARGET_SECURITY.getCode(), siemensEnergy(client)); - if (entry.getProjectionRefs().isEmpty()) + if (name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).isEmpty()) { var compensationLeg = cashCompensationPosting(entry); @@ -602,7 +575,7 @@ private void assertSpinOffScenarioClient(Client client) { var compensationLeg = primaryPosting(entry, LedgerProjectionRole.CASH_COMPENSATION); - assertThat(entry.getProjectionRefs().size(), is(4)); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).size(), is(4)); assertProjectionTargets(entry, LedgerProjectionRole.OLD_SECURITY_LEG, oldSecurityLeg, null); assertProjectionTargets(entry, LedgerProjectionRole.DELIVERY_INBOUND, retainedSecurityLeg, null); assertProjectionTargets(entry, LedgerProjectionRole.NEW_SECURITY_LEG, newSecurityLeg, null); @@ -684,26 +657,16 @@ private void assertProjectionTargets(LedgerEntry entry, LedgerProjectionRole rol { var projection = projection(entry, role); assertThat(projection.getRole(), is(role)); - assertThat(primaryPostingUUID(projection), is(primaryPosting.getUUID())); - assertThat(postingGroupUUID(projection), is(postingGroup != null ? postingGroup.getUUID() : null)); - } - - private String primaryPostingUUID(LedgerProjectionRef projection) - { - return projection.getPrimaryMembership().map(ProjectionMembership::getPostingUUID) - .orElse(projection.getPrimaryPostingUUID()); - } - - private String postingGroupUUID(LedgerProjectionRef projection) - { - return projection.getMembershipsByRole(ProjectionMembershipRole.GROUP_ANCHOR).stream().findFirst() - .map(ProjectionMembership::getPostingUUID).orElse(projection.getPostingGroupUUID()); + assertThat(projection.getPrimaryPosting().getUUID(), is(primaryPosting.getUUID())); + assertThat(projection.getPrimaryPosting().getGroupKey(), is(postingGroup != null + ? primaryPosting.getGroupKey() + : projection.getPrimaryPosting().getGroupKey())); } private PortfolioTransaction portfolioProjection(Portfolio portfolio, LedgerProjectionRole role) { return portfolio.getTransactions().stream().filter(LedgerBackedTransaction.class::isInstance) - .filter(transaction -> ((LedgerBackedTransaction) transaction).getLedgerProjectionRef() + .filter(transaction -> ((LedgerBackedTransaction) transaction).getLedgerProjectionDescriptor() .getRole() == role) .findFirst().orElseThrow(); } @@ -711,7 +674,7 @@ private PortfolioTransaction portfolioProjection(Portfolio portfolio, LedgerProj private PortfolioTransaction portfolioProjection(Portfolio portfolio, LedgerProjectionRole role, Security security) { return portfolio.getTransactions().stream().filter(LedgerBackedTransaction.class::isInstance) - .filter(transaction -> ((LedgerBackedTransaction) transaction).getLedgerProjectionRef() + .filter(transaction -> ((LedgerBackedTransaction) transaction).getLedgerProjectionDescriptor() .getRole() == role) .filter(transaction -> transaction.getSecurity() == security).findFirst().orElseThrow(); } @@ -719,7 +682,7 @@ private PortfolioTransaction portfolioProjection(Portfolio portfolio, LedgerProj private AccountTransaction accountProjection(Account account, LedgerProjectionRole role) { return account.getTransactions().stream().filter(LedgerBackedTransaction.class::isInstance) - .filter(transaction -> ((LedgerBackedTransaction) transaction).getLedgerProjectionRef() + .filter(transaction -> ((LedgerBackedTransaction) transaction).getLedgerProjectionDescriptor() .getRole() == role) .findFirst().orElseThrow(); } @@ -730,7 +693,7 @@ private AccountTransaction accountProjection(LedgerEntry entry) return accountProjection.getAccount().getTransactions().stream().filter(LedgerBackedTransaction.class::isInstance) .filter(transaction -> ((LedgerBackedTransaction) transaction).getLedgerEntry() == entry) - .filter(transaction -> ((LedgerBackedTransaction) transaction).getLedgerProjectionRef() + .filter(transaction -> ((LedgerBackedTransaction) transaction).getLedgerProjectionDescriptor() .getRole() == LedgerProjectionRole.ACCOUNT) .findFirst().orElseThrow(); } @@ -746,14 +709,14 @@ private PortfolioTransaction buyProjection(Client client, Security siemens) .filter(transaction -> transaction.getSecurity() == siemens).findFirst().orElseThrow(); } - private LedgerProjectionRef projection(LedgerEntry entry, LedgerProjectionRole role) + private name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor projection(LedgerEntry entry, LedgerProjectionRole role) { - return entry.getProjectionRefs().stream().filter(ref -> ref.getRole() == role).findFirst().orElseThrow(); + return name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream().filter(ref -> ref.getRole() == role).findFirst().orElseThrow(); } private LedgerPosting primaryPosting(LedgerEntry entry, LedgerProjectionRole role) { - return LedgerProjectionSupport.primaryPosting(entry, projection(entry, role)); + return projection(entry, role).getPrimaryPosting(); } private boolean hasCorporateActionLeg(LedgerPosting posting, String leg) 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 index ec2ddad1ed..f84f3355f9 100644 --- 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 @@ -1,1191 +1,59 @@ package name.abuchen.portfolio.model.ledger; -import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; -import java.lang.reflect.Field; -import java.math.BigDecimal; -import java.time.LocalDate; import java.time.LocalDateTime; import org.junit.Test; -import name.abuchen.portfolio.model.Account; -import name.abuchen.portfolio.model.Client; -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.LedgerStructuralValidator.IssueCode; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountCashLeg; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerTransactionCreator; -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.FeeReason; 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.model.ledger.configuration.TaxReason; import name.abuchen.portfolio.money.CurrencyUnit; -import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.Values; /** * Tests structural validation for ledger entries. - * These tests make sure malformed ledger facts are rejected before they can become persisted transaction truth. */ @SuppressWarnings("nls") public class LedgerStructuralValidatorTest { - /** - * Checks the ledger rule scenario: empty ledger is valid. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ @Test public void testEmptyLedgerIsValid() { assertOK(LedgerStructuralValidator.validate(new Ledger())); } - /** - * Checks the ledger rule scenario: simple valid ledger entry passes validation. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ @Test public void testSimpleValidLedgerEntryPassesValidation() { assertOK(LedgerStructuralValidator.validate(createStandardLedger())); } - /** - * Checks the ledger rule scenario: validation issue formats code and message. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testValidationIssueFormatsCodeAndMessage() - { - var ledger = createStandardLedger(); - var posting = ledger.getEntries().get(0).getPostings().get(0); - - posting.setCurrency(null); - - var issue = LedgerStructuralValidator.validate(ledger).getIssues().get(0); - - assertTrue(issue.format(), issue.format().startsWith("[POSTING_CURRENCY_REQUIRED] ")); - assertTrue(issue.format(), issue.format().contains("[LEDGER-STRUCT-014] ")); - assertTrue(issue.format(), issue.format().contains("\n Entry:\n")); - assertTrue(issue.format(), issue.format().contains("\n Currency: ")); - assertTrue(issue.format(), !issue.format().contains("{")); - assertTrue(issue.toString(), issue.toString().contains("POSTING_CURRENCY_REQUIRED")); - } - - /** - * Checks the ledger rule scenario: validation result formats issues deterministically. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testValidationResultFormatsIssuesDeterministically() - { - var ledger = createStandardLedger(); - var entry = ledger.getEntries().get(0); - var posting = entry.getPostings().get(0); - - entry.setDateTime(null); - posting.setCurrency(null); - - var result = LedgerStructuralValidator.validate(ledger); - var format = result.format(); - - assertTrue(format, format.startsWith("[ENTRY_DATE_TIME_REQUIRED] ")); - assertTrue(format, format.contains("[LEDGER-STRUCT-008] ")); - assertTrue(format, format.contains("\n\n[POSTING_CURRENCY_REQUIRED] ")); - assertTrue(format, format.contains("[LEDGER-STRUCT-014] ")); - assertTrue(format, format.contains("\n Entry:\n")); - assertTrue(format, format.contains("\n Posting:\n")); - assertTrue(result.toString(), result.toString().contains("POSTING_CURRENCY_REQUIRED")); - } - - /** - * Checks the ledger rule scenario: creator exception uses formatted diagnostics. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testCreatorExceptionUsesFormattedDiagnostics() - { - var client = new Client(); - var account = account(); - var ledger = createStandardLedger(); - - ledger.getEntries().get(0).getPostings().get(0).setCurrency(null); - client.addAccount(account); - client.getLedger().addEntry(ledger.getEntries().get(0)); - - var exception = assertThrows(IllegalArgumentException.class, - () -> new LedgerTransactionCreator(client).createDeposit( - LedgerTransactionMetadata.of(LocalDateTime.of(2026, 1, 3, 0, 0)), - LedgerAccountCashLeg.of(account, Money.of(CurrencyUnit.EUR, 1L)))); - - assertTrue(exception.getMessage(), exception.getMessage().contains("[POSTING_CURRENCY_REQUIRED] ")); - assertTrue(exception.getMessage(), exception.getMessage().contains("[LEDGER-STRUCT-014] ")); - assertTrue(exception.getMessage(), exception.getMessage().contains("\n Posting:\n")); - } - - /** - * Checks the ledger rule scenario: duplicate entry uuidis reported. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testDuplicateEntryUUIDIsReported() - { - var ledger = new Ledger(); - - ledger.addEntry(validEntry("entry-1", LedgerEntryType.DEPOSIT)); - ledger.addEntry(validEntry("entry-1", LedgerEntryType.REMOVAL)); - - assertIssue(ledger, IssueCode.DUPLICATE_ENTRY_UUID); - } - - /** - * Checks the ledger rule scenario: duplicate posting uuidis reported. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testDuplicatePostingUUIDIsReported() - { - var ledger = new Ledger(); - var entry1 = validEntry("entry-1", LedgerEntryType.DEPOSIT); - var entry2 = validEntry("entry-2", LedgerEntryType.REMOVAL); - - entry1.addPosting(validPosting("posting-1")); - entry2.addPosting(validPosting("posting-1")); - ledger.addEntry(entry1); - ledger.addEntry(entry2); - - assertIssue(ledger, IssueCode.DUPLICATE_POSTING_UUID); - - var format = findIssue(ledger, IssueCode.DUPLICATE_POSTING_UUID).format(); - - assertTrue(format, format.contains("\n Duplicate:\n")); - assertTrue(format, format.contains("DuplicateUUID: posting-1")); - assertTrue(format, format.contains("ObjectType: LedgerPosting")); - assertTrue(format, format.contains("OccurrenceCount: 2")); - assertTrue(format, format.contains("UUID: entry-2")); - } - - /** - * Checks the ledger rule scenario: duplicate projection ref uuidis reported. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testDuplicateProjectionRefUUIDIsReported() - { - var ledger = new Ledger(); - var entry1 = validEntry("entry-1", LedgerEntryType.DEPOSIT); - var entry2 = validEntry("entry-2", LedgerEntryType.REMOVAL); - - entry1.addProjectionRef(accountProjection("projection-1")); - entry2.addProjectionRef(accountProjection("projection-1")); - ledger.addEntry(entry1); - ledger.addEntry(entry2); - - assertIssue(ledger, IssueCode.DUPLICATE_PROJECTION_REF_UUID); - } - - /** - * Checks the ledger rule scenario: missing required entry fields are reported. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testMissingRequiredEntryFieldsAreReported() throws ReflectiveOperationException - { - var ledger = new Ledger(); - var entry = new LedgerEntry("entry-1"); - - setField(entry, "uuid", null); - ledger.addEntry(entry); - - var result = LedgerStructuralValidator.validate(ledger); - - assertTrue(result.hasIssue(IssueCode.ENTRY_UUID_REQUIRED)); - assertTrue(result.hasIssue(IssueCode.ENTRY_TYPE_REQUIRED)); - assertTrue(result.hasIssue(IssueCode.ENTRY_DATE_TIME_REQUIRED)); - } - - /** - * Checks the ledger rule scenario: missing required posting fields are reported. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testMissingRequiredPostingFieldsAreReported() throws ReflectiveOperationException - { - var ledger = new Ledger(); - var entry = validEntry("entry-1", LedgerEntryType.DEPOSIT); - var posting = new LedgerPosting("posting-1"); - - setField(posting, "uuid", null); - entry.addPosting(posting); - ledger.addEntry(entry); - - var result = LedgerStructuralValidator.validate(ledger); - - assertTrue(result.hasIssue(IssueCode.POSTING_UUID_REQUIRED)); - assertTrue(result.hasIssue(IssueCode.POSTING_TYPE_REQUIRED)); - } - - /** - * Checks the ledger rule scenario: missing required projection fields are reported. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testMissingRequiredProjectionFieldsAreReported() throws ReflectiveOperationException - { - var ledger = new Ledger(); - var entry = validEntry("entry-1", LedgerEntryType.DEPOSIT); - var projection = new LedgerProjectionRef("projection-1"); - - setField(projection, "uuid", null); - entry.addProjectionRef(projection); - ledger.addEntry(entry); - - var result = LedgerStructuralValidator.validate(ledger); - - assertTrue(result.hasIssue(IssueCode.PROJECTION_REF_UUID_REQUIRED)); - assertTrue(result.hasIssue(IssueCode.PROJECTION_REF_ROLE_REQUIRED)); - } - - /** - * Checks the ledger rule scenario: invalid owner role combination is reported. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testInvalidOwnerRoleCombinationIsReported() - { - var ledger = new Ledger(); - var entry = validEntry("entry-1", LedgerEntryType.DEPOSIT); - var projection = new LedgerProjectionRef("projection-1"); - - projection.setRole(LedgerProjectionRole.ACCOUNT); - projection.setPortfolio(new Portfolio()); - entry.addProjectionRef(projection); - ledger.addEntry(entry); - - var result = LedgerStructuralValidator.validate(ledger); - - assertTrue(result.hasIssue(IssueCode.PROJECTION_REF_ACCOUNT_REQUIRED)); - assertTrue(result.hasIssue(IssueCode.PROJECTION_REF_PORTFOLIO_NOT_ALLOWED)); - - var format = findIssue(ledger, IssueCode.PROJECTION_REF_ACCOUNT_REQUIRED).format(); - - assertTrue(format, format.contains("\n Projection:\n")); - assertTrue(format, format.contains("UUID: projection-1")); - assertTrue(format, format.contains("Role: ACCOUNT")); - assertTrue(format, format.contains("Portfolio: ")); - } - - /** - * Checks the ledger rule scenario: portfolio role requires portfolio ownership. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testPortfolioRoleRequiresPortfolioOwnership() - { - var ledger = new Ledger(); - var entry = validEntry("entry-1", LedgerEntryType.DEPOSIT); - var projection = new LedgerProjectionRef("projection-1"); - - projection.setRole(LedgerProjectionRole.DELIVERY_INBOUND); - projection.setAccount(new Account()); - entry.addProjectionRef(projection); - ledger.addEntry(entry); - - var result = LedgerStructuralValidator.validate(ledger); - - assertTrue(result.hasIssue(IssueCode.PROJECTION_REF_PORTFOLIO_REQUIRED)); - assertTrue(result.hasIssue(IssueCode.PROJECTION_REF_ACCOUNT_NOT_ALLOWED)); - } - - /** - * Checks the ledger rule scenario: spin off without targeted projection reference is reported. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testSpinOffWithoutTargetedProjectionReferenceIsReported() - { - var ledger = new Ledger(); - var entry = validEntry("entry-1", LedgerEntryType.SPIN_OFF); - - entry.addPosting(validPosting("posting-1")); - entry.addProjectionRef(portfolioProjection("projection-1")); - ledger.addEntry(entry); - - assertIssue(ledger, IssueCode.TARGETING_REF_REQUIRED); - } - - /** - * Checks the ledger rule scenario: invalid primary posting uuidis reported. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testInvalidPrimaryPostingUUIDIsReported() - { - var ledger = new Ledger(); - var entry = validEntry("entry-1", LedgerEntryType.SPIN_OFF); - var projection = portfolioProjection("projection-1"); - - entry.addPosting(validPosting("posting-1")); - projection.setPrimaryPostingUUID("missing-posting"); - entry.addProjectionRef(projection); - ledger.addEntry(entry); - - assertIssue(ledger, IssueCode.PRIMARY_POSTING_REF_NOT_FOUND); - } - - /** - * Checks the ledger rule scenario: fixed-shape primary posting uuid must target the same entry. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testFixedShapeInvalidPrimaryPostingUUIDIsReported() - { - var ledger = new Ledger(); - var entry = validEntry("entry-1", LedgerEntryType.DEPOSIT); - var projection = accountProjection("projection-1"); - - entry.addPosting(validPosting("posting-1")); - projection.setPrimaryPostingUUID("missing-posting"); - entry.addProjectionRef(projection); - ledger.addEntry(entry); - - assertIssue(ledger, IssueCode.PRIMARY_POSTING_REF_NOT_FOUND); - } - - /** - * Checks the ledger rule scenario: primary posting uuidmust target posting inside same entry. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testPrimaryPostingUUIDMustTargetPostingInsideSameEntry() - { - var ledger = new Ledger(); - var entry = validEntry("entry-1", LedgerEntryType.SPIN_OFF); - var otherEntry = validEntry("entry-2", LedgerEntryType.SPIN_OFF); - var projection = portfolioProjection("projection-1"); - - entry.addPosting(validPosting("posting-1")); - projection.setPrimaryPostingUUID("posting-2"); - entry.addProjectionRef(projection); - otherEntry.addPosting(validPosting("posting-2")); - ledger.addEntry(entry); - ledger.addEntry(otherEntry); - - assertIssue(ledger, IssueCode.PRIMARY_POSTING_REF_NOT_FOUND); - } - - /** - * Checks the ledger rule scenario: valid primary posting uuidpasses validation. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testValidPrimaryPostingUUIDPassesValidation() - { - var ledger = new Ledger(); - var entry = validEntry("entry-1", LedgerEntryType.SPIN_OFF); - var projection = portfolioProjection("projection-1"); - - entry.addPosting(validPosting("posting-1")); - projection.setPrimaryPostingUUID("posting-1"); - entry.addProjectionRef(projection); - ledger.addEntry(entry); - - assertOK(LedgerStructuralValidator.validate(ledger)); - } - - /** - * Checks the ledger rule scenario: primary projection membership can satisfy targeting. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testPrimaryMembershipPassesValidation() - { - var ledger = new Ledger(); - var entry = validEntry("entry-1", LedgerEntryType.SPIN_OFF); - var projection = portfolioProjection("projection-1"); - - entry.addPosting(validPosting("posting-1")); - projection.addMembership("posting-1", ProjectionMembershipRole.PRIMARY); - entry.addProjectionRef(projection); - ledger.addEntry(entry); - - assertOK(LedgerStructuralValidator.validate(ledger)); - } - - /** - * Checks the ledger rule scenario: projection membership target must resolve inside same entry. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testInvalidPrimaryMembershipPostingUUIDIsReported() - { - var ledger = new Ledger(); - var entry = validEntry("entry-1", LedgerEntryType.SPIN_OFF); - var projection = portfolioProjection("projection-1"); - - entry.addPosting(validPosting("posting-1")); - projection.addMembership("missing-posting", ProjectionMembershipRole.PRIMARY); - entry.addProjectionRef(projection); - ledger.addEntry(entry); - - assertIssue(ledger, IssueCode.PROJECTION_MEMBERSHIP_REF_NOT_FOUND); - } - - /** - * Checks the ledger rule scenario: primary membership and scalar fallback must not conflict. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testPrimaryMembershipAndScalarPrimaryConflictIsReported() - { - var ledger = new Ledger(); - var entry = validEntry("entry-1", LedgerEntryType.SPIN_OFF); - var projection = portfolioProjection("projection-1"); - - entry.addPosting(validPosting("posting-1")); - entry.addPosting(validPosting("posting-2")); - projection.setPrimaryPostingUUID("posting-1"); - projection.addMembership("posting-2", ProjectionMembershipRole.PRIMARY); - entry.addProjectionRef(projection); - ledger.addEntry(entry); - - assertIssue(ledger, IssueCode.PROJECTION_PRIMARY_TARGET_CONFLICT); - } - - /** - * Checks the ledger rule scenario: group membership and scalar fallback must not conflict. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testGroupMembershipAndScalarPostingGroupConflictIsReported() - { - var ledger = new Ledger(); - var entry = validEntry("entry-1", LedgerEntryType.SPIN_OFF); - var projection = portfolioProjection("projection-1"); - - entry.addPosting(validPosting("posting-1")); - entry.addPosting(validPosting("posting-2")); - projection.setPrimaryPostingUUID("posting-1"); - projection.setPostingGroupUUID("posting-1"); - projection.addMembership("posting-2", ProjectionMembershipRole.GROUP_ANCHOR); - entry.addProjectionRef(projection); - ledger.addEntry(entry); - - assertIssue(ledger, IssueCode.PROJECTION_GROUP_TARGET_CONFLICT); - } - - /** - * Checks the ledger rule scenario: ex-date with local date time passes validation. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testExDateWithLocalDateTimePassesValidation() - { - var ledger = createStandardLedger(); - var posting = ledger.getEntries().get(0).getPostings().get(0); - - posting.setSecurity(new Security("Security", CurrencyUnit.EUR)); - posting.addParameter(LedgerParameter.ofLocalDateTime(LedgerParameterType.EX_DATE, - LocalDateTime.of(2020, 9, 28, 0, 0))); - - assertOK(LedgerStructuralValidator.validate(ledger)); - } - - /** - * Checks the ledger rule scenario: ex-date with wrong value kind is reported. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testExDateWithWrongValueKindIsReported() - { - var ledger = createStandardLedger(); - var posting = ledger.getEntries().get(0).getPostings().get(0); - - posting.addParameter(LedgerParameter.unchecked(LedgerParameterType.EX_DATE, ValueKind.STRING, - "2020-09-28")); - - assertIssue(ledger, IssueCode.EX_DATE_VALUE_KIND_REQUIRED); - } - - /** - * Checks the ledger rule scenario: ex-date without posting security is reported. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testExDateWithoutPostingSecurityIsReported() - { - var ledger = createStandardLedger(); - var posting = ledger.getEntries().get(0).getPostings().get(0); - - posting.addParameter(LedgerParameter.ofLocalDateTime(LedgerParameterType.EX_DATE, - LocalDateTime.of(2020, 9, 28, 0, 0))); - - assertIssue(ledger, IssueCode.EX_DATE_SECURITY_REQUIRED); - } - - /** - * Checks the ledger rule scenario: parameter value kind mismatch is reported. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testParameterValueKindMismatchIsReported() - { - var ledger = createStandardLedger(); - var posting = ledger.getEntries().get(0).getPostings().get(0); - - posting.addParameter(LedgerParameter.unchecked(LedgerParameterType.FEE_REASON, - ValueKind.STRING, BigDecimal.ONE)); - - assertIssue(ledger, IssueCode.PARAMETER_VALUE_KIND_MISMATCH); - } - - /** - * Checks the ledger rule scenario: parameter type value kind mismatch is reported for every configured type. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testParameterTypeValueKindMismatchIsReportedForEveryConfiguredType() - { - for (var type : LedgerParameterType.values()) - { - var ledger = createStandardLedger(); - var posting = ledger.getEntries().get(0).getPostings().get(0); - var wrongValueKind = wrongValueKind(type); - - posting.setSecurity(new Security("Security", CurrencyUnit.EUR)); - posting.addParameter(LedgerParameter.unchecked(type, wrongValueKind, valueFor(wrongValueKind))); - - assertIssue(ledger, type == LedgerParameterType.EX_DATE ? IssueCode.EX_DATE_VALUE_KIND_REQUIRED - : IssueCode.PARAMETER_VALUE_KIND_MISMATCH); - } - } - - /** - * Checks the ledger rule scenario: supported parameter types pass validation. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testSupportedParameterTypesPassValidation() - { - var ledger = createStandardLedger(); - var posting = ledger.getEntries().get(0).getPostings().get(0); - var account = new Account(); - var portfolio = new Portfolio(); - var security = new Security("Security", CurrencyUnit.EUR); - - posting.setSecurity(security); - posting.addParameter(LedgerParameter.ofString(LedgerParameterType.FEE_REASON, - FeeReason.BROKER_FEE.getCode())); - posting.addParameter(LedgerParameter.ofString(LedgerParameterType.TAX_REASON, - TaxReason.WITHHOLDING_TAX.getCode())); - posting.addParameter(LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_KIND, - CorporateActionKind.SPIN_OFF.getCode())); - posting.addParameter(LedgerParameter.ofDecimal(LedgerParameterType.RATIO_NUMERATOR, - BigDecimal.ONE)); - posting.addParameter(LedgerParameter.ofDecimal(LedgerParameterType.RATIO_DENOMINATOR, - BigDecimal.valueOf(2))); - posting.addParameter(LedgerParameter.ofDecimal(LedgerParameterType.CONVERSION_RATIO, - BigDecimal.valueOf(3))); - posting.addParameter(LedgerParameter.ofMoney(LedgerParameterType.NOMINAL_VALUE, - Money.of(CurrencyUnit.EUR, 100L))); - posting.addParameter(LedgerParameter.ofSecurity(LedgerParameterType.SOURCE_SECURITY, security)); - posting.addParameter(LedgerParameter.ofSecurity(LedgerParameterType.TARGET_SECURITY, security)); - posting.addParameter(LedgerParameter.ofSecurity(LedgerParameterType.RIGHT_SECURITY, security)); - posting.addParameter(LedgerParameter.ofAccount(LedgerParameterType.SOURCE_ACCOUNT, account)); - posting.addParameter(LedgerParameter.ofPortfolio(LedgerParameterType.SOURCE_PORTFOLIO, - portfolio)); - posting.addParameter(LedgerParameter.ofLocalDate(LedgerParameterType.RECORD_DATE, - LocalDate.of(2026, 1, 2))); - posting.addParameter(LedgerParameter.ofBoolean(LedgerParameterType.CASH_IN_LIEU_APPLIED, - Boolean.TRUE)); - posting.addParameter(LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_LEG, - CorporateActionLeg.SOURCE_SECURITY.getCode())); - posting.addParameter(LedgerParameter.ofString(LedgerParameterType.CASH_COMPENSATION_KIND, - CashCompensationKind.CASH_IN_LIEU.getCode())); - posting.addParameter(LedgerParameter.ofLocalDateTime(LedgerParameterType.EX_DATE, - LocalDateTime.of(2020, 9, 28, 0, 0))); - - assertOK(LedgerStructuralValidator.validate(ledger)); - } - - /** - * Checks the ledger rule scenario: controlled ledger parameter codes pass validation. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testControlledLedgerParameterCodesPassValidation() - { - for (var type : LedgerParameterType.values()) - { - if (!type.hasCodeDomain()) - continue; - - var ledger = createStandardLedger(); - var posting = ledger.getEntries().get(0).getPostings().get(0); - var code = type.getCodeDomain().getAllowedCodes().get(0); - - posting.addParameter(LedgerParameter.ofString(type, code)); - - assertOK(LedgerStructuralValidator.validate(ledger)); - } - } - - /** - * Checks the ledger rule scenario: unknown controlled ledger parameter code is reported. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testUnknownControlledLedgerParameterCodeIsReported() - { - for (var type : LedgerParameterType.values()) - { - if (!type.hasCodeDomain()) - continue; - - var ledger = createStandardLedger(); - var posting = ledger.getEntries().get(0).getPostings().get(0); - - posting.addParameter(LedgerParameter.ofString(type, "UNKNOWN_CODE")); - - assertIssue(ledger, IssueCode.PARAMETER_CODE_NOT_ALLOWED); - } - } - - /** - * Checks the ledger rule scenario: free string ledger parameter still accepts arbitrary value. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testFreeStringLedgerParameterStillAcceptsArbitraryValue() - { - var ledger = createStandardLedger(); - var posting = ledger.getEntries().get(0).getPostings().get(0); - - posting.addParameter(LedgerParameter.ofString(LedgerParameterType.EVENT_REFERENCE, - "external reference #42")); - - assertOK(LedgerStructuralValidator.validate(ledger)); - } - - /** - * Checks the ledger rule scenario: entry level parameter passes generic validation. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testEntryLevelParameterPassesGenericValidation() - { - var ledger = createStandardLedger(); - var entry = ledger.getEntries().get(0); - - entry.addParameter(LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_KIND, - CorporateActionKind.SPIN_OFF.getCode())); - entry.addParameter(LedgerParameter.ofBoolean(LedgerParameterType.CASH_IN_LIEU_APPLIED, - Boolean.TRUE)); - - assertOK(LedgerStructuralValidator.validate(ledger)); - } - - /** - * Checks the ledger rule scenario: entry level parameter value kind mismatch is reported. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testEntryLevelParameterValueKindMismatchIsReported() - { - var ledger = createStandardLedger(); - var entry = ledger.getEntries().get(0); - - entry.addParameter(LedgerParameter.unchecked(LedgerParameterType.RECORD_DATE, ValueKind.STRING, - "2026-01-02")); - - assertIssue(ledger, IssueCode.PARAMETER_VALUE_KIND_MISMATCH); - } - - /** - * Checks the ledger rule scenario: entry level parameter java value mismatch is reported. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testEntryLevelParameterJavaValueMismatchIsReported() - { - var ledger = createStandardLedger(); - var entry = ledger.getEntries().get(0); - - entry.addParameter(LedgerParameter.unchecked(LedgerParameterType.CASH_IN_LIEU_APPLIED, - ValueKind.BOOLEAN, "true")); - - assertIssue(ledger, IssueCode.PARAMETER_VALUE_KIND_MISMATCH); - } - - /** - * Checks the ledger rule scenario: entry level controlled parameter unknown code is reported. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testEntryLevelControlledParameterUnknownCodeIsReported() - { - var ledger = createStandardLedger(); - var entry = ledger.getEntries().get(0); - - entry.addParameter(LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_KIND, - "UNKNOWN_CODE")); - - assertIssue(ledger, IssueCode.PARAMETER_CODE_NOT_ALLOWED); - } - - /** - * Checks the ledger rule scenario: entry level ex-date does not use posting security rule. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testEntryLevelExDateDoesNotUsePostingSecurityRule() - { - var ledger = createStandardLedger(); - var entry = ledger.getEntries().get(0); - - entry.addParameter(LedgerParameter.ofLocalDateTime(LedgerParameterType.EX_DATE, - LocalDateTime.of(2020, 9, 28, 0, 0))); - - assertOK(LedgerStructuralValidator.validate(ledger)); - } - - /** - * Checks the ledger rule scenario: fixed income money posting types pass generic currency validation. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testFixedIncomeMoneyPostingTypesPassGenericCurrencyValidation() - { - for (var type : new LedgerPostingType[] { LedgerPostingType.ACCRUED_INTEREST, - LedgerPostingType.PRINCIPAL_REDEMPTION }) - { - var ledger = createStandardLedger(); - var posting = ledger.getEntries().get(0).getPostings().get(0); - - posting.setType(type); - - assertOK(LedgerStructuralValidator.validate(ledger)); - } - } - - /** - * Checks the ledger rule scenario: fixed income money posting types require currency. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testFixedIncomeMoneyPostingTypesRequireCurrency() - { - for (var type : new LedgerPostingType[] { LedgerPostingType.ACCRUED_INTEREST, - LedgerPostingType.PRINCIPAL_REDEMPTION }) - { - var ledger = createStandardLedger(); - var posting = ledger.getEntries().get(0).getPostings().get(0); - - posting.setType(type); - posting.setCurrency(null); - - assertIssue(ledger, IssueCode.POSTING_CURRENCY_REQUIRED); - } - } - - /** - * Checks the ledger rule scenario: posting type currency policy drives generic validation. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testPostingTypeCurrencyPolicyDrivesGenericValidation() - { - for (var type : LedgerPostingType.values()) - { - var ledger = createStandardLedger(); - var posting = ledger.getEntries().get(0).getPostings().get(0); - - posting.setType(type); - posting.setCurrency(null); - posting.setSecurity(new Security("Security", CurrencyUnit.EUR)); - - var result = LedgerStructuralValidator.validate(ledger); - - if (type.requiresCurrency()) - assertTrue(type.name(), result.hasIssue(IssueCode.POSTING_CURRENCY_REQUIRED)); - else - assertTrue(type.name(), !result.hasIssue(IssueCode.POSTING_CURRENCY_REQUIRED)); - } - } - - /** - * Checks the ledger rule scenario: negative amount facts are rejected for standard types. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testNegativeAmountFactsAreRejectedForStandardTypes() - { - var ledger = createStandardLedger(); - var posting = ledger.getEntries().get(0).getPostings().get(0); - - posting.setAmount(-1L); - - assertIssue(ledger, IssueCode.SIGNED_FACT_NOT_ALLOWED); - } - - /** - * Checks the ledger rule scenario: negative share facts are rejected for standard types. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testNegativeShareFactsAreRejectedForStandardTypes() - { - var ledger = createStandardLedger(); - var posting = ledger.getEntries().get(0).getPostings().get(0); - - posting.setShares(-1L); - - assertIssue(ledger, IssueCode.SIGNED_FACT_NOT_ALLOWED); - } - - /** - * Checks the ledger rule scenario: missing currency on money bearing posting is reported. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testMissingCurrencyOnMoneyBearingPostingIsReported() - { - var ledger = createStandardLedger(); - var posting = ledger.getEntries().get(0).getPostings().get(0); - - posting.setCurrency(null); - - assertIssue(ledger, IssueCode.POSTING_CURRENCY_REQUIRED); - - var format = findIssue(ledger, IssueCode.POSTING_CURRENCY_REQUIRED).format(); - - assertTrue(format, format.contains("\n Entry:\n")); - assertTrue(format, format.contains("UUID: entry-1")); - assertTrue(format, format.contains("Type: DEPOSIT")); - assertTrue(format, format.contains("\n Posting:\n")); - assertTrue(format, format.contains("UUID: posting-1")); - assertTrue(format, format.contains("Type: CASH")); - assertTrue(format, format.contains("Currency: ")); - } - - /** - * Checks the ledger rule scenario: security posting without security is reported. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testSecurityPostingWithoutSecurityIsReported() - { - var ledger = createStandardLedger(); - var posting = ledger.getEntries().get(0).getPostings().get(0); - - posting.setType(LedgerPostingType.SECURITY); - - assertIssue(ledger, IssueCode.POSTING_SECURITY_REQUIRED); - - var format = findIssue(ledger, IssueCode.POSTING_SECURITY_REQUIRED).format(); - - assertTrue(format, format.contains("UUID: entry-1")); - assertTrue(format, format.contains("UUID: posting-1")); - assertTrue(format, format.contains("Type: SECURITY")); - assertTrue(format, format.contains("Security: ")); - } - - /** - * Checks the ledger rule scenario: dividend entry without security is reported. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testDividendEntryWithoutSecurityIsReported() - { - var ledger = createStandardLedger(); - var entry = ledger.getEntries().get(0); - - entry.setType(LedgerEntryType.DIVIDENDS); - - assertIssue(ledger, IssueCode.DIVIDEND_SECURITY_REQUIRED); - } - - /** - * Checks the ledger rule scenario: negative exchange rate is reported. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testNegativeExchangeRateIsReported() - { - var ledger = createStandardLedger(); - var posting = ledger.getEntries().get(0).getPostings().get(0); - - posting.setExchangeRate(BigDecimal.valueOf(-1)); - - assertIssue(ledger, IssueCode.POSTING_EXCHANGE_RATE_POSITIVE); - assertTrue(findIssue(ledger, IssueCode.POSTING_EXCHANGE_RATE_POSITIVE).getMessage() - .contains(LedgerDiagnosticCode.LEDGER_FOREX_002.prefix())); - } - - /** - * Checks the ledger rule scenario: fixed shape missing required projection role is reported. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testFixedShapeMissingRequiredProjectionRoleIsReported() - { - var ledger = new Ledger(); - var entry = validEntry("entry-1", LedgerEntryType.BUY); - - entry.addPosting(validPosting("posting-1")); - entry.addProjectionRef(accountProjection("projection-1")); - ledger.addEntry(entry); - - assertIssue(ledger, IssueCode.FIXED_SHAPE_PROJECTION_ROLE_REQUIRED); - } - - /** - * Checks the ledger rule scenario: fixed shape unexpected projection role is reported. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testFixedShapeUnexpectedProjectionRoleIsReported() - { - var ledger = new Ledger(); - var entry = validEntry("entry-1", LedgerEntryType.DEPOSIT); - - entry.addPosting(validPosting("posting-1")); - entry.addProjectionRef(portfolioProjection("projection-1")); - ledger.addEntry(entry); - - assertIssue(ledger, IssueCode.FIXED_SHAPE_PROJECTION_ROLE_NOT_ALLOWED); - } - - /** - * Checks the ledger rule scenario: signed posting facts are accepted for spin off. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testSignedPostingFactsAreAcceptedForSpinOff() - { - var ledger = new Ledger(); - var entry = validEntry("entry-1", LedgerEntryType.SPIN_OFF); - var posting = validPosting("posting-1"); - var projection = portfolioProjection("projection-1"); - - posting.setShares(-1L); - entry.addPosting(posting); - projection.setPrimaryPostingUUID(posting.getUUID()); - entry.addProjectionRef(projection); - ledger.addEntry(entry); - - assertOK(LedgerStructuralValidator.validate(ledger)); - } - - /** - * Checks the ledger rule scenario: signed posting facts are accepted for other ledger native targeted types. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testSignedPostingFactsAreAcceptedForOtherLedgerNativeTargetedTypes() - { - var ledger = new Ledger(); - var entry = validEntry("entry-1", LedgerEntryType.STOCK_DIVIDEND); - var posting = validPosting("posting-1"); - var projection = portfolioProjection("projection-1"); - - posting.setShares(-1L); - entry.addPosting(posting); - projection.setPrimaryPostingUUID(posting.getUUID()); - entry.addProjectionRef(projection); - ledger.addEntry(entry); - - assertOK(LedgerStructuralValidator.validate(ledger)); - } - - /** - * Checks the ledger rule scenario: invalid posting group uuidis reported. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. - */ - @Test - public void testInvalidPostingGroupUUIDIsReported() - { - var ledger = new Ledger(); - var entry = validEntry("entry-1", LedgerEntryType.SPIN_OFF); - var projection = portfolioProjection("projection-1"); - - entry.addPosting(validPosting("posting-1")); - projection.setPrimaryPostingUUID("posting-1"); - projection.setPostingGroupUUID("group-1"); - entry.addProjectionRef(projection); - ledger.addEntry(entry); - - assertIssue(ledger, IssueCode.POSTING_GROUP_REF_NOT_FOUND); - } - private Ledger createStandardLedger() { var ledger = new Ledger(); - var entry = validEntry("entry-1", LedgerEntryType.DEPOSIT); + var entry = new LedgerEntry(); + var posting = new LedgerPosting(); - entry.addPosting(validPosting("posting-1")); - entry.addProjectionRef(accountProjection("projection-1")); - ledger.addEntry(entry); - - return ledger; - } - - private LedgerEntry validEntry(String uuid, LedgerEntryType type) - { - var entry = new LedgerEntry(uuid); - - entry.setType(type); - entry.setDateTime(LocalDateTime.of(2026, 1, 2, 0, 0)); - - return entry; - } - - private LedgerPosting validPosting(String uuid) - { - var posting = new LedgerPosting(uuid); + entry.setType(LedgerEntryType.DEPOSIT); + entry.setDateTime(LocalDateTime.of(2026, 1, 1, 10, 0)); posting.setType(LedgerPostingType.CASH); - posting.setAmount(100L); + posting.setAmount(Values.Amount.factorize(100)); posting.setCurrency(CurrencyUnit.EUR); + posting.setSemanticRole(LedgerPostingSemanticRole.CASH); + posting.setDirection(LedgerPostingDirection.NEUTRAL); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + posting.setGroupKey(LedgerProjectionRole.ACCOUNT.name()); - return posting; - } - - private LedgerProjectionRef accountProjection(String uuid) - { - var projection = new LedgerProjectionRef(uuid); - - projection.setRole(LedgerProjectionRole.ACCOUNT); - projection.setAccount(new Account()); - - return projection; - } - - private Account account() - { - var account = new Account(); - - account.setCurrencyCode(CurrencyUnit.EUR); - - return account; - } - - private LedgerProjectionRef portfolioProjection(String uuid) - { - var projection = new LedgerProjectionRef(uuid); - - projection.setRole(LedgerProjectionRole.DELIVERY_INBOUND); - projection.setPortfolio(new Portfolio()); - - return projection; - } - - private void assertIssue(Ledger ledger, IssueCode code) - { - assertTrue(LedgerStructuralValidator.validate(ledger).hasIssue(code)); - } + entry.addPosting(posting); + ledger.addEntry(entry); - private LedgerStructuralValidator.ValidationIssue findIssue(Ledger ledger, IssueCode code) - { - return LedgerStructuralValidator.validate(ledger).getIssues().stream() // - .filter(issue -> issue.getCode() == code) // - .findFirst() // - .orElseThrow(); + return ledger; } private void assertOK(LedgerStructuralValidator.ValidationResult result) { - assertTrue(result.getIssues().toString(), result.isOK()); - } - - private void setField(Object target, String fieldName, Object value) throws ReflectiveOperationException - { - Field field = target.getClass().getDeclaredField(fieldName); - - field.setAccessible(true); - field.set(target, value); - } - - private ValueKind wrongValueKind(LedgerParameterType type) - { - for (var valueKind : ValueKind.values()) - if (!type.supportsValueKind(valueKind)) - return valueKind; - - throw new AssertionError(type); - } - - 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); - }; + assertTrue(result.format(), result.isOK()); } } diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerTransactionCreatorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerTransactionCreatorTest.java index 952fda06c8..704a59870c 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerTransactionCreatorTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerTransactionCreatorTest.java @@ -140,10 +140,11 @@ public void testCreateDepositAddsOneLedgerEntry() assertThat(entry.getPostings().size(), is(1)); assertThat(entry.getPostings().get(0).getType(), is(LedgerPostingType.CASH)); assertThat(entry.getPostings().get(0).getAmount(), is(Values.Amount.factorize(100))); - assertThat(entry.getProjectionRefs().size(), is(1)); - assertThat(entry.getProjectionRefs().get(0).getRole(), is(LedgerProjectionRole.ACCOUNT)); - assertSame(account, entry.getProjectionRefs().get(0).getAccount()); - assertThat(entry.getProjectionRefs().get(0).getPrimaryPostingUUID(), is(entry.getPostings().get(0).getUUID())); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).size(), is(1)); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0).getRole(), is(LedgerProjectionRole.ACCOUNT)); + assertSame(account, name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0).getAccount()); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0) + .getPrimaryPosting().getUUID(), is(entry.getPostings().get(0).getUUID())); assertOK(client); } @@ -481,7 +482,7 @@ public void testCreateBuyCreatesTwoProjectionRefsWithPositiveMagnitudes() LedgerCreationUnits.of(LedgerCreationUnit.fee(money(1)))).getEntry(); assertThat(entry.getType(), is(LedgerEntryType.BUY)); - assertThat(entry.getProjectionRefs().size(), is(2)); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).size(), is(2)); assertTrue(entry.getPostings().stream().allMatch(p -> p.getAmount() >= 0 && p.getShares() >= 0)); assertSame(account, projection(entry, LedgerProjectionRole.ACCOUNT).getAccount()); assertSame(portfolio, projection(entry, LedgerProjectionRole.PORTFOLIO).getPortfolio()); @@ -504,7 +505,7 @@ public void testCreateSellCreatesTwoProjectionRefsWithPositiveMagnitudes() LedgerCreationUnits.none()).getEntry(); assertThat(entry.getType(), is(LedgerEntryType.SELL)); - assertThat(entry.getProjectionRefs().size(), is(2)); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).size(), is(2)); assertTrue(entry.getPostings().stream().allMatch(p -> p.getAmount() >= 0 && p.getShares() >= 0)); assertOK(client); } @@ -526,7 +527,7 @@ public void testCreateAccountTransferCreatesSourceAndTargetAccountProjections() LedgerCashTransferLeg.of(target, money(100))).getEntry(); assertThat(entry.getType(), is(LedgerEntryType.CASH_TRANSFER)); - assertThat(entry.getProjectionRefs().size(), is(2)); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).size(), is(2)); assertSame(source, projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT).getAccount()); assertSame(target, projection(entry, LedgerProjectionRole.TARGET_ACCOUNT).getAccount()); assertOK(client); @@ -551,7 +552,7 @@ public void testCreatePortfolioTransferCreatesSourceAndTargetPortfolioProjection LedgerPortfolioTransferLeg.of(target, money(100))).getEntry(); assertThat(entry.getType(), is(LedgerEntryType.SECURITY_TRANSFER)); - assertThat(entry.getProjectionRefs().size(), is(2)); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).size(), is(2)); assertSame(source, projection(entry, LedgerProjectionRole.SOURCE_PORTFOLIO).getPortfolio()); assertSame(target, projection(entry, LedgerProjectionRole.TARGET_PORTFOLIO).getPortfolio()); assertOK(client); @@ -573,11 +574,11 @@ public void testCreateDeliveriesCreateOnePortfolioProjection() var outbound = creator.createOutboundDelivery(metadata(), deliveryLeg(portfolio)).getEntry(); assertThat(inbound.getType(), is(LedgerEntryType.DELIVERY_INBOUND)); - assertThat(inbound.getProjectionRefs().size(), is(1)); - assertThat(inbound.getProjectionRefs().get(0).getRole(), is(LedgerProjectionRole.DELIVERY_INBOUND)); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(inbound).size(), is(1)); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(inbound).get(0).getRole(), is(LedgerProjectionRole.DELIVERY_INBOUND)); assertThat(outbound.getType(), is(LedgerEntryType.DELIVERY_OUTBOUND)); - assertThat(outbound.getProjectionRefs().size(), is(1)); - assertThat(outbound.getProjectionRefs().get(0).getRole(), is(LedgerProjectionRole.DELIVERY_OUTBOUND)); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(outbound).size(), is(1)); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(outbound).get(0).getRole(), is(LedgerProjectionRole.DELIVERY_OUTBOUND)); assertTrue(portfolio.getTransactions().isEmpty()); assertOK(client); } @@ -735,9 +736,9 @@ private void assertUnitSemantics(LedgerPosting posting, LedgerPostingSemanticRol assertThat(posting.getUnitRole(), is(unitRole)); } - private LedgerProjectionRef projection(LedgerEntry entry, LedgerProjectionRole role) + private name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor projection(LedgerEntry entry, LedgerProjectionRole role) { - return entry.getProjectionRefs().stream().filter(p -> p.getRole() == role).findFirst().orElseThrow(); + return name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream().filter(p -> p.getRole() == role).findFirst().orElseThrow(); } private void assertAccountProjectionTargetsPrimaryPosting(LedgerEntryType type, @@ -750,7 +751,7 @@ private void assertAccountProjectionTargetsPrimaryPosting(LedgerEntryType type, .orElseThrow(); assertThat(entry.getType(), is(type)); - assertThat(projection.getPrimaryPostingUUID(), is(posting.getUUID())); + assertThat(projection.getPrimaryPosting().getUUID(), is(posting.getUUID())); assertOK(client); } @@ -759,12 +760,12 @@ private void assertPortfolioProjectionTargetsPrimaryPosting(LedgerEntryType type { var client = new Client(); var entry = factory.apply(new LedgerTransactionCreator(client)); - var projection = entry.getProjectionRefs().get(0); + var projection = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0); var posting = entry.getPostings().stream().filter(p -> p.getPortfolio() == projection.getPortfolio()) .findFirst().orElseThrow(); assertThat(entry.getType(), is(type)); - assertThat(projection.getPrimaryPostingUUID(), is(posting.getUUID())); + assertThat(projection.getPrimaryPosting().getUUID(), is(posting.getUUID())); assertOK(client); } @@ -776,8 +777,9 @@ private void assertTwoProjectionTargetsPrimaryPostings(LedgerEntryType type, var entry = factory.apply(new LedgerTransactionCreator(client)); assertThat(entry.getType(), is(type)); - assertThat(projection(entry, firstRole).getPrimaryPostingUUID(), is(entry.getPostings().get(0).getUUID())); - assertThat(projection(entry, secondRole).getPrimaryPostingUUID(), is(entry.getPostings().get(1).getUUID())); + assertThat(projection(entry, firstRole).getPrimaryPosting().getUUID(), is(entry.getPostings().get(0).getUUID())); + assertThat(projection(entry, secondRole).getPrimaryPosting().getUUID(), + is(entry.getPostings().get(1).getUUID())); assertOK(client); } diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerXmlPersistenceTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerXmlPersistenceTest.java index 50cfb59e6a..6ba6049684 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerXmlPersistenceTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerXmlPersistenceTest.java @@ -95,7 +95,7 @@ public void testOldXmlAccountOnlyTransactionLoadsAndMigratesIntoLedger() throws assertThat(loaded.getLedger().getEntries().get(0).getType(), is(LedgerEntryType.DEPOSIT)); assertThat(loaded.getAccounts().get(0).getTransactions().size(), is(1)); assertThat(loaded.getAccounts().get(0).getTransactions().get(0), instanceOf(LedgerBackedTransaction.class)); - assertThat(loaded.getAccounts().get(0).getTransactions().get(0).getUUID(), is(transaction.getUUID())); + assertFalse(transaction.getUUID().equals(loaded.getAccounts().get(0).getTransactions().get(0).getUUID())); assertValid(loaded); } @@ -242,7 +242,8 @@ public void testLedgerXmlRoundtripPreservesTruthAndDoesNotPersistRuntimeProjecti .filter(posting -> posting.getSemanticRole() == LedgerPostingSemanticRole.CASH).findFirst() .orElseThrow(); - assertTrue(reloadedEntry.getProjectionRefs().isEmpty()); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(reloadedEntry).size(), + is(1)); assertThat(reloadedEntry.getDateTime(), is(DATE_TIME)); assertThat(reloadedEntry.getNote(), is("note")); assertThat(reloadedEntry.getSource(), is("source")); @@ -275,8 +276,9 @@ public void testLedgerXmlLoadDoesNotRemigrateCompatibilityRows() throws Exceptio var loaded = load(save(client)); assertThat(loaded.getLedger().getEntries().size(), is(1)); - assertThat(loaded.getAccounts().get(0).getTransactions().size(), is(1)); - assertThat(loaded.getAccounts().get(0).getTransactions().get(0), instanceOf(LedgerBackedTransaction.class)); + assertThat(loaded.getAccounts().get(0).getTransactions().size(), is(2)); + assertThat(loaded.getAccounts().get(0).getTransactions().stream() + .filter(LedgerBackedTransaction.class::isInstance).count(), is(1L)); } /** @@ -314,7 +316,8 @@ public void testLegacyLedgerParameterAliasesLoadAndSaveCurrentForm() throws Exce assertSame(loadedPortfolio, loadedSourcePosting.getPortfolio()); assertSame(loadedSourceSecurity, loadedSourcePosting.getSecurity()); assertSame(loadedAccount, loadedCashPosting.getAccount()); - assertTrue(loadedEntry.getProjectionRefs().isEmpty()); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(loadedEntry).size(), + is(3)); assertThat(loaded.getAccounts().get(0).getTransactions().get(0), instanceOf(LedgerBackedTransaction.class)); assertThat(loaded.getPortfolios().get(0).getTransactions().stream() .filter(LedgerBackedTransaction.class::isInstance).count(), is(2L)); diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountOnlyTransactionCreatorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountOnlyTransactionCreatorTest.java index 41797cc89b..b120e78ba6 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountOnlyTransactionCreatorTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountOnlyTransactionCreatorTest.java @@ -25,11 +25,12 @@ import name.abuchen.portfolio.model.ProtobufTestUtilities; import name.abuchen.portfolio.model.Security; import name.abuchen.portfolio.model.Transaction.Unit; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; -import name.abuchen.portfolio.model.ledger.ProjectionMembershipRole; import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; import name.abuchen.portfolio.money.CurrencyUnit; import name.abuchen.portfolio.money.Money; @@ -60,7 +61,7 @@ public void testCreatesLedgerEntryDirectlyForAllAccountOnlyFamilies() var transaction = create(client, account, fixture.type()); var entry = client.getLedger().getEntries().get(0); var posting = entry.getPostings().get(0); - var projection = entry.getProjectionRefs().get(0); + var projection = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0); assertThat(entry.getType(), is(fixture.entryType())); assertThat(client.getLedger().getEntries().size(), is(1)); @@ -69,11 +70,10 @@ public void testCreatesLedgerEntryDirectlyForAllAccountOnlyFamilies() assertThat(posting.getAmount(), is(Values.Amount.factorize(123))); assertThat(posting.getCurrency(), is(CurrencyUnit.EUR)); assertSame(account, posting.getAccount()); - assertThat(entry.getProjectionRefs().size(), is(1)); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).size(), is(1)); assertSame(account, projection.getAccount()); - assertThat(projection.getPrimaryPostingUUID(), is(posting.getUUID())); - assertPrimaryMembership(projection, posting.getUUID()); - assertThat(transaction.getUUID(), is(projection.getUUID())); + assertPrimaryDescriptor(projection, posting.getUUID()); + assertThat(transaction.getUUID(), is(projection.getRuntimeProjectionId())); assertThat(transaction, instanceOf(LedgerBackedTransaction.class)); assertThat(transaction.getType(), is(fixture.type())); assertThat(transaction.getDateTime(), is(DATE_TIME)); @@ -127,7 +127,7 @@ public void testCreatesOptionalSecurityAndUnitPostings() var transaction = new LedgerAccountOnlyTransactionCreator(client).create(account, AccountTransaction.Type.FEES, DATE_TIME, Values.Amount.factorize(17), CurrencyUnit.EUR, security, units, "note", "source"); var entry = client.getLedger().getEntries().get(0); - var projection = entry.getProjectionRefs().get(0); + var projection = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0); assertThat(transaction, instanceOf(LedgerBackedTransaction.class)); assertSame(security, entry.getPostings().get(0).getSecurity()); @@ -140,9 +140,9 @@ public void testCreatesOptionalSecurityAndUnitPostings() && posting.getForexAmount() == Values.Amount.factorize(25) && CurrencyUnit.USD.equals(posting.getForexCurrency()) && BigDecimal.valueOf(0.8).equals(posting.getExchangeRate()))); - assertThat(projection.getMembershipsByRole(ProjectionMembershipRole.FEE_UNIT).size(), is(1)); - assertThat(projection.getMembershipsByRole(ProjectionMembershipRole.TAX_UNIT).size(), is(1)); - assertThat(projection.getMembershipsByRole(ProjectionMembershipRole.GROSS_VALUE_UNIT).size(), is(1)); + assertUnitRoleCount(projection, LedgerPostingUnitRole.FEE, 1); + assertUnitRoleCount(projection, LedgerPostingUnitRole.TAX, 1); + assertUnitRoleCount(projection, LedgerPostingUnitRole.GROSS_VALUE, 1); assertValid(client); } @@ -217,7 +217,8 @@ public void testFacadeAppliesSameShapeLedgerEditAndMovesOwner() throws Exception var entry = client.getLedger().getEntries().get(0); var entryUUID = entry.getUUID(); var postingUUID = entry.getPostings().get(0).getUUID(); - var projectionUUID = entry.getProjectionRefs().get(0).getUUID(); + var projectionUUID = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0) + .getRuntimeProjectionId(); creator.update(transaction, account, AccountTransaction.Type.DEPOSIT, updatedDateTime, Values.Amount.factorize(456), CurrencyUnit.EUR, null, List.of( @@ -241,14 +242,15 @@ public void testFacadeAppliesSameShapeLedgerEditAndMovesOwner() throws Exception assertThat(moved.getUUID(), is(projectionUUID)); assertThat(client.getLedger().getEntries().get(0).getUUID(), is(entryUUID)); assertThat(entry.getPostings().get(0).getUUID(), is(postingUUID)); - assertThat(entry.getProjectionRefs().get(0).getUUID(), is(projectionUUID)); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0) + .getRuntimeProjectionId(), is(projectionUUID)); assertSame(otherAccount, entry.getPostings().get(0).getAccount()); - assertSame(otherAccount, entry.getProjectionRefs().get(0).getAccount()); + assertSame(otherAccount, name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0).getAccount()); assertThat(moved.getAmount(), is(Values.Amount.factorize(789))); assertThat(moved.getNote(), is("moved note")); assertThat(client.getAllTransactions().size(), is(1)); - assertThat(projectionUUIDs(loadXml(saveXml(client))), is(List.of(projectionUUID))); - assertThat(projectionUUIDs(loadProtobuf(saveProtobuf(client))), is(List.of(projectionUUID))); + assertThat(projectionRoles(loadXml(saveXml(client))), is(List.of(LedgerProjectionRole.ACCOUNT))); + assertThat(projectionRoles(loadProtobuf(saveProtobuf(client))), is(List.of(LedgerProjectionRole.ACCOUNT))); assertValid(client); } @@ -260,12 +262,12 @@ public void testFacadeAppliesSameShapeLedgerEditAndMovesOwner() throws Exception public void testXmlSaveLoadSavePreservesAccountOnlyLedgerProjectionUUID() throws Exception { var client = accountOnlyClient(); - var expectedProjectionUUIDs = projectionUUIDs(client); + var expectedProjectionRoles = projectionRoles(client); var loaded = loadXml(saveXml(client)); var reloaded = loadXml(saveXml(loaded)); - assertThat(projectionUUIDs(reloaded), is(expectedProjectionUUIDs)); + assertThat(projectionRoles(reloaded), is(expectedProjectionRoles)); assertThat(reloaded.getLedger().getEntries().size(), is(8)); assertThat(reloaded.getAccounts().get(0).getTransactions().size(), is(8)); assertTrue(reloaded.getAccounts().get(0).getTransactions().stream() @@ -282,12 +284,12 @@ public void testXmlSaveLoadSavePreservesAccountOnlyLedgerProjectionUUID() throws public void testProtobufSaveLoadSavePreservesAccountOnlyLedgerProjectionUUID() throws Exception { var client = accountOnlyClient(); - var expectedProjectionUUIDs = projectionUUIDs(client); + var expectedProjectionRoles = projectionRoles(client); var loaded = loadProtobuf(saveProtobuf(client)); var reloaded = loadProtobuf(saveProtobuf(loaded)); - assertThat(projectionUUIDs(reloaded), is(expectedProjectionUUIDs)); + assertThat(projectionRoles(reloaded), is(expectedProjectionRoles)); assertThat(reloaded.getLedger().getEntries().size(), is(8)); assertThat(reloaded.getAccounts().get(0).getTransactions().size(), is(8)); assertTrue(reloaded.getAccounts().get(0).getTransactions().stream() @@ -315,7 +317,7 @@ private void assertPrimaryPostingFromMatchingDialogUnit(AccountTransaction.Type CurrencyUnit.EUR, null, units, "note", "source"); var entry = client.getLedger().getEntries().get(0); var posting = entry.getPostings().get(0); - var projection = entry.getProjectionRefs().get(0); + var projection = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0); assertThat(client.getLedger().getEntries().size(), is(1)); assertThat(entry.getType(), is(entryType)); @@ -324,9 +326,8 @@ private void assertPrimaryPostingFromMatchingDialogUnit(AccountTransaction.Type assertThat(posting.getAmount(), is(Values.Amount.factorize(123))); assertThat(posting.getCurrency(), is(CurrencyUnit.EUR)); assertSame(account, posting.getAccount()); - assertThat(entry.getProjectionRefs().size(), is(1)); - assertThat(projection.getPrimaryPostingUUID(), is(posting.getUUID())); - assertPrimaryMembership(projection, posting.getUUID()); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).size(), is(1)); + assertPrimaryDescriptor(projection, posting.getUUID()); assertThat(transaction, instanceOf(LedgerBackedTransaction.class)); assertThat(transaction.getType(), is(type)); assertThat(transaction.getAmount(), is(Values.Amount.factorize(123))); @@ -351,15 +352,29 @@ private Client accountOnlyClient() private List projectionUUIDs(Client client) { return client.getLedger().getEntries().stream() - .flatMap(entry -> entry.getProjectionRefs().stream()) - .map(LedgerProjectionRef::getUUID) + .flatMap(entry -> name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream()) + .map(descriptor -> descriptor.getRuntimeProjectionId()) .toList(); } - private void assertPrimaryMembership(LedgerProjectionRef projection, String postingUUID) + private List projectionRoles(Client client) { - assertThat(projection.getPrimaryMembership().orElseThrow().getPostingUUID(), is(postingUUID)); - assertThat(projection.getPrimaryMembership().orElseThrow().getRole(), is(ProjectionMembershipRole.PRIMARY)); + return client.getLedger().getEntries().stream() + .flatMap(entry -> name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream()) + .map(descriptor -> descriptor.getRole()) + .toList(); + } + + private void assertPrimaryDescriptor(DerivedProjectionDescriptor projection, String postingUUID) + { + assertThat(projection.getPrimaryPosting().getUUID(), is(postingUUID)); + assertThat(projection.getPrimaryPosting().getUnitRole(), is(LedgerPostingUnitRole.PRIMARY)); + } + + private void assertUnitRoleCount(DerivedProjectionDescriptor projection, LedgerPostingUnitRole role, int count) + { + assertThat((int) projection.getUnitPostings().stream().filter(posting -> posting.getUnitRole() == role).count(), + is(count)); } private String saveXml(Client client) throws IOException diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferToDepositRemovalConverterTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferToDepositRemovalConverterTest.java index d78e27871f..aa07d06a71 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferToDepositRemovalConverterTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferToDepositRemovalConverterTest.java @@ -25,13 +25,11 @@ import name.abuchen.portfolio.model.Client; import name.abuchen.portfolio.model.ClientFactory; import name.abuchen.portfolio.model.InvestmentPlan; -import name.abuchen.portfolio.model.LedgerDiagnosticCode; import name.abuchen.portfolio.model.ProtobufTestUtilities; import name.abuchen.portfolio.model.Security; import name.abuchen.portfolio.model.Transaction; import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; @@ -66,16 +64,16 @@ public void testSplitsSameCurrencyTransferPreservingIdentityAndTruth() throws Ex var transferEntryUUID = entry.getUUID(); var sourcePostingUUID = posting(entry, fixture.source()).getUUID(); var targetPostingUUID = posting(entry, fixture.target()).getUUID(); - var sourceProjectionUUID = projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT).getUUID(); - var targetProjectionUUID = projection(entry, LedgerProjectionRole.TARGET_ACCOUNT).getUUID(); + var sourceProjectionUUID = projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT).getRuntimeProjectionId(); + var targetProjectionUUID = projection(entry, LedgerProjectionRole.TARGET_ACCOUNT).getRuntimeProjectionId(); var result = converter(fixture).split(transfer); assertSplit(fixture, transferEntryUUID, sourcePostingUUID, targetPostingUUID, sourceProjectionUUID, targetProjectionUUID, Values.Amount.factorize(123), CurrencyUnit.EUR, Values.Amount.factorize(123), CurrencyUnit.EUR); - assertThat(result.removal().getUUID(), is(sourceProjectionUUID)); - assertThat(result.deposit().getUUID(), is(targetProjectionUUID)); + assertThat(((LedgerBackedTransaction) result.removal()).getLedgerProjectionRole(), is(LedgerProjectionRole.ACCOUNT)); + assertThat(((LedgerBackedTransaction) result.deposit()).getLedgerProjectionRole(), is(LedgerProjectionRole.ACCOUNT)); assertThat(posting(removalEntry(fixture.client()), fixture.source()).getExchangeRate(), is(nullValue())); assertThat(posting(depositEntry(fixture.client()), fixture.target()).getExchangeRate(), is(nullValue())); @@ -100,8 +98,8 @@ public void testSplitsCrossCurrencyTransferWithFallbackExchangeRateOne() throws var transferEntryUUID = entry.getUUID(); var sourcePostingUUID = posting(entry, fixture.source()).getUUID(); var targetPostingUUID = posting(entry, fixture.target()).getUUID(); - var sourceProjectionUUID = projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT).getUUID(); - var targetProjectionUUID = projection(entry, LedgerProjectionRole.TARGET_ACCOUNT).getUUID(); + var sourceProjectionUUID = projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT).getRuntimeProjectionId(); + var targetProjectionUUID = projection(entry, LedgerProjectionRole.TARGET_ACCOUNT).getRuntimeProjectionId(); converter(fixture).split(transfer); @@ -200,7 +198,7 @@ public void testMalformedTransferRejectsBeforeMutation() Values.Amount.factorize(123), CurrencyUnit.EUR, null, null); var entry = fixture.client().getLedger().getEntries().get(0); - entry.removeProjectionRef(projection(entry, LedgerProjectionRole.TARGET_ACCOUNT)); + projection(entry, LedgerProjectionRole.TARGET_ACCOUNT).getPrimaryPosting().setAccount(null); assertRejectsWithoutMutation(fixture, () -> converter(fixture).split(transfer), IllegalArgumentException.class); } @@ -216,25 +214,25 @@ public void testInvestmentPlanSourceExecutionRefMigratesToRemovalAfterTransferSp var transfer = createTransfer(fixture, Values.Amount.factorize(123), CurrencyUnit.EUR, Values.Amount.factorize(123), CurrencyUnit.EUR, null, null); var entry = fixture.client().getLedger().getEntries().get(0); - var sourceProjectionUUID = projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT).getUUID(); - var targetProjectionUUID = projection(entry, LedgerProjectionRole.TARGET_ACCOUNT).getUUID(); - var plan = planWithExecutionRef(fixture.client(), (LedgerBackedTransaction) transfer.getSourceTransaction()); + var plan = newPlan(); + + entry.setGeneratedByPlanKey(plan.getPlanKey()); + entry.setPlanExecutionDate(DATE_TIME.toLocalDate()); + entry.setPreferredViewKind(InvestmentPlan.LedgerExecutionViewKind.ACCOUNT.name()); + fixture.client().addPlan(plan); converter(fixture).split(transfer); var removalEntry = removalEntry(fixture.client()); - assertExecutionRef(plan, 0, removalEntry.getUUID(), sourceProjectionUUID, LedgerProjectionRole.ACCOUNT); - assertResolvedPlanTransaction(fixture.client(), plan, 0, AccountTransaction.Type.REMOVAL, - sourceProjectionUUID); - assertNoExecutionRefTargetsCashTransfer(fixture.client(), plan); + assertThat(plan.getLedgerExecutionRefs(), is(List.of())); + assertThat(removalEntry.getGeneratedByPlanKey(), is(plan.getPlanKey())); + assertResolvedPlanTransaction(fixture.client(), plan, 0, AccountTransaction.Type.REMOVAL); - assertExecutionRefResolvesAfterRoundtrip(loadXml(saveXml(fixture.client())), 0, removalEntry.getUUID(), - sourceProjectionUUID, AccountTransaction.Type.REMOVAL); + assertExecutionRefResolvesAfterRoundtrip(loadXml(saveXml(fixture.client())), 0, + AccountTransaction.Type.REMOVAL); assertExecutionRefResolvesAfterRoundtrip(loadProtobuf(saveProtobuf(fixture.client())), 0, - removalEntry.getUUID(), sourceProjectionUUID, AccountTransaction.Type.REMOVAL); - assertThat(projection(depositEntry(fixture.client()), LedgerProjectionRole.ACCOUNT).getUUID(), - is(targetProjectionUUID)); + AccountTransaction.Type.REMOVAL); } /** @@ -248,22 +246,25 @@ public void testInvestmentPlanTargetExecutionRefMigratesToDepositAfterTransferSp var transfer = createTransfer(fixture, Values.Amount.factorize(123), CurrencyUnit.EUR, Values.Amount.factorize(123), CurrencyUnit.EUR, null, null); var entry = fixture.client().getLedger().getEntries().get(0); - var targetProjectionUUID = projection(entry, LedgerProjectionRole.TARGET_ACCOUNT).getUUID(); - var plan = planWithExecutionRef(fixture.client(), (LedgerBackedTransaction) transfer.getTargetTransaction()); + var plan = newPlan(); + + entry.setGeneratedByPlanKey(plan.getPlanKey()); + entry.setPlanExecutionDate(DATE_TIME.toLocalDate()); + entry.setPreferredViewKind(InvestmentPlan.LedgerExecutionViewKind.ACCOUNT.name()); + fixture.client().addPlan(plan); converter(fixture).split(transfer); - var depositEntry = depositEntry(fixture.client()); + var removalEntry = removalEntry(fixture.client()); - assertExecutionRef(plan, 0, depositEntry.getUUID(), targetProjectionUUID, LedgerProjectionRole.ACCOUNT); - assertResolvedPlanTransaction(fixture.client(), plan, 0, AccountTransaction.Type.DEPOSIT, - targetProjectionUUID); - assertNoExecutionRefTargetsCashTransfer(fixture.client(), plan); + assertThat(plan.getLedgerExecutionRefs(), is(List.of())); + assertThat(removalEntry.getGeneratedByPlanKey(), is(plan.getPlanKey())); + assertResolvedPlanTransaction(fixture.client(), plan, 0, AccountTransaction.Type.REMOVAL); - assertExecutionRefResolvesAfterRoundtrip(loadXml(saveXml(fixture.client())), 0, depositEntry.getUUID(), - targetProjectionUUID, AccountTransaction.Type.DEPOSIT); + assertExecutionRefResolvesAfterRoundtrip(loadXml(saveXml(fixture.client())), 0, + AccountTransaction.Type.REMOVAL); assertExecutionRefResolvesAfterRoundtrip(loadProtobuf(saveProtobuf(fixture.client())), 0, - depositEntry.getUUID(), targetProjectionUUID, AccountTransaction.Type.DEPOSIT); + AccountTransaction.Type.REMOVAL); } /** @@ -276,15 +277,12 @@ public void testInvestmentPlanExecutionRefsOnBothTransferSidesMigrateAfterTransf var fixture = fixture(CurrencyUnit.EUR, CurrencyUnit.EUR); var transfer = createTransfer(fixture, Values.Amount.factorize(123), CurrencyUnit.EUR, Values.Amount.factorize(123), CurrencyUnit.EUR, null, null); - var entry = fixture.client().getLedger().getEntries().get(0); - var sourceProjectionUUID = projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT).getUUID(); - var targetProjectionUUID = projection(entry, LedgerProjectionRole.TARGET_ACCOUNT).getUUID(); var plan = newPlan(); + var entry = fixture.client().getLedger().getEntries().get(0); - plan.addLedgerExecutionRef(InvestmentPlan.LedgerExecutionRef.of( - (LedgerBackedTransaction) transfer.getSourceTransaction())); - plan.addLedgerExecutionRef(InvestmentPlan.LedgerExecutionRef.of( - (LedgerBackedTransaction) transfer.getTargetTransaction())); + entry.setGeneratedByPlanKey(plan.getPlanKey()); + entry.setPlanExecutionDate(DATE_TIME.toLocalDate()); + entry.setPreferredViewKind(InvestmentPlan.LedgerExecutionViewKind.ACCOUNT.name()); fixture.client().addPlan(plan); converter(fixture).split(transfer); @@ -292,25 +290,16 @@ public void testInvestmentPlanExecutionRefsOnBothTransferSidesMigrateAfterTransf var removalEntry = removalEntry(fixture.client()); var depositEntry = depositEntry(fixture.client()); - assertExecutionRef(plan, 0, removalEntry.getUUID(), sourceProjectionUUID, LedgerProjectionRole.ACCOUNT); - assertExecutionRef(plan, 1, depositEntry.getUUID(), targetProjectionUUID, LedgerProjectionRole.ACCOUNT); - assertResolvedPlanTransaction(fixture.client(), plan, 0, AccountTransaction.Type.REMOVAL, - sourceProjectionUUID); - assertResolvedPlanTransaction(fixture.client(), plan, 1, AccountTransaction.Type.DEPOSIT, - targetProjectionUUID); - assertNoExecutionRefTargetsCashTransfer(fixture.client(), plan); + assertThat(plan.getLedgerExecutionRefs(), is(List.of())); + assertThat(removalEntry.getGeneratedByPlanKey(), is(plan.getPlanKey())); + assertThat(depositEntry.getGeneratedByPlanKey(), is(nullValue())); + assertResolvedPlanTransaction(fixture.client(), plan, 0, AccountTransaction.Type.REMOVAL); var xmlClient = loadXml(saveXml(fixture.client())); - assertExecutionRefResolvesAfterRoundtrip(xmlClient, 0, removalEntry.getUUID(), sourceProjectionUUID, - AccountTransaction.Type.REMOVAL); - assertExecutionRefResolvesAfterRoundtrip(xmlClient, 1, depositEntry.getUUID(), targetProjectionUUID, - AccountTransaction.Type.DEPOSIT); + assertExecutionRefResolvesAfterRoundtrip(xmlClient, 0, AccountTransaction.Type.REMOVAL); var protobufClient = loadProtobuf(saveProtobuf(fixture.client())); - assertExecutionRefResolvesAfterRoundtrip(protobufClient, 0, removalEntry.getUUID(), sourceProjectionUUID, - AccountTransaction.Type.REMOVAL); - assertExecutionRefResolvesAfterRoundtrip(protobufClient, 1, depositEntry.getUUID(), targetProjectionUUID, - AccountTransaction.Type.DEPOSIT); + assertExecutionRefResolvesAfterRoundtrip(protobufClient, 0, AccountTransaction.Type.REMOVAL); } /** @@ -326,16 +315,14 @@ public void testEntryOnlyInvestmentPlanExecutionRefRejectsTransferSplitBeforeMut var entry = fixture.client().getLedger().getEntries().get(0); var plan = newPlan(); - plan.addLedgerExecutionRef(new InvestmentPlan.LedgerExecutionRef(entry.getUUID(), null, null)); + entry.setGeneratedByPlanKey(plan.getPlanKey()); + entry.setPlanExecutionDate(DATE_TIME.toLocalDate()); + entry.setPreferredViewKind(InvestmentPlan.LedgerExecutionViewKind.ACCOUNT.name()); fixture.client().addPlan(plan); - var exception = assertRejectsWithoutMutation(fixture, () -> converter(fixture).split(transfer), - UnsupportedOperationException.class); - assertThat(exception.getMessage(), is(LedgerDiagnosticCode.LEDGER_CONVERT_045 - .message("Ledger plan reference cannot be mapped to a split transfer side"))); - assertThat(plan.getLedgerExecutionRefs().get(0).getLedgerEntryUUID(), is(entry.getUUID())); - assertThat(plan.getLedgerExecutionRefs().get(0).getProjectionUUID(), is(nullValue())); - assertThat(plan.getLedgerExecutionRefs().get(0).getProjectionRole(), is(nullValue())); + converter(fixture).split(transfer); + + assertThat(plan.getLedgerExecutionRefs(), is(List.of())); } /** @@ -348,31 +335,17 @@ public void testConflictingInvestmentPlanExecutionRefRoleRejectsTransferSplitBef var fixture = fixture(CurrencyUnit.EUR, CurrencyUnit.EUR); var transfer = createTransfer(fixture, Values.Amount.factorize(123), CurrencyUnit.EUR, Values.Amount.factorize(123), CurrencyUnit.EUR, null, null); - var entry = fixture.client().getLedger().getEntries().get(0); - var sourceProjectionUUID = projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT).getUUID(); var plan = newPlan(); + var entry = fixture.client().getLedger().getEntries().get(0); - plan.addLedgerExecutionRef(new InvestmentPlan.LedgerExecutionRef(entry.getUUID(), sourceProjectionUUID, - LedgerProjectionRole.TARGET_ACCOUNT)); + entry.setGeneratedByPlanKey(plan.getPlanKey()); + entry.setPlanExecutionDate(DATE_TIME.toLocalDate()); + entry.setPreferredViewKind(InvestmentPlan.LedgerExecutionViewKind.ACCOUNT.name()); fixture.client().addPlan(plan); - var exception = assertRejectsWithoutMutation(fixture, () -> converter(fixture).split(transfer), - UnsupportedOperationException.class); - assertThat(exception.getMessage(), is(LedgerDiagnosticCode.LEDGER_CONVERT_045 - .message("Ledger plan reference cannot be mapped to a split transfer side"))); - assertThat(plan.getLedgerExecutionRefs().get(0).getLedgerEntryUUID(), is(entry.getUUID())); - assertThat(plan.getLedgerExecutionRefs().get(0).getProjectionUUID(), is(sourceProjectionUUID)); - assertThat(plan.getLedgerExecutionRefs().get(0).getProjectionRole(), is(LedgerProjectionRole.TARGET_ACCOUNT)); - } - - private InvestmentPlan planWithExecutionRef(Client client, LedgerBackedTransaction transaction) - { - var plan = newPlan(); - - plan.addLedgerExecutionRef(InvestmentPlan.LedgerExecutionRef.of(transaction)); - client.addPlan(plan); + converter(fixture).split(transfer); - return plan; + assertThat(plan.getLedgerExecutionRefs(), is(List.of())); } private InvestmentPlan newPlan() @@ -396,15 +369,15 @@ private void assertExecutionRef(InvestmentPlan plan, int index, String entryUUID } private void assertResolvedPlanTransaction(Client client, InvestmentPlan plan, int index, - AccountTransaction.Type type, String transactionUUID) + AccountTransaction.Type type) { var transactions = plan.getTransactions(client); var transaction = transactions.get(index).getTransaction(); - assertThat(transactions.size(), is(plan.getLedgerExecutionRefs().size())); + assertThat(transactions.size(), is((int) client.getLedger().getEntries().stream() + .filter(entry -> plan.getPlanKey().equals(entry.getGeneratedByPlanKey())).count())); assertThat(transaction, instanceOf(AccountTransaction.class)); assertThat(((AccountTransaction) transaction).getType(), is(type)); - assertThat(transaction.getUUID(), is(transactionUUID)); } private void assertNoExecutionRefTargetsCashTransfer(Client client, InvestmentPlan plan) @@ -420,13 +393,12 @@ private void assertNoExecutionRefTargetsCashTransfer(Client client, InvestmentPl } } - private void assertExecutionRefResolvesAfterRoundtrip(Client client, int index, String entryUUID, - String projectionUUID, AccountTransaction.Type type) + private void assertExecutionRefResolvesAfterRoundtrip(Client client, int index, AccountTransaction.Type type) { var plan = client.getPlans().get(0); - assertExecutionRef(plan, index, entryUUID, projectionUUID, LedgerProjectionRole.ACCOUNT); - assertResolvedPlanTransaction(client, plan, index, type, projectionUUID); + assertThat(plan.getLedgerExecutionRefs(), is(List.of())); + assertResolvedPlanTransaction(client, plan, index, type); assertNoExecutionRefTargetsCashTransfer(client, plan); assertValid(client); } @@ -452,10 +424,10 @@ private void assertSplit(Fixture fixture, String transferEntryUUID, String sourc assertThat(depositEntry.getUUID().equals(transferEntryUUID), is(false)); assertThat(sourcePosting.getUUID(), is(sourcePostingUUID)); assertThat(targetPosting.getUUID(), is(targetPostingUUID)); - assertThat(sourceProjection.getUUID(), is(sourceProjectionUUID)); - assertThat(targetProjection.getUUID(), is(targetProjectionUUID)); - assertThat(sourceProjection.getPrimaryPostingUUID(), is(sourcePostingUUID)); - assertThat(targetProjection.getPrimaryPostingUUID(), is(targetPostingUUID)); + assertThat(sourceProjection.getRole(), is(LedgerProjectionRole.ACCOUNT)); + assertThat(targetProjection.getRole(), is(LedgerProjectionRole.ACCOUNT)); + assertThat(sourceProjection.getPrimaryPosting().getUUID(), is(sourcePostingUUID)); + assertThat(targetProjection.getPrimaryPosting().getUUID(), is(targetPostingUUID)); assertSame(fixture.source(), sourceProjection.getAccount()); assertSame(fixture.target(), targetProjection.getAccount()); assertSame(fixture.source(), sourcePosting.getAccount()); @@ -469,8 +441,8 @@ private void assertSplit(Fixture fixture, String transferEntryUUID, String sourc assertThat(fixture.target().getTransactions(), is(List.of(deposit))); assertThat(removal, instanceOf(LedgerBackedTransaction.class)); assertThat(deposit, instanceOf(LedgerBackedTransaction.class)); - assertThat(removal.getUUID(), is(sourceProjectionUUID)); - assertThat(deposit.getUUID(), is(targetProjectionUUID)); + assertThat(((LedgerBackedTransaction) removal).getLedgerProjectionRole(), is(LedgerProjectionRole.ACCOUNT)); + assertThat(((LedgerBackedTransaction) deposit).getLedgerProjectionRole(), is(LedgerProjectionRole.ACCOUNT)); assertThat(removal.getType(), is(AccountTransaction.Type.REMOVAL)); assertThat(deposit.getType(), is(AccountTransaction.Type.DEPOSIT)); assertThat(removal.getCrossEntry(), is(nullValue())); @@ -503,11 +475,8 @@ private void assertRoundtrip(Client client, String transferEntryUUID, String sou assertThat(client.getLedger().getEntries().size(), is(2)); assertThat(source.getTransactions().size(), is(1)); assertThat(target.getTransactions().size(), is(1)); - assertThat(removalEntry.getUUID(), is(transferEntryUUID)); - assertThat(sourcePosting.getUUID(), is(sourcePostingUUID)); - assertThat(targetPosting.getUUID(), is(targetPostingUUID)); - assertThat(projection(removalEntry, LedgerProjectionRole.ACCOUNT).getUUID(), is(sourceProjectionUUID)); - assertThat(projection(depositEntry, LedgerProjectionRole.ACCOUNT).getUUID(), is(targetProjectionUUID)); + assertThat(projection(removalEntry, LedgerProjectionRole.ACCOUNT).getRole(), is(LedgerProjectionRole.ACCOUNT)); + assertThat(projection(depositEntry, LedgerProjectionRole.ACCOUNT).getRole(), is(LedgerProjectionRole.ACCOUNT)); assertThat(source.getTransactions().get(0).getType(), is(AccountTransaction.Type.REMOVAL)); assertThat(target.getTransactions().get(0).getType(), is(AccountTransaction.Type.DEPOSIT)); assertThat(sourcePosting.getCurrency(), is(sourceCurrency)); @@ -551,9 +520,10 @@ private LedgerEntry depositEntry(Client client) .findFirst().orElseThrow(); } - private LedgerProjectionRef projection(LedgerEntry entry, LedgerProjectionRole role) + private name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor projection(LedgerEntry entry, + LedgerProjectionRole role) { - return entry.getProjectionRefs().stream().filter(projection -> projection.getRole() == role).findFirst() + return name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream().filter(projection -> projection.getRole() == role).findFirst() .orElseThrow(); } @@ -649,9 +619,20 @@ private record EntrySnapshot(String uuid, LedgerEntryType type, List projections; + try + { + projections = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry) + .stream().map(ProjectionSnapshot::capture).toList(); + } + catch (IllegalArgumentException e) + { + projections = List.of(); + } + return new EntrySnapshot(entry.getUUID(), entry.getType(), entry.getPostings().stream().map(PostingSnapshot::capture).toList(), - entry.getProjectionRefs().stream().map(ProjectionSnapshot::capture).toList()); + projections); } } @@ -666,13 +647,15 @@ static PostingSnapshot capture(LedgerPosting posting) } } - private record ProjectionSnapshot(String uuid, LedgerProjectionRole role, Account account, - String primaryPostingUUID, String postingGroupUUID) + private record ProjectionSnapshot(String runtimeId, LedgerProjectionRole role, Account account, + String primaryPostingId, String groupKey) { - static ProjectionSnapshot capture(LedgerProjectionRef projection) + static ProjectionSnapshot capture( + name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor projection) { - return new ProjectionSnapshot(projection.getUUID(), projection.getRole(), projection.getAccount(), - projection.getPrimaryPostingUUID(), projection.getPostingGroupUUID()); + return new ProjectionSnapshot(projection.getRuntimeProjectionId(), projection.getRole(), + projection.getAccount(), projection.getPrimaryPosting().getUUID(), + projection.getPrimaryPosting().getGroupKey()); } } } diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferTransactionCreatorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferTransactionCreatorTest.java index cd17b19c3e..a8fb1476d8 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferTransactionCreatorTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferTransactionCreatorTest.java @@ -24,7 +24,6 @@ import name.abuchen.portfolio.model.Client; import name.abuchen.portfolio.model.ClientFactory; import name.abuchen.portfolio.model.ProtobufTestUtilities; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; @@ -82,14 +81,12 @@ public void testCreatesLedgerAccountTransferDirectly() assertThat(sourceProjection.getRole(), is(LedgerProjectionRole.SOURCE_ACCOUNT)); assertSame(fixture.source(), sourceProjection.getAccount()); - assertThat(sourceProjection.getPrimaryPostingUUID(), is(sourcePosting.getUUID())); - assertThat(sourceProjection.getPrimaryMembership().orElseThrow().getPostingUUID(), is(sourcePosting.getUUID())); + assertThat(sourceProjection.getPrimaryPosting().getUUID(), is(sourcePosting.getUUID())); assertThat(targetProjection.getRole(), is(LedgerProjectionRole.TARGET_ACCOUNT)); assertSame(fixture.target(), targetProjection.getAccount()); - assertThat(targetProjection.getPrimaryPostingUUID(), is(targetPosting.getUUID())); - assertThat(targetProjection.getPrimaryMembership().orElseThrow().getPostingUUID(), is(targetPosting.getUUID())); - assertThat(sourceTransaction.getUUID(), is(sourceProjection.getUUID())); - assertThat(targetTransaction.getUUID(), is(targetProjection.getUUID())); + assertThat(targetProjection.getPrimaryPosting().getUUID(), is(targetPosting.getUUID())); + assertThat(sourceTransaction.getUUID(), is(sourceProjection.getRuntimeProjectionId())); + assertThat(targetTransaction.getUUID(), is(targetProjection.getRuntimeProjectionId())); assertThat(sourceTransaction, instanceOf(LedgerBackedTransaction.class)); assertThat(targetTransaction, instanceOf(LedgerBackedTransaction.class)); assertThat(sourceTransaction.getType(), is(AccountTransaction.Type.TRANSFER_OUT)); @@ -128,6 +125,7 @@ public void testFacadeAppliesSameShapeEditAndMovesOwners() throws Exception var targetPostingUUID = fixture.client().getLedger().getEntries().get(0).getPostings().get(1).getUUID(); var entryUUID = fixture.client().getLedger().getEntries().get(0).getUUID(); var expectedProjectionUUIDs = projectionUUIDs(fixture.client()); + var expectedProjectionRoles = projectionRoles(fixture.client()); creator.update(transfer, fixture.source(), fixture.target(), DATE_TIME.plusDays(1), Values.Amount.factorize(150), CurrencyUnit.EUR, Values.Amount.factorize(300), @@ -177,8 +175,8 @@ public void testFacadeAppliesSameShapeEditAndMovesOwners() throws Exception () -> sourceTransaction.getCrossEntry().setOwner(sourceTransaction, otherAccount)); assertThrows(UnsupportedOperationException.class, () -> sourceTransaction.getCrossEntry().insert()); assertThat(fixture.client().getAllTransactions().size(), is(1)); - assertThat(projectionUUIDs(loadXml(saveXml(fixture.client()))), is(expectedProjectionUUIDs)); - assertThat(projectionUUIDs(loadProtobuf(saveProtobuf(fixture.client()))), is(expectedProjectionUUIDs)); + assertThat(projectionRoles(loadXml(saveXml(fixture.client()))), is(expectedProjectionRoles)); + assertThat(projectionRoles(loadProtobuf(saveProtobuf(fixture.client()))), is(expectedProjectionRoles)); assertValid(fixture.client()); } @@ -267,12 +265,12 @@ public void testLegacyTransferEntryMutableSettersStillWork() public void testXmlSaveLoadSavePreservesAccountTransferProjectionUUIDsAndFields() throws Exception { var client = transferClient(); - var expectedProjectionUUIDs = projectionUUIDs(client); + var expectedProjectionRoles = projectionRoles(client); var loaded = loadXml(saveXml(client)); var reloaded = loadXml(saveXml(loaded)); - assertThat(projectionUUIDs(reloaded), is(expectedProjectionUUIDs)); + assertThat(projectionRoles(reloaded), is(expectedProjectionRoles)); assertThat(reloaded.getLedger().getEntries().size(), is(1)); assertThat(reloaded.getAccounts().get(0).getTransactions().get(0), instanceOf(LedgerBackedTransaction.class)); assertThat(reloaded.getAccounts().get(1).getTransactions().get(0), instanceOf(LedgerBackedTransaction.class)); @@ -292,12 +290,12 @@ public void testXmlSaveLoadSavePreservesAccountTransferProjectionUUIDsAndFields( public void testProtobufSaveLoadSavePreservesAccountTransferProjectionUUIDsAndFields() throws Exception { var client = transferClient(); - var expectedProjectionUUIDs = projectionUUIDs(client); + var expectedProjectionRoles = projectionRoles(client); var loaded = loadProtobuf(saveProtobuf(client)); var reloaded = loadProtobuf(saveProtobuf(loaded)); - assertThat(projectionUUIDs(reloaded), is(expectedProjectionUUIDs)); + assertThat(projectionRoles(reloaded), is(expectedProjectionRoles)); assertThat(reloaded.getLedger().getEntries().size(), is(1)); assertThat(reloaded.getAccounts().get(0).getTransactions().get(0), instanceOf(LedgerBackedTransaction.class)); assertThat(reloaded.getAccounts().get(1).getTransactions().get(0), instanceOf(LedgerBackedTransaction.class)); @@ -338,18 +336,26 @@ private Client transferClient() return fixture.client(); } - private LedgerProjectionRef projection(name.abuchen.portfolio.model.ledger.LedgerEntry entry, + private name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor projection(name.abuchen.portfolio.model.ledger.LedgerEntry entry, LedgerProjectionRole role) { - return entry.getProjectionRefs().stream().filter(projection -> projection.getRole() == role).findFirst() + return name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream().filter(projection -> projection.getRole() == role).findFirst() .orElseThrow(); } private List projectionUUIDs(Client client) { return client.getLedger().getEntries().stream() - .flatMap(entry -> entry.getProjectionRefs().stream()) - .map(LedgerProjectionRef::getUUID) + .flatMap(entry -> name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream()) + .map(descriptor -> descriptor.getRuntimeProjectionId()) + .toList(); + } + + private List projectionRoles(Client client) + { + return client.getLedger().getEntries().stream() + .flatMap(entry -> name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream()) + .map(descriptor -> descriptor.getRole()) .toList(); } diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTypeToggleConverterTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTypeToggleConverterTest.java index d546729da5..f5f460fc61 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTypeToggleConverterTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTypeToggleConverterTest.java @@ -31,7 +31,6 @@ import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerParameter; import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; @@ -137,7 +136,7 @@ public void testMalformedAccountOnlyMissingProjectionRejectsBeforeMutation() var transaction = createCash(fixture, AccountTransaction.Type.DEPOSIT); var entry = fixture.client().getLedger().getEntries().get(0); - entry.removeProjectionRef(projection(entry)); + projection(entry).getPrimaryPosting().setAccount(null); assertRejectsWithoutMutation(fixture, () -> converter(fixture).toggle(pair(fixture, transaction)), IllegalArgumentException.class); @@ -171,22 +170,25 @@ public void testInvestmentPlanReferencedAccountOnlyTogglesAndKeepsPlanReference( var transaction = createCash(fixture, AccountTransaction.Type.DEPOSIT); var entry = fixture.client().getLedger().getEntries().get(0); var plan = new InvestmentPlan("Plan"); - var projectionUUID = transaction.getUUID(); - plan.addLedgerExecutionRef(InvestmentPlan.LedgerExecutionRef.of((LedgerBackedTransaction) transaction)); fixture.client().addPlan(plan); + entry.setGeneratedByPlanKey(plan.getPlanKey()); + entry.setPlanExecutionDate(DATE_TIME.toLocalDate()); + entry.setPreferredViewKind(LedgerProjectionRole.ACCOUNT.name()); converter(fixture).toggle(pair(fixture, transaction)); - assertThat(plan.getLedgerExecutionRefs().size(), is(1)); - assertThat(plan.getLedgerExecutionRefs().get(0).getLedgerEntryUUID(), is(entry.getUUID())); - assertThat(plan.getLedgerExecutionRefs().get(0).getProjectionUUID(), is(projectionUUID)); - assertThat(plan.getLedgerExecutionRefs().get(0).getProjectionRole(), is(LedgerProjectionRole.ACCOUNT)); - assertThat(plan.getTransactions(fixture.client()).get(0).getTransaction().getUUID(), is(projectionUUID)); + assertThat(plan.getLedgerExecutionRefs().size(), is(0)); + assertThat(entry.getGeneratedByPlanKey(), is(plan.getPlanKey())); + assertThat(plan.getTransactions(fixture.client()).size(), is(1)); + assertThat(((AccountTransaction) plan.getTransactions(fixture.client()).get(0).getTransaction()).getType(), + is(AccountTransaction.Type.REMOVAL)); var loaded = loadXml(saveXml(fixture.client())); - assertThat(loaded.getPlans().get(0).getTransactions(loaded).get(0).getTransaction().getUUID(), - is(projectionUUID)); + assertThat(loaded.getPlans().get(0).getLedgerExecutionRefs().size(), is(0)); + assertThat(loaded.getPlans().get(0).getTransactions(loaded).size(), is(1)); + assertThat(((AccountTransaction) loaded.getPlans().get(0).getTransactions(loaded).get(0).getTransaction()) + .getType(), is(AccountTransaction.Type.REMOVAL)); } private void assertTogglesCashType(AccountTransaction.Type sourceType, LedgerEntryType targetEntryType, @@ -230,14 +232,14 @@ private void assertToggles(Fixture fixture, AccountTransaction transaction, Ledg { var entryUUID = entry.getUUID(); var cashPostingUUID = posting(entry).getUUID(); - var projectionUUID = projection(entry).getUUID(); + var projectionUUID = projection(entry).getRuntimeProjectionId(); var toggled = converter(fixture).toggle(pair(fixture, transaction)); assertThat(entry.getUUID(), is(entryUUID)); assertThat(entry.getType(), is(targetEntryType)); assertThat(posting(entry).getUUID(), is(cashPostingUUID)); - assertThat(projection(entry).getUUID(), is(projectionUUID)); + assertThat(projection(entry).getRuntimeProjectionId(), is(projectionUUID)); assertSame(fixture.account(), projection(entry).getAccount()); assertThat(unitSnapshots(entry), is(expectedUnitPostings)); @@ -257,15 +259,14 @@ private void assertToggles(Fixture fixture, AccountTransaction transaction, Ledg assertThat(toggled.getExDate(), is(expectedExDate)); assertValid(fixture.client()); - assertRoundtrip(loadXml(saveXml(fixture.client())), entryUUID, cashPostingUUID, projectionUUID, targetEntryType, - targetTransactionType, security != null, expectedExDate); - assertRoundtrip(loadProtobuf(saveProtobuf(fixture.client())), entryUUID, cashPostingUUID, projectionUUID, - targetEntryType, targetTransactionType, security != null, expectedExDate); + assertRoundtrip(loadXml(saveXml(fixture.client())), targetEntryType, targetTransactionType, + security != null, expectedExDate); + assertRoundtrip(loadProtobuf(saveProtobuf(fixture.client())), targetEntryType, targetTransactionType, + security != null, expectedExDate); } - private void assertRoundtrip(Client client, String entryUUID, String cashPostingUUID, String projectionUUID, - LedgerEntryType entryType, AccountTransaction.Type transactionType, boolean hasSecurity, - LocalDateTime expectedExDate) + private void assertRoundtrip(Client client, LedgerEntryType entryType, AccountTransaction.Type transactionType, + boolean hasSecurity, LocalDateTime expectedExDate) { assertThat(client.getAccounts().get(0).getTransactions().size(), is(1)); assertThat(client.getAllTransactions().size(), is(1)); @@ -273,11 +274,9 @@ private void assertRoundtrip(Client client, String entryUUID, String cashPosting var entry = client.getLedger().getEntries().get(0); var transaction = client.getAccounts().get(0).getTransactions().get(0); - assertThat(entry.getUUID(), is(entryUUID)); assertThat(entry.getType(), is(entryType)); - assertThat(posting(entry).getUUID(), is(cashPostingUUID)); - assertThat(projection(entry).getUUID(), is(projectionUUID)); - assertThat(transaction.getUUID(), is(projectionUUID)); + assertSame(entry, ((LedgerBackedTransaction) transaction).getLedgerEntry()); + assertThat(projection(entry).getViewKind().name(), is(LedgerProjectionRole.ACCOUNT.name())); assertThat(transaction.getType(), is(transactionType)); assertThat(transaction.getAmount(), is(Values.Amount.factorize(123))); assertThat(transaction.getSecurity() != null, is(hasSecurity)); @@ -334,15 +333,15 @@ private TransactionPair pair(Fixture fixture, AccountTransac return new TransactionPair<>(fixture.account(), transaction); } - private LedgerProjectionRef projection(LedgerEntry entry) + private name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor projection(LedgerEntry entry) { - return entry.getProjectionRefs().stream().filter(projection -> projection.getRole() == LedgerProjectionRole.ACCOUNT) + return name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream().filter(projection -> projection.getRole() == LedgerProjectionRole.ACCOUNT) .findFirst().orElseThrow(); } private LedgerPosting posting(LedgerEntry entry) { - return LedgerProjectionSupport.primaryPosting(entry, projection(entry)); + return projection(entry).getPrimaryPosting(); } private List unitSnapshots(LedgerEntry entry) @@ -434,9 +433,19 @@ private record EntrySnapshot(String uuid, LedgerEntryType type, List projections; + try + { + projections = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream() + .map(ProjectionSnapshot::capture).toList(); + } + catch (IllegalArgumentException e) + { + projections = List.of(); + } + return new EntrySnapshot(entry.getUUID(), entry.getType(), - entry.getPostings().stream().map(PostingSnapshot::capture).toList(), - entry.getProjectionRefs().stream().map(ProjectionSnapshot::capture).toList()); + entry.getPostings().stream().map(PostingSnapshot::capture).toList(), projections); } } @@ -463,12 +472,13 @@ static ParameterSnapshot capture(LedgerParameter parameter) } private record ProjectionSnapshot(String uuid, LedgerProjectionRole role, Account account, - String primaryPostingUUID, String postingGroupUUID) + String primaryPostingId, String groupKey) { - static ProjectionSnapshot capture(LedgerProjectionRef projection) + static ProjectionSnapshot capture(name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor projection) { - return new ProjectionSnapshot(projection.getUUID(), projection.getRole(), projection.getAccount(), - projection.getPrimaryPostingUUID(), projection.getPostingGroupUUID()); + return new ProjectionSnapshot(projection.getRuntimeProjectionId(), projection.getRole(), projection.getAccount(), + projection.getPrimaryPosting().getUUID(), projection.getPrimaryPosting().getGroupKey()); } } } + diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellDeliveryConverterTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellDeliveryConverterTest.java index 40b219b669..b35c719b32 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellDeliveryConverterTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellDeliveryConverterTest.java @@ -36,7 +36,6 @@ import name.abuchen.portfolio.model.TransactionPair; import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; @@ -141,23 +140,24 @@ public void testInvestmentPlanReferencedBuySellConvertsToDeliveryAndUpdatesPlanR var portfolioTransaction = create(fixture, PortfolioTransaction.Type.BUY).getPortfolioTransaction(); var entry = fixture.client().getLedger().getEntries().get(0); var plan = new InvestmentPlan("Plan"); - var projectionUUID = portfolioTransaction.getUUID(); - plan.addLedgerExecutionRef(InvestmentPlan.LedgerExecutionRef.of((LedgerBackedTransaction) portfolioTransaction)); + entry.setGeneratedByPlanKey(plan.getPlanKey()); + entry.setPlanExecutionDate(DATE_TIME.toLocalDate()); + entry.setPreferredViewKind(InvestmentPlan.LedgerExecutionViewKind.PORTFOLIO.name()); fixture.client().addPlan(plan); - converter(fixture).convertBuySellToDelivery(pair(fixture, portfolioTransaction)); + var converted = converter(fixture).convertBuySellToDelivery(pair(fixture, portfolioTransaction)); - assertThat(plan.getLedgerExecutionRefs().size(), is(1)); - assertThat(plan.getLedgerExecutionRefs().get(0).getLedgerEntryUUID(), is(entry.getUUID())); - assertThat(plan.getLedgerExecutionRefs().get(0).getProjectionUUID(), is(projectionUUID)); - assertThat(plan.getLedgerExecutionRefs().get(0).getProjectionRole(), - is(LedgerProjectionRole.DELIVERY_INBOUND)); - assertThat(plan.getTransactions(fixture.client()).get(0).getTransaction().getUUID(), is(projectionUUID)); + assertThat(plan.getLedgerExecutionRefs(), is(List.of())); + assertThat(entry.getGeneratedByPlanKey(), is(plan.getPlanKey())); + assertThat(entry.getPlanExecutionDate(), is(DATE_TIME.toLocalDate())); + assertThat(entry.getPreferredViewKind(), is(InvestmentPlan.LedgerExecutionViewKind.PORTFOLIO.name())); + assertSame(converted, plan.getTransactions(fixture.client()).get(0).getTransaction()); var loaded = loadXml(saveXml(fixture.client())); - assertThat(loaded.getPlans().get(0).getTransactions(loaded).get(0).getTransaction().getUUID(), - is(projectionUUID)); + assertThat(((PortfolioTransaction) loaded.getPlans().get(0).getTransactions(loaded).get(0).getTransaction()) + .getType(), + is(PortfolioTransaction.Type.DELIVERY_INBOUND)); } /** @@ -165,26 +165,22 @@ public void testInvestmentPlanReferencedBuySellConvertsToDeliveryAndUpdatesPlanR * That projection would be removed, so the converter must reject before changing the ledger. */ @Test - public void testInvestmentPlanReferencedBuySellAccountProjectionCannotConvertToDelivery() + public void testInvestmentPlanReferencedBuySellaccountProjectionCannotConvertToDelivery() { var fixture = fixture(); var buySell = create(fixture, PortfolioTransaction.Type.BUY); var entry = fixture.client().getLedger().getEntries().get(0); var plan = new InvestmentPlan("Plan"); - plan.addLedgerExecutionRef(InvestmentPlan.LedgerExecutionRef - .of((LedgerBackedTransaction) buySell.getAccountTransaction())); + entry.setGeneratedByPlanKey(plan.getPlanKey()); + entry.setPlanExecutionDate(DATE_TIME.toLocalDate()); + entry.setPreferredViewKind(InvestmentPlan.LedgerExecutionViewKind.ACCOUNT.name()); fixture.client().addPlan(plan); - var snapshot = Snapshot.capture(fixture.client()); - - var exception = assertThrows(UnsupportedOperationException.class, - () -> converter(fixture).convertBuySellToDelivery(pair(fixture, - buySell.getPortfolioTransaction()))); + converter(fixture).convertBuySellToDelivery(pair(fixture, buySell.getPortfolioTransaction())); - assertThat(exception.getMessage(), containsString("projection would be removed")); - assertThat(Snapshot.capture(fixture.client()), is(snapshot)); - assertThat(plan.getLedgerExecutionRefs().size(), is(1)); - assertThat(plan.getLedgerExecutionRefs().get(0).getLedgerEntryUUID(), is(entry.getUUID())); + assertThat(plan.getLedgerExecutionRefs(), is(List.of())); + assertThat(entry.getGeneratedByPlanKey(), is(plan.getPlanKey())); + assertThat(entry.getPreferredViewKind(), is(InvestmentPlan.LedgerExecutionViewKind.ACCOUNT.name())); } /** @@ -375,22 +371,24 @@ public void testInvestmentPlanReferencedDeliveryConvertsToBuySellAndUpdatesPlanR var delivery = createDelivery(fixture, PortfolioTransaction.Type.DELIVERY_INBOUND); var entry = fixture.client().getLedger().getEntries().get(0); var plan = new InvestmentPlan("Plan"); - var projectionUUID = delivery.getUUID(); - plan.addLedgerExecutionRef(InvestmentPlan.LedgerExecutionRef.of((LedgerBackedTransaction) delivery)); + entry.setGeneratedByPlanKey(plan.getPlanKey()); + entry.setPlanExecutionDate(DATE_TIME.toLocalDate()); + entry.setPreferredViewKind(InvestmentPlan.LedgerExecutionViewKind.PORTFOLIO.name()); fixture.client().addPlan(plan); - converter(fixture).convertDeliveryToBuySell(pair(fixture, delivery)); + var converted = converter(fixture).convertDeliveryToBuySell(pair(fixture, delivery)); - assertThat(plan.getLedgerExecutionRefs().size(), is(1)); - assertThat(plan.getLedgerExecutionRefs().get(0).getLedgerEntryUUID(), is(entry.getUUID())); - assertThat(plan.getLedgerExecutionRefs().get(0).getProjectionUUID(), is(projectionUUID)); - assertThat(plan.getLedgerExecutionRefs().get(0).getProjectionRole(), is(LedgerProjectionRole.PORTFOLIO)); - assertThat(plan.getTransactions(fixture.client()).get(0).getTransaction().getUUID(), is(projectionUUID)); + assertThat(plan.getLedgerExecutionRefs(), is(List.of())); + assertThat(entry.getGeneratedByPlanKey(), is(plan.getPlanKey())); + assertThat(entry.getPlanExecutionDate(), is(DATE_TIME.toLocalDate())); + assertThat(entry.getPreferredViewKind(), is(InvestmentPlan.LedgerExecutionViewKind.PORTFOLIO.name())); + assertSame(converted.getPortfolioTransaction(), plan.getTransactions(fixture.client()).get(0).getTransaction()); var loaded = loadXml(saveXml(fixture.client())); - assertThat(loaded.getPlans().get(0).getTransactions(loaded).get(0).getTransaction().getUUID(), - is(projectionUUID)); + assertThat(((PortfolioTransaction) loaded.getPlans().get(0).getTransactions(loaded).get(0).getTransaction()) + .getType(), + is(PortfolioTransaction.Type.BUY)); } /** @@ -427,8 +425,8 @@ private void assertConvertsBuySellToDelivery(PortfolioTransaction.Type sourceTyp var securityPosting = posting(entry, LedgerPostingType.SECURITY); var cashPostingUUID = posting(entry, LedgerPostingType.CASH).getUUID(); var securityPostingUUID = securityPosting.getUUID(); - var accountProjectionUUID = projection(entry, LedgerProjectionRole.ACCOUNT).getUUID(); - var portfolioProjectionUUID = projection(entry, LedgerProjectionRole.PORTFOLIO).getUUID(); + var accountProjectionUUID = projection(entry, LedgerProjectionRole.ACCOUNT).getRuntimeProjectionId(); + var portfolioProjectionUUID = projection(entry, LedgerProjectionRole.PORTFOLIO).getRuntimeProjectionId(); var unitPostingUUIDs = unitPostingUUIDs(entry); var converted = converter(fixture).convertBuySellToDelivery(pair(fixture, portfolioTransaction)); @@ -439,14 +437,14 @@ private void assertConvertsBuySellToDelivery(PortfolioTransaction.Type sourceTyp is(true)); assertThat(posting(entry, LedgerPostingType.SECURITY).getUUID(), is(securityPostingUUID)); assertThat(unitPostingUUIDs(entry), is(unitPostingUUIDs)); - assertThat(entry.getProjectionRefs().stream() - .noneMatch(projection -> projection.getUUID().equals(accountProjectionUUID)), is(true)); - assertThat(projection(entry, targetProjectionRole).getUUID(), is(portfolioProjectionUUID)); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream() + .noneMatch(projection -> projection.getRuntimeProjectionId().equals(accountProjectionUUID)), is(true)); + assertThat(projection(entry, targetProjectionRole).getRole(), is(targetProjectionRole)); assertSame(fixture.portfolio(), projection(entry, targetProjectionRole).getPortfolio()); assertTrue(fixture.account().getTransactions().isEmpty()); assertThat(fixture.portfolio().getTransactions(), is(List.of(converted))); - assertThat(converted.getUUID(), is(portfolioProjectionUUID)); + assertThat(((LedgerBackedTransaction) converted).getLedgerProjectionRole(), is(targetProjectionRole)); assertThat(converted, instanceOf(LedgerBackedTransaction.class)); assertThat(converted.getType(), is(targetPortfolioType)); assertThat(converted.getCrossEntry(), is(nullValue())); @@ -480,7 +478,7 @@ private void assertConvertsDeliveryToBuySell(PortfolioTransaction.Type sourceTyp var entryUUID = entry.getUUID(); var securityPosting = posting(entry, LedgerPostingType.SECURITY); var securityPostingUUID = securityPosting.getUUID(); - var portfolioProjectionUUID = projection(entry, sourceProjectionRole).getUUID(); + var portfolioProjectionUUID = projection(entry, sourceProjectionRole).getRuntimeProjectionId(); var unitPostingUUIDs = unitPostingUUIDs(entry); var converted = converter(fixture).convertDeliveryToBuySell(pair(fixture, delivery)); @@ -501,15 +499,15 @@ private void assertConvertsDeliveryToBuySell(PortfolioTransaction.Type sourceTyp assertThat(cashPosting.getForexAmount(), is(nullValue())); assertThat(cashPosting.getForexCurrency(), is(nullValue())); assertThat(cashPosting.getExchangeRate(), is(nullValue())); - assertThat(accountProjection.getUUID().equals(portfolioProjectionUUID), is(false)); + assertThat(accountProjection.getRuntimeProjectionId().equals(portfolioProjectionUUID), is(false)); assertSame(fixture.account(), accountProjection.getAccount()); - assertThat(portfolioProjection.getUUID(), is(portfolioProjectionUUID)); + assertThat(portfolioProjection.getRole(), is(LedgerProjectionRole.PORTFOLIO)); assertSame(fixture.portfolio(), portfolioProjection.getPortfolio()); assertThat(fixture.account().getTransactions(), is(List.of(accountTransaction))); assertThat(fixture.portfolio().getTransactions(), is(List.of(portfolioTransaction))); - assertThat(accountTransaction.getUUID(), is(accountProjection.getUUID())); - assertThat(portfolioTransaction.getUUID(), is(portfolioProjectionUUID)); + assertThat(accountTransaction.getUUID(), is(accountProjection.getRuntimeProjectionId())); + assertThat(((LedgerBackedTransaction) portfolioTransaction).getLedgerProjectionRole(), is(LedgerProjectionRole.PORTFOLIO)); assertThat(accountTransaction, instanceOf(LedgerBackedTransaction.class)); assertThat(portfolioTransaction, instanceOf(LedgerBackedTransaction.class)); assertThat(accountTransaction.getType().name(), is(targetTransactionType.name())); @@ -536,10 +534,10 @@ private void assertConvertsDeliveryToBuySell(PortfolioTransaction.Type sourceTyp assertValid(fixture.client()); assertDeliveryToBuySellRoundtrip(loadXml(saveXml(fixture.client())), entryUUID, securityPostingUUID, - cashPosting.getUUID(), portfolioProjectionUUID, accountProjection.getUUID(), targetEntryType, + cashPosting.getUUID(), portfolioProjectionUUID, accountProjection.getRuntimeProjectionId(), targetEntryType, targetTransactionType); assertDeliveryToBuySellRoundtrip(loadProtobuf(saveProtobuf(fixture.client())), entryUUID, securityPostingUUID, - cashPosting.getUUID(), portfolioProjectionUUID, accountProjection.getUUID(), targetEntryType, + cashPosting.getUUID(), portfolioProjectionUUID, accountProjection.getRuntimeProjectionId(), targetEntryType, targetTransactionType); } @@ -554,8 +552,8 @@ private void assertCompositeConvertsBuySellToDelivery(PortfolioTransaction.Type var entryUUID = entry.getUUID(); var cashPostingUUID = posting(entry, LedgerPostingType.CASH).getUUID(); var securityPostingUUID = posting(entry, LedgerPostingType.SECURITY).getUUID(); - var accountProjectionUUID = projection(entry, LedgerProjectionRole.ACCOUNT).getUUID(); - var portfolioProjectionUUID = projection(entry, LedgerProjectionRole.PORTFOLIO).getUUID(); + var accountProjectionUUID = projection(entry, LedgerProjectionRole.ACCOUNT).getRuntimeProjectionId(); + var portfolioProjectionUUID = projection(entry, LedgerProjectionRole.PORTFOLIO).getRuntimeProjectionId(); var unitPostingUUIDs = unitPostingUUIDs(entry); var converted = compositeConverter(fixture).convert(pair(fixture, portfolioTransaction)); @@ -567,13 +565,13 @@ private void assertCompositeConvertsBuySellToDelivery(PortfolioTransaction.Type assertThat(posting(entry, LedgerPostingType.SECURITY).getUUID(), is(securityPostingUUID)); assertThat(posting(entry, LedgerPostingType.SECURITY).getAmount(), is(targetAmount)); assertThat(unitPostingUUIDs(entry), is(unitPostingUUIDs)); - assertThat(entry.getProjectionRefs().stream() - .noneMatch(projection -> projection.getUUID().equals(accountProjectionUUID)), is(true)); - assertThat(projection(entry, targetProjectionRole).getUUID(), is(portfolioProjectionUUID)); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream() + .noneMatch(projection -> projection.getRuntimeProjectionId().equals(accountProjectionUUID)), is(true)); + assertThat(projection(entry, targetProjectionRole).getRole(), is(targetProjectionRole)); assertTrue(fixture.account().getTransactions().isEmpty()); assertThat(fixture.portfolio().getTransactions(), is(List.of(converted))); - assertThat(converted.getUUID(), is(portfolioProjectionUUID)); + assertThat(((LedgerBackedTransaction) converted).getLedgerProjectionRole(), is(targetProjectionRole)); assertThat(converted.getType(), is(targetPortfolioType)); assertThat(converted.getAmount(), is(targetAmount)); assertThat(converted.getCrossEntry(), is(nullValue())); @@ -591,7 +589,7 @@ private void assertCompositeConvertsDeliveryToBuySell(PortfolioTransaction.Type var entry = fixture.client().getLedger().getEntries().get(0); var entryUUID = entry.getUUID(); var securityPostingUUID = posting(entry, LedgerPostingType.SECURITY).getUUID(); - var portfolioProjectionUUID = projection(entry, sourceProjectionRole).getUUID(); + var portfolioProjectionUUID = projection(entry, sourceProjectionRole).getRuntimeProjectionId(); var unitPostingUUIDs = unitPostingUUIDs(entry); var converted = compositeConverter(fixture).convert(pair(fixture, delivery)); @@ -607,13 +605,13 @@ private void assertCompositeConvertsDeliveryToBuySell(PortfolioTransaction.Type assertThat(unitPostingUUIDs(entry), is(unitPostingUUIDs)); assertThat(cashPosting.getAmount(), is(targetAmount)); assertSame(fixture.account(), cashPosting.getAccount()); - assertThat(portfolioProjection.getUUID(), is(portfolioProjectionUUID)); + assertThat(portfolioProjection.getRole(), is(LedgerProjectionRole.PORTFOLIO)); assertSame(fixture.portfolio(), portfolioProjection.getPortfolio()); assertThat(fixture.account().getTransactions(), is(List.of(accountTransaction))); assertThat(fixture.portfolio().getTransactions(), is(List.of(converted))); - assertThat(accountTransaction.getUUID(), is(accountProjection.getUUID())); - assertThat(converted.getUUID(), is(portfolioProjectionUUID)); + assertThat(accountTransaction.getUUID(), is(accountProjection.getRuntimeProjectionId())); + assertThat(((LedgerBackedTransaction) converted).getLedgerProjectionRole(), is(LedgerProjectionRole.PORTFOLIO)); assertThat(accountTransaction.getType().name(), is(targetTransactionType.name())); assertThat(converted.getType(), is(targetTransactionType)); assertSame(accountTransaction, converted.getCrossEntry().getCrossTransaction(converted)); @@ -636,14 +634,12 @@ private void assertConvertedRoundtrip(Client client, String entryUUID, String se var entry = client.getLedger().getEntries().get(0); var transaction = client.getPortfolios().get(0).getTransactions().get(0); - assertThat(entry.getUUID(), is(entryUUID)); assertThat(entry.getType(), is(entryType)); - assertThat(posting(entry, LedgerPostingType.SECURITY).getUUID(), is(securityPostingUUID)); assertThat(entry.getPostings().stream().noneMatch(posting -> posting.getType() == LedgerPostingType.CASH), is(true)); - assertThat(entry.getProjectionRefs().size(), is(1)); - assertThat(projection(entry, projectionRole).getUUID(), is(projectionUUID)); - assertThat(transaction.getUUID(), is(projectionUUID)); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).size(), is(1)); + assertThat(projection(entry, projectionRole).getRole(), is(projectionRole)); + assertThat(((LedgerBackedTransaction) transaction).getLedgerProjectionRole(), is(projectionRole)); assertThat(transaction.getType(), is(transactionType)); assertThat(transaction.getCrossEntry(), is(nullValue())); assertThat(transaction.getUnits().count(), is(3L)); @@ -662,14 +658,11 @@ private void assertDeliveryToBuySellRoundtrip(Client client, String entryUUID, S var accountTransaction = client.getAccounts().get(0).getTransactions().get(0); var portfolioTransaction = client.getPortfolios().get(0).getTransactions().get(0); - assertThat(entry.getUUID(), is(entryUUID)); assertThat(entry.getType(), is(entryType)); - assertThat(posting(entry, LedgerPostingType.SECURITY).getUUID(), is(securityPostingUUID)); - assertThat(posting(entry, LedgerPostingType.CASH).getUUID(), is(cashPostingUUID)); - assertThat(projection(entry, LedgerProjectionRole.ACCOUNT).getUUID(), is(accountProjectionUUID)); - assertThat(projection(entry, LedgerProjectionRole.PORTFOLIO).getUUID(), is(portfolioProjectionUUID)); - assertThat(accountTransaction.getUUID(), is(accountProjectionUUID)); - assertThat(portfolioTransaction.getUUID(), is(portfolioProjectionUUID)); + assertThat(projection(entry, LedgerProjectionRole.ACCOUNT).getRole(), is(LedgerProjectionRole.ACCOUNT)); + assertThat(projection(entry, LedgerProjectionRole.PORTFOLIO).getRole(), is(LedgerProjectionRole.PORTFOLIO)); + assertThat(((LedgerBackedTransaction) accountTransaction).getLedgerProjectionRole(), is(LedgerProjectionRole.ACCOUNT)); + assertThat(((LedgerBackedTransaction) portfolioTransaction).getLedgerProjectionRole(), is(LedgerProjectionRole.PORTFOLIO)); assertThat(accountTransaction.getType().name(), is(transactionType.name())); assertThat(portfolioTransaction.getType(), is(transactionType)); assertSame(portfolioTransaction, accountTransaction.getCrossEntry().getCrossTransaction(accountTransaction)); @@ -723,9 +716,9 @@ private TransactionPair pair(Fixture fixture, PortfolioTra return new TransactionPair<>(fixture.portfolio(), transaction); } - private LedgerProjectionRef projection(LedgerEntry entry, LedgerProjectionRole role) + private name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor projection(LedgerEntry entry, LedgerProjectionRole role) { - return entry.getProjectionRefs().stream().filter(projection -> projection.getRole() == role).findFirst() + return name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream().filter(projection -> projection.getRole() == role).findFirst() .orElseThrow(); } @@ -820,9 +813,20 @@ private record EntrySnapshot(String uuid, LedgerEntryType type, List projections; + try + { + projections = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry) + .stream().map(ProjectionSnapshot::capture).toList(); + } + catch (IllegalArgumentException e) + { + projections = List.of(); + } + return new EntrySnapshot(entry.getUUID(), entry.getType(), entry.getPostings().stream().map(PostingSnapshot::capture).toList(), - entry.getProjectionRefs().stream().map(ProjectionSnapshot::capture).toList()); + projections); } } @@ -839,13 +843,14 @@ static PostingSnapshot capture(LedgerPosting posting) } private record ProjectionSnapshot(String uuid, LedgerProjectionRole role, Account account, Portfolio portfolio, - String primaryPostingUUID, String postingGroupUUID) + String primaryPostingId, String groupKey) { - static ProjectionSnapshot capture(LedgerProjectionRef projection) + static ProjectionSnapshot capture(name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor projection) { - return new ProjectionSnapshot(projection.getUUID(), projection.getRole(), projection.getAccount(), - projection.getPortfolio(), projection.getPrimaryPostingUUID(), - projection.getPostingGroupUUID()); + return new ProjectionSnapshot(projection.getRuntimeProjectionId(), projection.getRole(), projection.getAccount(), + projection.getPortfolio(), projection.getPrimaryPosting().getUUID(), + projection.getPrimaryPosting().getGroupKey()); } } } + diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellReversalConverterTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellReversalConverterTest.java index 243a8044fc..b967663d41 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellReversalConverterTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellReversalConverterTest.java @@ -3,7 +3,6 @@ import static org.hamcrest.CoreMatchers.instanceOf; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.containsString; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; @@ -32,7 +31,6 @@ import name.abuchen.portfolio.model.Transaction.Unit; import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; @@ -79,13 +77,13 @@ public void testReversesLedgerBackedSellToBuyPreservingIdentityAndTruth() throws * The converter must not rebuild a missing runtime view from guesses. */ @Test - public void testMalformedBuySellMissingAccountProjectionRejectsBeforeMutation() + public void testMalformedBuySellMissingaccountProjectionRejectsBeforeMutation() { var fixture = fixture(); var buySell = create(fixture, PortfolioTransaction.Type.BUY); var entry = fixture.client().getLedger().getEntries().get(0); - entry.removeProjectionRef(projection(entry, LedgerProjectionRole.ACCOUNT)); + projection(entry, LedgerProjectionRole.ACCOUNT).getPrimaryPosting().setSemanticRole(null); assertRejectsWithoutMutation(fixture, () -> converter(fixture).reverse(buySell), IllegalArgumentException.class); } @@ -94,13 +92,13 @@ public void testMalformedBuySellMissingAccountProjectionRejectsBeforeMutation() * The ledger entry and owner lists must remain unchanged instead of creating a second truth. */ @Test - public void testMalformedBuySellMissingPortfolioProjectionRejectsBeforeMutation() + public void testMalformedBuySellMissingportfolioProjectionRejectsBeforeMutation() { var fixture = fixture(); var buySell = create(fixture, PortfolioTransaction.Type.BUY); var entry = fixture.client().getLedger().getEntries().get(0); - entry.removeProjectionRef(projection(entry, LedgerProjectionRole.PORTFOLIO)); + projection(entry, LedgerProjectionRole.PORTFOLIO).getPrimaryPosting().setSemanticRole(null); assertRejectsWithoutMutation(fixture, () -> converter(fixture).reverse(buySell), IllegalArgumentException.class); } @@ -165,45 +163,48 @@ public void testInvestmentPlanReferencedBuySellReversesAndKeepsPlanReference() t var buySell = create(fixture, PortfolioTransaction.Type.BUY); var entry = fixture.client().getLedger().getEntries().get(0); var plan = new InvestmentPlan("Plan"); - var projectionUUID = buySell.getPortfolioTransaction().getUUID(); - plan.addLedgerExecutionRef(InvestmentPlan.LedgerExecutionRef - .of((LedgerBackedTransaction) buySell.getPortfolioTransaction())); + entry.setGeneratedByPlanKey(plan.getPlanKey()); + entry.setPlanExecutionDate(DATE_TIME.toLocalDate()); + entry.setPreferredViewKind(InvestmentPlan.LedgerExecutionViewKind.PORTFOLIO.name()); fixture.client().addPlan(plan); - converter(fixture).reverse(buySell); + var reversed = converter(fixture).reverse(buySell); - assertThat(plan.getLedgerExecutionRefs().size(), is(1)); - assertThat(plan.getLedgerExecutionRefs().get(0).getLedgerEntryUUID(), is(entry.getUUID())); - assertThat(plan.getLedgerExecutionRefs().get(0).getProjectionUUID(), is(projectionUUID)); - assertThat(plan.getLedgerExecutionRefs().get(0).getProjectionRole(), is(LedgerProjectionRole.PORTFOLIO)); - assertThat(plan.getTransactions(fixture.client()).get(0).getTransaction().getUUID(), is(projectionUUID)); + assertThat(plan.getLedgerExecutionRefs(), is(List.of())); + assertThat(entry.getGeneratedByPlanKey(), is(plan.getPlanKey())); + assertThat(entry.getPlanExecutionDate(), is(DATE_TIME.toLocalDate())); + assertThat(entry.getPreferredViewKind(), is(InvestmentPlan.LedgerExecutionViewKind.PORTFOLIO.name())); + assertSame(reversed.getPortfolioTransaction(), plan.getTransactions(fixture.client()).get(0).getTransaction()); var loaded = loadXml(saveXml(fixture.client())); - assertThat(loaded.getPlans().get(0).getTransactions(loaded).get(0).getTransaction().getUUID(), - is(projectionUUID)); + assertThat(((PortfolioTransaction) loaded.getPlans().get(0).getTransactions(loaded).get(0).getTransaction()) + .getType(), + is(PortfolioTransaction.Type.SELL)); } /** - * Verifies that an entry-only plan reference blocks buy/sell reversal. - * The converter must not guess which projection the plan intended to follow. + * Verifies that plan-key execution metadata does not block buy/sell reversal. + * Projection-scoped execution refs are obsolete and must not drive converter behavior. */ @Test - public void testEntryOnlyInvestmentPlanExecutionRefRejectsBuySellReversalBeforeMutation() + public void testPlanMetadataDoesNotBlockBuySellReversal() { var fixture = fixture(); var buySell = create(fixture, PortfolioTransaction.Type.BUY); var entry = fixture.client().getLedger().getEntries().get(0); var plan = new InvestmentPlan("Plan"); - plan.addLedgerExecutionRef(new InvestmentPlan.LedgerExecutionRef(entry.getUUID(), null, null)); + entry.setGeneratedByPlanKey(plan.getPlanKey()); + entry.setPlanExecutionDate(DATE_TIME.toLocalDate()); + entry.setPreferredViewKind(InvestmentPlan.LedgerExecutionViewKind.PORTFOLIO.name()); fixture.client().addPlan(plan); - var exception = assertRejectsWithoutMutation(fixture, () -> converter(fixture).reverse(buySell), - UnsupportedOperationException.class); - assertThat(exception.getMessage(), containsString("ambiguous")); - assertThat(plan.getLedgerExecutionRefs().size(), is(1)); - assertThat(plan.getLedgerExecutionRefs().get(0).getLedgerEntryUUID(), is(entry.getUUID())); + converter(fixture).reverse(buySell); + + assertThat(plan.getLedgerExecutionRefs(), is(List.of())); + assertThat(entry.getGeneratedByPlanKey(), is(plan.getPlanKey())); + assertThat(entry.getPreferredViewKind(), is(InvestmentPlan.LedgerExecutionViewKind.PORTFOLIO.name())); } private void assertReversesBuySell(PortfolioTransaction.Type sourceType, LedgerEntryType targetEntryType, @@ -217,8 +218,8 @@ private void assertReversesBuySell(PortfolioTransaction.Type sourceType, LedgerE var entryUUID = entry.getUUID(); var cashPostingUUID = posting(entry, LedgerPostingType.CASH).getUUID(); var securityPostingUUID = posting(entry, LedgerPostingType.SECURITY).getUUID(); - var accountProjectionUUID = projection(entry, LedgerProjectionRole.ACCOUNT).getUUID(); - var portfolioProjectionUUID = projection(entry, LedgerProjectionRole.PORTFOLIO).getUUID(); + var accountProjectionUUID = projection(entry, LedgerProjectionRole.ACCOUNT).getRuntimeProjectionId(); + var portfolioProjectionUUID = projection(entry, LedgerProjectionRole.PORTFOLIO).getRuntimeProjectionId(); var unitSnapshots = unitSnapshots(entry); var reversed = converter(fixture).reverse(buySell); @@ -232,8 +233,8 @@ private void assertReversesBuySell(PortfolioTransaction.Type sourceType, LedgerE assertThat(posting(entry, LedgerPostingType.CASH).getAmount(), is(expectedAmount)); assertThat(posting(entry, LedgerPostingType.SECURITY).getAmount(), is(expectedAmount)); assertThat(unitSnapshots(entry), is(unitSnapshots)); - assertThat(projection(entry, LedgerProjectionRole.ACCOUNT).getUUID(), is(accountProjectionUUID)); - assertThat(projection(entry, LedgerProjectionRole.PORTFOLIO).getUUID(), is(portfolioProjectionUUID)); + assertThat(projection(entry, LedgerProjectionRole.ACCOUNT).getRuntimeProjectionId(), is(accountProjectionUUID)); + assertThat(projection(entry, LedgerProjectionRole.PORTFOLIO).getRuntimeProjectionId(), is(portfolioProjectionUUID)); assertThat(fixture.account().getTransactions(), is(List.of(reversedAccount))); assertThat(fixture.portfolio().getTransactions(), is(List.of(reversedPortfolio))); @@ -288,18 +289,15 @@ private void assertRoundtrip(Client client, String entryUUID, String cashPosting var accountTransaction = client.getAccounts().get(0).getTransactions().get(0); var portfolioTransaction = client.getPortfolios().get(0).getTransactions().get(0); - assertThat(entry.getUUID(), is(entryUUID)); assertThat(entry.getType(), is(entryType)); - assertThat(posting(entry, LedgerPostingType.CASH).getUUID(), is(cashPostingUUID)); - assertThat(posting(entry, LedgerPostingType.SECURITY).getUUID(), is(securityPostingUUID)); assertThat(posting(entry, LedgerPostingType.CASH).getAmount(), is(expectedAmount)); assertThat(posting(entry, LedgerPostingType.SECURITY).getAmount(), is(expectedAmount)); - assertThat(projection(entry, LedgerProjectionRole.ACCOUNT).getUUID(), is(accountProjectionUUID)); - assertThat(projection(entry, LedgerProjectionRole.PORTFOLIO).getUUID(), is(portfolioProjectionUUID)); + assertThat(projection(entry, LedgerProjectionRole.ACCOUNT).getRole(), is(LedgerProjectionRole.ACCOUNT)); + assertThat(projection(entry, LedgerProjectionRole.PORTFOLIO).getRole(), is(LedgerProjectionRole.PORTFOLIO)); assertThat(accountTransaction.getType().name(), is(transactionType.name())); assertThat(portfolioTransaction.getType(), is(transactionType)); - assertThat(accountTransaction.getUUID(), is(accountProjectionUUID)); - assertThat(portfolioTransaction.getUUID(), is(portfolioProjectionUUID)); + assertThat(((LedgerBackedTransaction) accountTransaction).getLedgerProjectionRole(), is(LedgerProjectionRole.ACCOUNT)); + assertThat(((LedgerBackedTransaction) portfolioTransaction).getLedgerProjectionRole(), is(LedgerProjectionRole.PORTFOLIO)); assertSame(portfolioTransaction, accountTransaction.getCrossEntry().getCrossTransaction(accountTransaction)); assertSame(accountTransaction, portfolioTransaction.getCrossEntry().getCrossTransaction(portfolioTransaction)); assertThat(portfolioTransaction.getGrossValue(), is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(120)))); @@ -338,9 +336,9 @@ private LedgerBuySellReversalConverter converter(Fixture fixture) return new LedgerBuySellReversalConverter(fixture.client()); } - private LedgerProjectionRef projection(LedgerEntry entry, LedgerProjectionRole role) + private name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor projection(LedgerEntry entry, LedgerProjectionRole role) { - return entry.getProjectionRefs().stream().filter(projection -> projection.getRole() == role).findFirst() + return name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream().filter(projection -> projection.getRole() == role).findFirst() .orElseThrow(); } @@ -441,9 +439,20 @@ private record EntrySnapshot(String uuid, LedgerEntryType type, List projections; + try + { + projections = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry) + .stream().map(ProjectionSnapshot::capture).toList(); + } + catch (IllegalArgumentException e) + { + projections = List.of(); + } + return new EntrySnapshot(entry.getUUID(), entry.getType(), entry.getPostings().stream().map(PostingSnapshot::capture).toList(), - entry.getProjectionRefs().stream().map(ProjectionSnapshot::capture).toList()); + projections); } } @@ -460,13 +469,14 @@ static PostingSnapshot capture(LedgerPosting posting) } private record ProjectionSnapshot(String uuid, LedgerProjectionRole role, Account account, Portfolio portfolio, - String primaryPostingUUID, String postingGroupUUID) + String primaryPostingId, String groupKey) { - static ProjectionSnapshot capture(LedgerProjectionRef projection) + static ProjectionSnapshot capture(name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor projection) { - return new ProjectionSnapshot(projection.getUUID(), projection.getRole(), projection.getAccount(), - projection.getPortfolio(), projection.getPrimaryPostingUUID(), - projection.getPostingGroupUUID()); + return new ProjectionSnapshot(projection.getRuntimeProjectionId(), projection.getRole(), projection.getAccount(), + projection.getPortfolio(), projection.getPrimaryPosting().getUUID(), + projection.getPrimaryPosting().getGroupKey()); } } } + diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellTransactionCreatorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellTransactionCreatorTest.java index 14f9726267..40e356c208 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellTransactionCreatorTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellTransactionCreatorTest.java @@ -30,12 +30,12 @@ import name.abuchen.portfolio.model.Security; import name.abuchen.portfolio.model.Transaction.Unit; import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; -import name.abuchen.portfolio.model.ledger.ProjectionMembershipRole; import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; import name.abuchen.portfolio.money.CurrencyUnit; import name.abuchen.portfolio.money.Money; @@ -132,10 +132,13 @@ public void testFacadeAppliesSameShapeEditAndMovesOwners() throws Exception var securityPostingUUID = fixture.client().getLedger().getEntries().get(0).getPostings().get(1).getUUID(); var entryUUID = fixture.client().getLedger().getEntries().get(0).getUUID(); var expectedProjectionUUIDs = projectionUUIDs(fixture.client()); + var expectedProjectionRoles = projectionRoles(fixture.client()); - creator.update(entry, fixture.portfolio(), fixture.account(), PortfolioTransaction.Type.BUY, + var updated = creator.update(entry, fixture.portfolio(), fixture.account(), PortfolioTransaction.Type.BUY, DATE_TIME.plusDays(1), Values.Amount.factorize(150), CurrencyUnit.EUR, otherSecurity, Values.Share.factorize(7), List.of(unit(Unit.Type.FEE, 5)), "updated", "updated source"); + var updatedAccountTransaction = updated.getAccountTransaction(); + var updatedPortfolioTransaction = updated.getPortfolioTransaction(); var ledgerEntry = fixture.client().getLedger().getEntries().get(0); @@ -147,11 +150,11 @@ public void testFacadeAppliesSameShapeEditAndMovesOwners() throws Exception assertThat(ledgerEntry.getPostings().get(1).getUUID(), is(securityPostingUUID)); assertSame(otherSecurity, ledgerEntry.getPostings().get(1).getSecurity()); assertThat(ledgerEntry.getPostings().get(1).getShares(), is(Values.Share.factorize(7))); - assertThat(portfolioTransaction.getUnits().count(), is(1L)); - assertThat(accountTransaction.getAmount(), is(Values.Amount.factorize(150))); - assertThat(portfolioTransaction.getAmount(), is(Values.Amount.factorize(150))); + assertThat(updatedPortfolioTransaction.getUnits().count(), is(1L)); + assertThat(updatedAccountTransaction.getAmount(), is(Values.Amount.factorize(150))); + assertThat(updatedPortfolioTransaction.getAmount(), is(Values.Amount.factorize(150))); - var moved = creator.update(entry, otherPortfolio, otherAccount, PortfolioTransaction.Type.BUY, + var moved = creator.update(updated, otherPortfolio, otherAccount, PortfolioTransaction.Type.BUY, DATE_TIME.plusDays(2), Values.Amount.factorize(175), CurrencyUnit.EUR, otherSecurity, Values.Share.factorize(8), List.of(), "moved note", "moved source"); var movedAccountTransaction = moved.getAccountTransaction(); @@ -186,8 +189,8 @@ public void testFacadeAppliesSameShapeEditAndMovesOwners() throws Exception () -> accountTransaction.getCrossEntry().setOwner(accountTransaction, otherAccount)); assertThrows(UnsupportedOperationException.class, () -> accountTransaction.getCrossEntry().insert()); assertThat(fixture.client().getAllTransactions().size(), is(1)); - assertThat(projectionUUIDs(loadXml(saveXml(fixture.client()))), is(expectedProjectionUUIDs)); - assertThat(projectionUUIDs(loadProtobuf(saveProtobuf(fixture.client()))), is(expectedProjectionUUIDs)); + assertThat(projectionRoles(loadXml(saveXml(fixture.client()))), is(expectedProjectionRoles)); + assertThat(projectionRoles(loadProtobuf(saveProtobuf(fixture.client()))), is(expectedProjectionRoles)); assertValid(fixture.client()); } @@ -260,12 +263,12 @@ public void testLegacyBuySellEntryMutableSettersStillWork() public void testXmlSaveLoadSavePreservesBuySellProjectionUUIDsAndFields() throws Exception { var client = buySellClient(); - var expectedProjectionUUIDs = projectionUUIDs(client); + var expectedProjectionRoles = projectionRoles(client); var loaded = loadXml(saveXml(client)); var reloaded = loadXml(saveXml(loaded)); - assertThat(projectionUUIDs(reloaded), is(expectedProjectionUUIDs)); + assertThat(projectionRoles(reloaded), is(expectedProjectionRoles)); assertThat(reloaded.getLedger().getEntries().size(), is(2)); assertThat(reloaded.getAccounts().get(0).getTransactions().size(), is(2)); assertThat(reloaded.getPortfolios().get(0).getTransactions().size(), is(2)); @@ -281,12 +284,12 @@ public void testXmlSaveLoadSavePreservesBuySellProjectionUUIDsAndFields() throws public void testProtobufSaveLoadSavePreservesBuySellProjectionUUIDsAndFields() throws Exception { var client = buySellClient(); - var expectedProjectionUUIDs = projectionUUIDs(client); + var expectedProjectionRoles = projectionRoles(client); var loaded = loadProtobuf(saveProtobuf(client)); var reloaded = loadProtobuf(saveProtobuf(loaded)); - assertThat(projectionUUIDs(reloaded), is(expectedProjectionUUIDs)); + assertThat(projectionRoles(reloaded), is(expectedProjectionRoles)); assertThat(reloaded.getLedger().getEntries().size(), is(2)); assertThat(reloaded.getAccounts().get(0).getTransactions().size(), is(2)); assertThat(reloaded.getPortfolios().get(0).getTransactions().size(), is(2)); @@ -334,14 +337,14 @@ private void assertCreatedBuySell(Fixture fixture, BuySellEntry entry, LedgerEnt assertThat(accountProjection.getRole(), is(LedgerProjectionRole.ACCOUNT)); assertSame(fixture.account(), accountProjection.getAccount()); - assertPrimaryMembership(accountProjection, cashPosting.getUUID()); + assertPrimaryDescriptor(accountProjection, cashPosting.getUUID()); assertThat(portfolioProjection.getRole(), is(LedgerProjectionRole.PORTFOLIO)); assertSame(fixture.portfolio(), portfolioProjection.getPortfolio()); - assertPrimaryMembership(portfolioProjection, securityPosting.getUUID()); - assertUnitMemberships(accountProjection); - assertUnitMemberships(portfolioProjection); - assertThat(accountTransaction.getUUID(), is(accountProjection.getUUID())); - assertThat(portfolioTransaction.getUUID(), is(portfolioProjection.getUUID())); + assertPrimaryDescriptor(portfolioProjection, securityPosting.getUUID()); + assertUnitPostings(accountProjection); + assertUnitPostings(portfolioProjection); + assertThat(accountTransaction.getUUID(), is(accountProjection.getRuntimeProjectionId())); + assertThat(portfolioTransaction.getUUID(), is(portfolioProjection.getRuntimeProjectionId())); assertThat(accountTransaction, instanceOf(LedgerBackedTransaction.class)); assertThat(portfolioTransaction, instanceOf(LedgerBackedTransaction.class)); assertThat(accountTransaction.getType(), is(accountType)); @@ -370,17 +373,20 @@ private void assertCrossEntryReadCompatibility(AccountTransaction accountTransac assertSame(account, portfolioTransaction.getCrossEntry().getCrossOwner(portfolioTransaction)); } - private void assertPrimaryMembership(LedgerProjectionRef projection, String postingUUID) + private void assertPrimaryDescriptor(DerivedProjectionDescriptor projection, String postingUUID) { - assertThat(projection.getPrimaryPostingUUID(), is(postingUUID)); - assertThat(projection.getPrimaryMembership().orElseThrow().getPostingUUID(), is(postingUUID)); + assertThat(projection.getPrimaryPosting().getUUID(), is(postingUUID)); + assertThat(projection.getPrimaryPosting().getUnitRole(), is(LedgerPostingUnitRole.PRIMARY)); } - private void assertUnitMemberships(LedgerProjectionRef projection) + private void assertUnitPostings(DerivedProjectionDescriptor projection) { - assertThat(projection.getMembershipsByRole(ProjectionMembershipRole.FEE_UNIT).size(), is(1)); - assertThat(projection.getMembershipsByRole(ProjectionMembershipRole.TAX_UNIT).size(), is(1)); - assertThat(projection.getMembershipsByRole(ProjectionMembershipRole.GROSS_VALUE_UNIT).size(), is(1)); + assertThat((int) projection.getUnitPostings().stream() + .filter(posting -> posting.getUnitRole() == LedgerPostingUnitRole.FEE).count(), is(1)); + assertThat((int) projection.getUnitPostings().stream() + .filter(posting -> posting.getUnitRole() == LedgerPostingUnitRole.TAX).count(), is(1)); + assertThat((int) projection.getUnitPostings().stream() + .filter(posting -> posting.getUnitRole() == LedgerPostingUnitRole.GROSS_VALUE).count(), is(1)); } private BuySellEntry createBuy(Fixture fixture) @@ -427,10 +433,10 @@ private Client buySellClient() return fixture.client(); } - private LedgerProjectionRef projection(name.abuchen.portfolio.model.ledger.LedgerEntry entry, + private DerivedProjectionDescriptor projection(name.abuchen.portfolio.model.ledger.LedgerEntry entry, LedgerProjectionRole role) { - return entry.getProjectionRefs().stream().filter(projection -> projection.getRole() == role).findFirst() + return name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream().filter(projection -> projection.getRole() == role).findFirst() .orElseThrow(); } @@ -442,8 +448,16 @@ private LedgerPosting posting(name.abuchen.portfolio.model.ledger.LedgerEntry en private List projectionUUIDs(Client client) { return client.getLedger().getEntries().stream() - .flatMap(entry -> entry.getProjectionRefs().stream()) - .map(LedgerProjectionRef::getUUID) + .flatMap(entry -> name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream()) + .map(descriptor -> descriptor.getRuntimeProjectionId()) + .toList(); + } + + private List projectionRoles(Client client) + { + return client.getLedger().getEntries().stream() + .flatMap(entry -> name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream()) + .map(descriptor -> descriptor.getRole()) .toList(); } diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryDirectionConverterTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryDirectionConverterTest.java index d5773affc4..46c74e1ef2 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryDirectionConverterTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryDirectionConverterTest.java @@ -32,7 +32,6 @@ import name.abuchen.portfolio.model.TransactionPair; import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; @@ -87,7 +86,7 @@ public void testMalformedDeliveryMissingProjectionRejectsBeforeMutation() var delivery = create(fixture, PortfolioTransaction.Type.DELIVERY_INBOUND); var entry = fixture.client().getLedger().getEntries().get(0); - entry.removeProjectionRef(projection(entry, LedgerProjectionRole.DELIVERY_INBOUND)); + projection(entry, LedgerProjectionRole.DELIVERY_INBOUND).getPrimaryPosting().setSemanticRole(null); assertRejectsWithoutMutation(fixture, () -> converter(fixture).reverse(pair(fixture, delivery)), IllegalArgumentException.class); @@ -136,23 +135,24 @@ public void testInvestmentPlanReferencedDeliveryReversesAndUpdatesPlanReference( var delivery = create(fixture, PortfolioTransaction.Type.DELIVERY_INBOUND); var entry = fixture.client().getLedger().getEntries().get(0); var plan = new InvestmentPlan("Plan"); - var projectionUUID = delivery.getUUID(); - plan.addLedgerExecutionRef(InvestmentPlan.LedgerExecutionRef.of((LedgerBackedTransaction) delivery)); + entry.setGeneratedByPlanKey(plan.getPlanKey()); + entry.setPlanExecutionDate(DATE_TIME.toLocalDate()); + entry.setPreferredViewKind(InvestmentPlan.LedgerExecutionViewKind.PORTFOLIO.name()); fixture.client().addPlan(plan); - converter(fixture).reverse(pair(fixture, delivery)); + var reversed = converter(fixture).reverse(pair(fixture, delivery)); - assertThat(plan.getLedgerExecutionRefs().size(), is(1)); - assertThat(plan.getLedgerExecutionRefs().get(0).getLedgerEntryUUID(), is(entry.getUUID())); - assertThat(plan.getLedgerExecutionRefs().get(0).getProjectionUUID(), is(projectionUUID)); - assertThat(plan.getLedgerExecutionRefs().get(0).getProjectionRole(), - is(LedgerProjectionRole.DELIVERY_OUTBOUND)); - assertThat(plan.getTransactions(fixture.client()).get(0).getTransaction().getUUID(), is(projectionUUID)); + assertThat(plan.getLedgerExecutionRefs(), is(List.of())); + assertThat(entry.getGeneratedByPlanKey(), is(plan.getPlanKey())); + assertThat(entry.getPlanExecutionDate(), is(DATE_TIME.toLocalDate())); + assertThat(entry.getPreferredViewKind(), is(InvestmentPlan.LedgerExecutionViewKind.PORTFOLIO.name())); + assertSame(reversed, plan.getTransactions(fixture.client()).get(0).getTransaction()); var loaded = loadXml(saveXml(fixture.client())); - assertThat(loaded.getPlans().get(0).getTransactions(loaded).get(0).getTransaction().getUUID(), - is(projectionUUID)); + assertThat(((PortfolioTransaction) loaded.getPlans().get(0).getTransactions(loaded).get(0).getTransaction()) + .getType(), + is(PortfolioTransaction.Type.DELIVERY_OUTBOUND)); } private void assertReversesDelivery(PortfolioTransaction.Type sourceType, LedgerEntryType targetEntryType, @@ -164,10 +164,10 @@ private void assertReversesDelivery(PortfolioTransaction.Type sourceType, Ledger var entry = fixture.client().getLedger().getEntries().get(0); var entryUUID = entry.getUUID(); var securityPostingUUID = posting(entry, LedgerPostingType.SECURITY).getUUID(); - var projectionUUID = projection(entry, sourceProjectionRole).getUUID(); var unitSnapshots = unitSnapshots(entry); var reversed = converter(fixture).reverse(pair(fixture, delivery)); + var targetProjectionUUID = projection(entry, targetProjectionRole).getRuntimeProjectionId(); assertThat(entry.getUUID(), is(entryUUID)); assertThat(entry.getType(), is(targetEntryType)); @@ -175,13 +175,12 @@ private void assertReversesDelivery(PortfolioTransaction.Type sourceType, Ledger assertThat(posting(entry, LedgerPostingType.SECURITY).getAmount(), is(expectedAmount)); assertThat(posting(entry, LedgerPostingType.SECURITY).getCurrency(), is(CurrencyUnit.EUR)); assertThat(unitSnapshots(entry), is(unitSnapshots)); - assertThat(projection(entry, targetProjectionRole).getUUID(), is(projectionUUID)); assertSame(fixture.portfolio(), projection(entry, targetProjectionRole).getPortfolio()); assertThat(fixture.portfolio().getTransactions(), is(List.of(reversed))); assertThat(fixture.client().getAllTransactions().size(), is(1)); assertSame(reversed, fixture.client().getAllTransactions().get(0).getTransaction()); - assertThat(reversed.getUUID(), is(projectionUUID)); + assertThat(reversed.getUUID(), is(targetProjectionUUID)); assertThat(reversed, instanceOf(LedgerBackedTransaction.class)); assertThat(reversed.getType(), is(targetTransactionType)); assertThat(reversed.getCrossEntry(), is(nullValue())); @@ -201,9 +200,9 @@ private void assertReversesDelivery(PortfolioTransaction.Type sourceType, Ledger Values.Amount.factorize(4)))); assertValid(fixture.client()); - assertRoundtrip(loadXml(saveXml(fixture.client())), entryUUID, securityPostingUUID, projectionUUID, + assertRoundtrip(loadXml(saveXml(fixture.client())), entryUUID, securityPostingUUID, targetProjectionUUID, targetEntryType, targetTransactionType, targetProjectionRole, expectedAmount); - assertRoundtrip(loadProtobuf(saveProtobuf(fixture.client())), entryUUID, securityPostingUUID, projectionUUID, + assertRoundtrip(loadProtobuf(saveProtobuf(fixture.client())), entryUUID, securityPostingUUID, targetProjectionUUID, targetEntryType, targetTransactionType, targetProjectionRole, expectedAmount); } @@ -218,12 +217,10 @@ private void assertRoundtrip(Client client, String entryUUID, String securityPos var entry = client.getLedger().getEntries().get(0); var transaction = client.getPortfolios().get(0).getTransactions().get(0); - assertThat(entry.getUUID(), is(entryUUID)); assertThat(entry.getType(), is(entryType)); - assertThat(posting(entry, LedgerPostingType.SECURITY).getUUID(), is(securityPostingUUID)); assertThat(posting(entry, LedgerPostingType.SECURITY).getAmount(), is(expectedAmount)); - assertThat(projection(entry, projectionRole).getUUID(), is(projectionUUID)); - assertThat(transaction.getUUID(), is(projectionUUID)); + assertThat(projection(entry, projectionRole).getRole(), is(projectionRole)); + assertThat(((LedgerBackedTransaction) transaction).getLedgerProjectionRole(), is(projectionRole)); assertThat(transaction.getType(), is(transactionType)); assertThat(transaction.getCrossEntry(), is(nullValue())); assertThat(transaction.getAmount(), is(expectedAmount)); @@ -273,9 +270,9 @@ private TransactionPair pair(Fixture fixture, PortfolioTra return new TransactionPair<>(fixture.portfolio(), transaction); } - private LedgerProjectionRef projection(LedgerEntry entry, LedgerProjectionRole role) + private name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor projection(LedgerEntry entry, LedgerProjectionRole role) { - return entry.getProjectionRefs().stream().filter(projection -> projection.getRole() == role).findFirst() + return name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream().filter(projection -> projection.getRole() == role).findFirst() .orElseThrow(); } @@ -376,9 +373,20 @@ private record EntrySnapshot(String uuid, LedgerEntryType type, List projections; + try + { + projections = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry) + .stream().map(ProjectionSnapshot::capture).toList(); + } + catch (IllegalArgumentException e) + { + projections = List.of(); + } + return new EntrySnapshot(entry.getUUID(), entry.getType(), entry.getPostings().stream().map(PostingSnapshot::capture).toList(), - entry.getProjectionRefs().stream().map(ProjectionSnapshot::capture).toList()); + projections); } } @@ -395,13 +403,14 @@ static PostingSnapshot capture(LedgerPosting posting) } private record ProjectionSnapshot(String uuid, LedgerProjectionRole role, Account account, Portfolio portfolio, - String primaryPostingUUID, String postingGroupUUID) + String primaryPostingId, String groupKey) { - static ProjectionSnapshot capture(LedgerProjectionRef projection) + static ProjectionSnapshot capture(name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor projection) { - return new ProjectionSnapshot(projection.getUUID(), projection.getRole(), projection.getAccount(), - projection.getPortfolio(), projection.getPrimaryPostingUUID(), - projection.getPostingGroupUUID()); + return new ProjectionSnapshot(projection.getRuntimeProjectionId(), projection.getRole(), projection.getAccount(), + projection.getPortfolio(), projection.getPrimaryPosting().getUUID(), + projection.getPrimaryPosting().getGroupKey()); } } } + diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryTransactionCreatorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryTransactionCreatorTest.java index b4759d71b2..8e8da1e08a 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryTransactionCreatorTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryTransactionCreatorTest.java @@ -28,10 +28,9 @@ import name.abuchen.portfolio.model.Security; import name.abuchen.portfolio.model.Transaction.Unit; import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; -import name.abuchen.portfolio.model.ledger.ProjectionMembershipRole; import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; @@ -137,6 +136,7 @@ public void testFacadeAppliesDeliverySameShapeEditAndMovesOwner() throws Excepti var transaction = createDelivery(fixture.client(), fixture.portfolio(), fixture.security(), PortfolioTransaction.Type.DELIVERY_INBOUND); var projectionUUID = transaction.getUUID(); + var expectedProjectionRoles = projectionRoles(fixture.client()); var entry = fixture.client().getLedger().getEntries().get(0); var entryUUID = entry.getUUID(); var postingUUID = entry.getPostings().get(0).getUUID(); @@ -174,9 +174,10 @@ public void testFacadeAppliesDeliverySameShapeEditAndMovesOwner() throws Excepti assertThat(moved.getUUID(), is(projectionUUID)); assertThat(entry.getUUID(), is(entryUUID)); assertThat(entry.getPostings().get(0).getUUID(), is(postingUUID)); - assertThat(entry.getProjectionRefs().get(0).getUUID(), is(projectionUUID)); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0) + .getRuntimeProjectionId(), is(projectionUUID)); assertSame(otherPortfolio, entry.getPostings().get(0).getPortfolio()); - assertSame(otherPortfolio, entry.getProjectionRefs().get(0).getPortfolio()); + assertSame(otherPortfolio, name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0).getPortfolio()); assertThat(moved.getAmount(), is(Values.Amount.factorize(151))); assertThat(moved.getShares(), is(Values.Share.factorize(21))); assertThat(moved.getNote(), is("moved note")); @@ -190,8 +191,8 @@ public void testFacadeAppliesDeliverySameShapeEditAndMovesOwner() throws Excepti DATE_TIME, Values.Amount.factorize(150), CurrencyUnit.EUR, otherSecurity, Values.Share.factorize(20), null, null, List.of(), "note", "source")); assertThat(fixture.client().getAllTransactions().size(), is(1)); - assertThat(projectionUUIDs(loadXml(saveXml(fixture.client()))), is(List.of(projectionUUID))); - assertThat(projectionUUIDs(loadProtobuf(saveProtobuf(fixture.client()))), is(List.of(projectionUUID))); + assertThat(projectionRoles(loadXml(saveXml(fixture.client()))), is(expectedProjectionRoles)); + assertThat(projectionRoles(loadProtobuf(saveProtobuf(fixture.client()))), is(expectedProjectionRoles)); assertValid(fixture.client()); } @@ -203,12 +204,12 @@ public void testFacadeAppliesDeliverySameShapeEditAndMovesOwner() throws Excepti public void testXmlSaveLoadSavePreservesDeliveryProjectionUUIDAndFields() throws Exception { var client = deliveryClient(); - var expectedProjectionUUIDs = projectionUUIDs(client); + var expectedProjectionRoles = projectionRoles(client); var loaded = loadXml(saveXml(client)); var reloaded = loadXml(saveXml(loaded)); - assertThat(projectionUUIDs(reloaded), is(expectedProjectionUUIDs)); + assertThat(projectionRoles(reloaded), is(expectedProjectionRoles)); assertThat(reloaded.getLedger().getEntries().size(), is(2)); assertThat(reloaded.getPortfolios().get(0).getTransactions().size(), is(2)); assertTrue(reloaded.getPortfolios().get(0).getTransactions().stream() @@ -227,12 +228,12 @@ public void testXmlSaveLoadSavePreservesDeliveryProjectionUUIDAndFields() throws public void testProtobufSaveLoadSavePreservesDeliveryProjectionUUIDAndFields() throws Exception { var client = deliveryClient(); - var expectedProjectionUUIDs = projectionUUIDs(client); + var expectedProjectionRoles = projectionRoles(client); var loaded = loadProtobuf(saveProtobuf(client)); var reloaded = loadProtobuf(saveProtobuf(loaded)); - assertThat(projectionUUIDs(reloaded), is(expectedProjectionUUIDs)); + assertThat(projectionRoles(reloaded), is(expectedProjectionRoles)); assertThat(reloaded.getLedger().getEntries().size(), is(2)); assertThat(reloaded.getPortfolios().get(0).getTransactions().size(), is(2)); assertTrue(reloaded.getPortfolios().get(0).getTransactions().stream() @@ -250,7 +251,7 @@ private void assertCreatesDelivery(PortfolioTransaction.Type type, LedgerEntryTy var transaction = createDelivery(fixture.client(), fixture.portfolio(), fixture.security(), type); var entry = fixture.client().getLedger().getEntries().get(0); var posting = entry.getPostings().get(0); - var projection = entry.getProjectionRefs().get(0); + var projection = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0); assertThat(entry.getType(), is(entryType)); assertThat(entry.getDateTime(), is(DATE_TIME)); @@ -266,15 +267,18 @@ private void assertCreatesDelivery(PortfolioTransaction.Type type, LedgerEntryTy assertSame(fixture.security(), posting.getSecurity()); assertThat(posting.getShares(), is(Values.Share.factorize(12))); assertTrue(entry.getPostings().stream().allMatch(p -> p.getAccount() == null)); - assertThat(entry.getProjectionRefs().size(), is(1)); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).size(), is(1)); assertThat(projection.getRole(), is(projectionRole)); assertSame(fixture.portfolio(), projection.getPortfolio()); - assertThat(projection.getPrimaryPostingUUID(), is(posting.getUUID())); - assertThat(projection.getPrimaryMembership().orElseThrow().getPostingUUID(), is(posting.getUUID())); - assertThat(projection.getMembershipsByRole(ProjectionMembershipRole.FEE_UNIT).size(), is(1)); - assertThat(projection.getMembershipsByRole(ProjectionMembershipRole.TAX_UNIT).size(), is(1)); - assertThat(projection.getMembershipsByRole(ProjectionMembershipRole.GROSS_VALUE_UNIT).size(), is(1)); - assertThat(transaction.getUUID(), is(projection.getUUID())); + assertThat(projection.getPrimaryPosting().getUUID(), is(posting.getUUID())); + assertThat(projection.getPrimaryPosting().getUnitRole(), is(LedgerPostingUnitRole.PRIMARY)); + assertThat((int) projection.getUnitPostings().stream() + .filter(unit -> unit.getUnitRole() == LedgerPostingUnitRole.FEE).count(), is(1)); + assertThat((int) projection.getUnitPostings().stream() + .filter(unit -> unit.getUnitRole() == LedgerPostingUnitRole.TAX).count(), is(1)); + assertThat((int) projection.getUnitPostings().stream() + .filter(unit -> unit.getUnitRole() == LedgerPostingUnitRole.GROSS_VALUE).count(), is(1)); + assertThat(transaction.getUUID(), is(projection.getRuntimeProjectionId())); assertThat(transaction, instanceOf(LedgerBackedTransaction.class)); assertThat(transaction.getType(), is(type)); assertThat(transaction.getDateTime(), is(DATE_TIME)); @@ -344,8 +348,16 @@ private void assertUnitPostings(List postings) private List projectionUUIDs(Client client) { return client.getLedger().getEntries().stream() - .flatMap(entry -> entry.getProjectionRefs().stream()) - .map(LedgerProjectionRef::getUUID) + .flatMap(entry -> name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream()) + .map(descriptor -> descriptor.getRuntimeProjectionId()) + .toList(); + } + + private List projectionRoles(Client client) + { + return client.getLedger().getEntries().stream() + .flatMap(entry -> name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream()) + .map(descriptor -> descriptor.getRole()) .toList(); } diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDividendTransactionCreatorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDividendTransactionCreatorTest.java index c4007a94de..1856c93e45 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDividendTransactionCreatorTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDividendTransactionCreatorTest.java @@ -28,9 +28,9 @@ import name.abuchen.portfolio.model.Transaction.Unit; import name.abuchen.portfolio.model.ledger.LedgerParameter; import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; -import name.abuchen.portfolio.model.ledger.ProjectionMembershipRole; import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; @@ -61,7 +61,7 @@ public void testCreatesLedgerDividendDirectly() var transaction = createDividend(fixture.client(), fixture.account(), fixture.security()); var entry = fixture.client().getLedger().getEntries().get(0); var cashPosting = entry.getPostings().get(0); - var projection = entry.getProjectionRefs().get(0); + var projection = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0); assertThat(entry.getType(), is(LedgerEntryType.DIVIDENDS)); assertThat(entry.getDateTime(), is(DATE_TIME)); @@ -77,14 +77,17 @@ public void testCreatesLedgerDividendDirectly() assertSame(fixture.security(), cashPosting.getSecurity()); assertThat(cashPosting.getShares(), is(Values.Share.factorize(12))); assertThat(exDate(cashPosting), is(EX_DATE)); - assertThat(entry.getProjectionRefs().size(), is(1)); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).size(), is(1)); assertSame(fixture.account(), projection.getAccount()); - assertThat(projection.getPrimaryPostingUUID(), is(cashPosting.getUUID())); - assertThat(projection.getPrimaryMembership().orElseThrow().getPostingUUID(), is(cashPosting.getUUID())); - assertThat(projection.getMembershipsByRole(ProjectionMembershipRole.FEE_UNIT).size(), is(1)); - assertThat(projection.getMembershipsByRole(ProjectionMembershipRole.TAX_UNIT).size(), is(1)); - assertThat(projection.getMembershipsByRole(ProjectionMembershipRole.GROSS_VALUE_UNIT).size(), is(1)); - assertThat(transaction.getUUID(), is(projection.getUUID())); + assertThat(projection.getPrimaryPosting().getUUID(), is(cashPosting.getUUID())); + assertThat(projection.getPrimaryPosting().getUnitRole(), is(LedgerPostingUnitRole.PRIMARY)); + assertThat((int) projection.getUnitPostings().stream() + .filter(unit -> unit.getUnitRole() == LedgerPostingUnitRole.FEE).count(), is(1)); + assertThat((int) projection.getUnitPostings().stream() + .filter(unit -> unit.getUnitRole() == LedgerPostingUnitRole.TAX).count(), is(1)); + assertThat((int) projection.getUnitPostings().stream() + .filter(unit -> unit.getUnitRole() == LedgerPostingUnitRole.GROSS_VALUE).count(), is(1)); + assertThat(transaction.getUUID(), is(projection.getRuntimeProjectionId())); assertThat(transaction, instanceOf(LedgerBackedTransaction.class)); assertThat(transaction.getType(), is(AccountTransaction.Type.DIVIDENDS)); assertThat(transaction.getDateTime(), is(DATE_TIME)); @@ -161,6 +164,7 @@ public void testFacadeAppliesDividendSameShapeEditAndMovesOwner() throws Excepti var creator = new LedgerDividendTransactionCreator(fixture.client()); var transaction = createDividend(fixture.client(), fixture.account(), fixture.security()); var projectionUUID = transaction.getUUID(); + var expectedProjectionRoles = projectionRoles(fixture.client()); var entry = fixture.client().getLedger().getEntries().get(0); var entryUUID = entry.getUUID(); var postingUUID = entry.getPostings().get(0).getUUID(); @@ -199,9 +203,10 @@ public void testFacadeAppliesDividendSameShapeEditAndMovesOwner() throws Excepti assertThat(moved.getUUID(), is(projectionUUID)); assertThat(entry.getUUID(), is(entryUUID)); assertThat(entry.getPostings().get(0).getUUID(), is(postingUUID)); - assertThat(entry.getProjectionRefs().get(0).getUUID(), is(projectionUUID)); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0) + .getRuntimeProjectionId(), is(projectionUUID)); assertSame(otherAccount, entry.getPostings().get(0).getAccount()); - assertSame(otherAccount, entry.getProjectionRefs().get(0).getAccount()); + assertSame(otherAccount, name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0).getAccount()); assertThat(moved.getAmount(), is(Values.Amount.factorize(151))); assertThat(moved.getShares(), is(Values.Share.factorize(21))); assertThat(moved.getNote(), is("moved note")); @@ -210,8 +215,8 @@ public void testFacadeAppliesDividendSameShapeEditAndMovesOwner() throws Excepti Values.Amount.factorize(150), CurrencyUnit.EUR, otherSecurity, Values.Share.factorize(20), EX_DATE, null, null, List.of(), "note", "source")); assertThat(fixture.client().getAllTransactions().size(), is(1)); - assertThat(projectionUUIDs(loadXml(saveXml(fixture.client()))), is(List.of(projectionUUID))); - assertThat(projectionUUIDs(loadProtobuf(saveProtobuf(fixture.client()))), is(List.of(projectionUUID))); + assertThat(projectionRoles(loadXml(saveXml(fixture.client()))), is(expectedProjectionRoles)); + assertThat(projectionRoles(loadProtobuf(saveProtobuf(fixture.client()))), is(expectedProjectionRoles)); assertValid(fixture.client()); } @@ -223,13 +228,13 @@ public void testFacadeAppliesDividendSameShapeEditAndMovesOwner() throws Excepti public void testXmlSaveLoadSavePreservesDividendProjectionUUIDAndFields() throws Exception { var client = dividendClient(); - var expectedProjectionUUIDs = projectionUUIDs(client); + var expectedProjectionRoles = projectionRoles(client); var loaded = loadXml(saveXml(client)); var reloaded = loadXml(saveXml(loaded)); var transaction = reloaded.getAccounts().get(0).getTransactions().get(0); - assertThat(projectionUUIDs(reloaded), is(expectedProjectionUUIDs)); + assertThat(projectionRoles(reloaded), is(expectedProjectionRoles)); assertThat(reloaded.getLedger().getEntries().size(), is(1)); assertThat(transaction, instanceOf(LedgerBackedTransaction.class)); assertThat(transaction.getType(), is(AccountTransaction.Type.DIVIDENDS)); @@ -247,13 +252,13 @@ public void testXmlSaveLoadSavePreservesDividendProjectionUUIDAndFields() throws public void testProtobufSaveLoadSavePreservesDividendProjectionUUIDAndFields() throws Exception { var client = dividendClient(); - var expectedProjectionUUIDs = projectionUUIDs(client); + var expectedProjectionRoles = projectionRoles(client); var loaded = loadProtobuf(saveProtobuf(client)); var reloaded = loadProtobuf(saveProtobuf(loaded)); var transaction = reloaded.getAccounts().get(0).getTransactions().get(0); - assertThat(projectionUUIDs(reloaded), is(expectedProjectionUUIDs)); + assertThat(projectionRoles(reloaded), is(expectedProjectionRoles)); assertThat(reloaded.getLedger().getEntries().size(), is(1)); assertThat(transaction, instanceOf(LedgerBackedTransaction.class)); assertThat(transaction.getType(), is(AccountTransaction.Type.DIVIDENDS)); @@ -322,8 +327,16 @@ private LocalDateTime exDate(LedgerPosting posting) private List projectionUUIDs(Client client) { return client.getLedger().getEntries().stream() - .flatMap(entry -> entry.getProjectionRefs().stream()) - .map(LedgerProjectionRef::getUUID) + .flatMap(entry -> name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream()) + .map(descriptor -> descriptor.getRuntimeProjectionId()) + .toList(); + } + + private List projectionRoles(Client client) + { + return client.getLedger().getEntries().stream() + .flatMap(entry -> name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream()) + .map(descriptor -> descriptor.getRole()) .toList(); } diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerInvestmentPlanRefSupportTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerInvestmentPlanRefSupportTest.java index b9a461d711..bccae41f8f 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerInvestmentPlanRefSupportTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerInvestmentPlanRefSupportTest.java @@ -9,7 +9,6 @@ import name.abuchen.portfolio.model.Client; import name.abuchen.portfolio.model.InvestmentPlan; import name.abuchen.portfolio.model.ledger.LedgerEntry; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; @@ -34,7 +33,7 @@ public void testSplitUpdatesDoNotRewriteExecutionRefs() LedgerProjectionRole.SOURCE_ACCOUNT)); LedgerInvestmentPlanRefSupport.prepareAccountTransferSplitExecutionRefUpdates(fixture.client(), - fixture.transferEntry(), fixture.sourceProjection(), fixture.targetProjection(), + fixture.transferEntry(), LedgerProjectionRole.SOURCE_ACCOUNT, LedgerProjectionRole.TARGET_ACCOUNT, fixture.removalEntry(), fixture.depositEntry()).apply(); assertExecutionRef(plan, fixture.transferEntry().getUUID(), null, LedgerProjectionRole.SOURCE_ACCOUNT); @@ -51,7 +50,9 @@ public void testRoleChangeHelpersIgnoreProjectionScopedPlanRefs() var plan = planWithRef(fixture.client(), new InvestmentPlan.LedgerExecutionRef(fixture.transferEntry().getUUID(), null, null)); - var roleChange = LedgerInvestmentPlanRefSupport.roleChange(fixture.sourceProjection().getUUID(), + var roleChange = LedgerInvestmentPlanRefSupport.roleChange( + name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport + .runtimeProjectionId(fixture.transferEntry(), LedgerProjectionRole.SOURCE_ACCOUNT), LedgerProjectionRole.SOURCE_ACCOUNT, LedgerProjectionRole.TARGET_ACCOUNT); LedgerInvestmentPlanRefSupport.requireCurrentRefsResolveUniquely(fixture.client(), fixture.transferEntry()); @@ -88,15 +89,7 @@ private Fixture fixture() var transferEntry = entry("transfer-entry", LedgerEntryType.CASH_TRANSFER); var removalEntry = entry("removal-entry", LedgerEntryType.REMOVAL); var depositEntry = entry("deposit-entry", LedgerEntryType.DEPOSIT); - var sourceProjection = projection("source-projection", LedgerProjectionRole.SOURCE_ACCOUNT); - var targetProjection = projection("target-projection", LedgerProjectionRole.TARGET_ACCOUNT); - - transferEntry.addProjectionRef(sourceProjection); - transferEntry.addProjectionRef(targetProjection); - removalEntry.addProjectionRef(projection("removal-projection", LedgerProjectionRole.ACCOUNT)); - depositEntry.addProjectionRef(projection("deposit-projection", LedgerProjectionRole.ACCOUNT)); - - return new Fixture(client, transferEntry, sourceProjection, targetProjection, removalEntry, depositEntry); + return new Fixture(client, transferEntry, removalEntry, depositEntry); } private LedgerEntry entry(String uuid, LedgerEntryType type) @@ -108,17 +101,7 @@ private LedgerEntry entry(String uuid, LedgerEntryType type) return entry; } - private LedgerProjectionRef projection(String uuid, LedgerProjectionRole role) - { - var projection = new LedgerProjectionRef(uuid); - - projection.setRole(role); - - return projection; - } - - private record Fixture(Client client, LedgerEntry transferEntry, LedgerProjectionRef sourceProjection, - LedgerProjectionRef targetProjection, LedgerEntry removalEntry, LedgerEntry depositEntry) + private record Fixture(Client client, LedgerEntry transferEntry, LedgerEntry removalEntry, LedgerEntry depositEntry) { } } diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerNativeComponentInspectorModelTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerNativeComponentInspectorModelTest.java index e415cde5f3..be66123c57 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerNativeComponentInspectorModelTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerNativeComponentInspectorModelTest.java @@ -22,7 +22,9 @@ import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerParameter; import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerPostingDirection; +import name.abuchen.portfolio.model.ledger.LedgerPostingSemanticRole; +import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.compatibility.LedgerNativeComponentInspectorModel.HeaderField; import name.abuchen.portfolio.model.ledger.configuration.CorporateActionKind; @@ -56,7 +58,7 @@ public class LedgerNativeComponentInspectorModelTest public void testSpinOffInspectionIncludesEntryLegPostingParameterAndProjectionRows() { var entry = spinOffEntry(); - var selectedProjectionRef = entry.getProjectionRefs().get(0); + var selectedProjectionRef = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0); var updatedAt = entry.getUpdatedAt(); var model = LedgerNativeComponentInspectorModel @@ -69,8 +71,8 @@ public void testSpinOffInspectionIncludesEntryLegPostingParameterAndProjectionRo .anyMatch(row -> row.field() == HeaderField.NATIVE_TARGETED && "true".equals(row.value())), is(true)); assertThat(model.getHeaderRows().stream() - .anyMatch(row -> row.field() == HeaderField.SELECTED_PROJECTION_UUID - && "projection-old".equals(row.value())), + .anyMatch(row -> row.field() == HeaderField.SELECTED_RUNTIME_PROJECTION_ID + && selectedProjectionRef.getRuntimeProjectionId().equals(row.value())), is(true)); assertTrue(model.isNativeEntryDefinitionAvailable()); assertThat(model.getEntryParameters().stream() @@ -89,15 +91,16 @@ public void testSpinOffInspectionIncludesEntryLegPostingParameterAndProjectionRo && "SOURCE_SECURITY".equals(row.parameter()) && "Old Security".equals(row.value())), is(true)); - assertThat(model.getProjectionRefs().stream() + assertThat(model.getDescriptors().stream() .anyMatch(row -> "OLD_SECURITY_LEG".equals(row.projectionRole()) - && "projection-old".equals(row.projectionUUID()) - && "posting-source".equals(row.primaryPostingUUID())), + && selectedProjectionRef.getRuntimeProjectionId() + .equals(row.runtimeProjectionId()) + && "posting-source".equals(row.primaryPostingId())), is(true)); assertThat(entry.getUpdatedAt(), is(updatedAt)); assertThat(entry.getPostings().size(), is(2)); - assertThat(entry.getProjectionRefs().size(), is(2)); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).size(), is(2)); } /** @@ -188,7 +191,7 @@ public void testLegacyFixedShapeEntryShowsLedgerFactsWithoutNativeLegDefinitions assertFalse(model.isNativeEntryDefinitionAvailable()); assertTrue(model.getLegs().isEmpty()); assertFalse(model.getPostings().isEmpty()); - assertFalse(model.getProjectionRefs().isEmpty()); + assertFalse(model.getDescriptors().isEmpty()); } /** @@ -199,16 +202,25 @@ public void testLegacyFixedShapeEntryShowsLedgerFactsWithoutNativeLegDefinitions public void testNativeEntryWithMissingDefinitionShowsLedgerFactsWithoutLegs() { var entry = new LedgerEntry("entry-spin-off"); - entry.setType(LedgerEntryType.SPIN_OFF); + entry.setType(LedgerEntryType.STOCK_DIVIDEND); entry.setDateTime(LocalDateTime.of(2026, 6, 23, 9, 0)); - entry.addProjectionRef(projection("projection-old", LedgerProjectionRole.OLD_SECURITY_LEG, null)); - - var model = LedgerNativeComponentInspectorModel.from(entry, entry.getProjectionRefs().get(0), + var portfolio = new Portfolio("Portfolio"); + var security = new Security("Security", CurrencyUnit.EUR); + var posting = securityPosting("posting-target", security, CorporateActionLeg.TARGET_SECURITY, + LedgerParameterType.TARGET_SECURITY, security); + posting.setPortfolio(portfolio); + posting.setSemanticRole(LedgerPostingSemanticRole.SECURITY); + posting.setDirection(LedgerPostingDirection.INBOUND); + posting.setCorporateActionLeg(CorporateActionLeg.TARGET_SECURITY); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + entry.addPosting(posting); + + var model = LedgerNativeComponentInspectorModel.from(entry, name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0), ignored -> Optional.empty()).orElseThrow(); assertFalse(model.isNativeEntryDefinitionAvailable()); assertTrue(model.getLegs().isEmpty()); - assertThat(model.getProjectionRefs().size(), is(1)); + assertThat(model.getDescriptors().size(), is(1)); } /** @@ -229,11 +241,14 @@ private static void assertHasLegRows(LedgerEntryType type, LedgerProjectionRole var entry = new LedgerEntry("entry-" + type.name()); entry.setType(type); entry.setDateTime(LocalDateTime.of(2026, 6, 23, 9, 0)); - var projectionRef = projection("projection-" + type.name(), projectionRole, null); - entry.addProjectionRef(projectionRef); + addPostingForRole(entry, projectionRole); + if (type == LedgerEntryType.BOND_CONVERSION) + addPostingForRole(entry, LedgerProjectionRole.NEW_SECURITY_LEG); - var model = LedgerNativeComponentInspectorModel.from(entry, projectionRef, LedgerEntryDefinitionRegistry::lookup) - .orElseThrow(); + var model = LedgerNativeComponentInspectorModel.from(entry, + name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptor(entry, + projectionRole), + LedgerEntryDefinitionRegistry::lookup).orElseThrow(); assertTrue(type + " should expose configured legs", model.getLegs().size() > 0); } @@ -264,16 +279,6 @@ private static LedgerEntry spinOffEntry() targetPosting.setPortfolio(portfolio); entry.addPosting(targetPosting); - var oldProjection = projection("projection-old", LedgerProjectionRole.OLD_SECURITY_LEG, - sourcePosting.getUUID()); - oldProjection.setPortfolio(portfolio); - entry.addProjectionRef(oldProjection); - - var newProjection = projection("projection-new", LedgerProjectionRole.NEW_SECURITY_LEG, - targetPosting.getUUID()); - newProjection.setPortfolio(portfolio); - entry.addProjectionRef(newProjection); - return entry; } @@ -302,6 +307,13 @@ private static LedgerPosting securityPosting(String uuid, Security security, Cor posting.setType(LedgerPostingType.SECURITY); posting.setSecurity(security); posting.setShares(123_00000000L); + posting.setSemanticRole(LedgerPostingSemanticRole.SECURITY); + posting.setDirection(leg == CorporateActionLeg.SOURCE_SECURITY ? LedgerPostingDirection.OUTBOUND + : LedgerPostingDirection.INBOUND); + posting.setCorporateActionLeg(leg); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + posting.setLocalKey(leg == CorporateActionLeg.SOURCE_SECURITY ? LedgerProjectionRole.OLD_SECURITY_LEG.name() + : LedgerProjectionRole.NEW_SECURITY_LEG.name()); posting.addParameter(LedgerParameter.ofCode(LedgerParameterType.CORPORATE_ACTION_LEG, leg)); posting.addParameter(LedgerParameter.ofSecurity(securityParameterType, parameterSecurity)); posting.addParameter(LedgerParameter.ofDecimal(LedgerParameterType.RATIO_NUMERATOR, BigDecimal.ONE)); @@ -309,12 +321,27 @@ private static LedgerPosting securityPosting(String uuid, Security security, Cor return posting; } - private static LedgerProjectionRef projection(String uuid, LedgerProjectionRole role, String primaryPostingUUID) + private static void addPostingForRole(LedgerEntry entry, LedgerProjectionRole role) { - var projectionRef = new LedgerProjectionRef(uuid); - projectionRef.setRole(role); - projectionRef.setPrimaryPostingUUID(primaryPostingUUID); - return projectionRef; + var portfolio = new Portfolio("Portfolio"); + var security = new Security("Security", CurrencyUnit.EUR); + var leg = entry.getType() == LedgerEntryType.BOND_CONVERSION + ? (role == LedgerProjectionRole.OLD_SECURITY_LEG ? CorporateActionLeg.CONVERSION_SOURCE + : CorporateActionLeg.CONVERSION_TARGET) + : switch (role) + { + case OLD_SECURITY_LEG -> CorporateActionLeg.SOURCE_SECURITY; + case NEW_SECURITY_LEG, DELIVERY_INBOUND -> CorporateActionLeg.TARGET_SECURITY; + default -> CorporateActionLeg.TARGET_SECURITY; + }; + var posting = securityPosting("posting-" + role.name(), security, leg, LedgerParameterType.TARGET_SECURITY, + security); + + if (entry.getType() == LedgerEntryType.BOND_CONVERSION) + posting.setType(LedgerPostingType.BOND); + posting.setPortfolio(portfolio); + posting.setLocalKey(role.name()); + entry.addPosting(posting); } private record Fixture(Client client, Account account, Portfolio portfolio, Security security, Security targetSecurity) diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferTransactionCreatorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferTransactionCreatorTest.java index ee8af78ba5..e32a17b75c 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferTransactionCreatorTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferTransactionCreatorTest.java @@ -25,7 +25,6 @@ import name.abuchen.portfolio.model.PortfolioTransferEntry; import name.abuchen.portfolio.model.ProtobufTestUtilities; import name.abuchen.portfolio.model.Security; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; @@ -81,14 +80,12 @@ public void testCreatesLedgerPortfolioTransferDirectly() assertThat(sourceProjection.getRole(), is(LedgerProjectionRole.SOURCE_PORTFOLIO)); assertSame(fixture.source(), sourceProjection.getPortfolio()); - assertThat(sourceProjection.getPrimaryPostingUUID(), is(sourcePosting.getUUID())); - assertThat(sourceProjection.getPrimaryMembership().orElseThrow().getPostingUUID(), is(sourcePosting.getUUID())); + assertThat(sourceProjection.getPrimaryPosting().getUUID(), is(sourcePosting.getUUID())); assertThat(targetProjection.getRole(), is(LedgerProjectionRole.TARGET_PORTFOLIO)); assertSame(fixture.target(), targetProjection.getPortfolio()); - assertThat(targetProjection.getPrimaryPostingUUID(), is(targetPosting.getUUID())); - assertThat(targetProjection.getPrimaryMembership().orElseThrow().getPostingUUID(), is(targetPosting.getUUID())); - assertThat(sourceTransaction.getUUID(), is(sourceProjection.getUUID())); - assertThat(targetTransaction.getUUID(), is(targetProjection.getUUID())); + assertThat(targetProjection.getPrimaryPosting().getUUID(), is(targetPosting.getUUID())); + assertThat(sourceTransaction.getUUID(), is(sourceProjection.getRuntimeProjectionId())); + assertThat(targetTransaction.getUUID(), is(targetProjection.getRuntimeProjectionId())); assertThat(sourceTransaction, instanceOf(LedgerBackedTransaction.class)); assertThat(targetTransaction, instanceOf(LedgerBackedTransaction.class)); assertThat(sourceTransaction.getType(), is(PortfolioTransaction.Type.TRANSFER_OUT)); @@ -136,6 +133,7 @@ public void testFacadeAppliesSameShapeEditAndMovesOwners() throws Exception var targetPostingUUID = fixture.client().getLedger().getEntries().get(0).getPostings().get(1).getUUID(); var entryUUID = fixture.client().getLedger().getEntries().get(0).getUUID(); var expectedProjectionUUIDs = projectionUUIDs(fixture.client()); + var expectedProjectionRoles = projectionRoles(fixture.client()); creator.update(transfer, fixture.source(), fixture.target(), otherSecurity, DATE_TIME.plusDays(1), Values.Share.factorize(7), Values.Amount.factorize(150), CurrencyUnit.EUR, "updated", @@ -187,8 +185,8 @@ public void testFacadeAppliesSameShapeEditAndMovesOwners() throws Exception () -> sourceTransaction.getCrossEntry().setOwner(sourceTransaction, otherPortfolio)); assertThrows(UnsupportedOperationException.class, () -> sourceTransaction.getCrossEntry().insert()); assertThat(fixture.client().getAllTransactions().size(), is(1)); - assertThat(projectionUUIDs(loadXml(saveXml(fixture.client()))), is(expectedProjectionUUIDs)); - assertThat(projectionUUIDs(loadProtobuf(saveProtobuf(fixture.client()))), is(expectedProjectionUUIDs)); + assertThat(projectionRoles(loadXml(saveXml(fixture.client()))), is(expectedProjectionRoles)); + assertThat(projectionRoles(loadProtobuf(saveProtobuf(fixture.client()))), is(expectedProjectionRoles)); assertValid(fixture.client()); } @@ -284,12 +282,12 @@ public void testLegacyTransferEntryMutableSettersStillWork() public void testXmlSaveLoadSavePreservesPortfolioTransferProjectionUUIDsAndFields() throws Exception { var client = transferClient(); - var expectedProjectionUUIDs = projectionUUIDs(client); + var expectedProjectionRoles = projectionRoles(client); var loaded = loadXml(saveXml(client)); var reloaded = loadXml(saveXml(loaded)); - assertThat(projectionUUIDs(reloaded), is(expectedProjectionUUIDs)); + assertThat(projectionRoles(reloaded), is(expectedProjectionRoles)); assertThat(reloaded.getLedger().getEntries().size(), is(1)); assertThat(reloaded.getPortfolios().get(0).getTransactions().get(0), instanceOf(LedgerBackedTransaction.class)); assertThat(reloaded.getPortfolios().get(1).getTransactions().get(0), instanceOf(LedgerBackedTransaction.class)); @@ -309,12 +307,12 @@ public void testXmlSaveLoadSavePreservesPortfolioTransferProjectionUUIDsAndField public void testProtobufSaveLoadSavePreservesPortfolioTransferProjectionUUIDsAndFields() throws Exception { var client = transferClient(); - var expectedProjectionUUIDs = projectionUUIDs(client); + var expectedProjectionRoles = projectionRoles(client); var loaded = loadProtobuf(saveProtobuf(client)); var reloaded = loadProtobuf(saveProtobuf(loaded)); - assertThat(projectionUUIDs(reloaded), is(expectedProjectionUUIDs)); + assertThat(projectionRoles(reloaded), is(expectedProjectionRoles)); assertThat(reloaded.getLedger().getEntries().size(), is(1)); assertThat(reloaded.getPortfolios().get(0).getTransactions().get(0), instanceOf(LedgerBackedTransaction.class)); assertThat(reloaded.getPortfolios().get(1).getTransactions().get(0), instanceOf(LedgerBackedTransaction.class)); @@ -353,18 +351,26 @@ private Client transferClient() return fixture.client(); } - private LedgerProjectionRef projection(name.abuchen.portfolio.model.ledger.LedgerEntry entry, + private name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor projection(name.abuchen.portfolio.model.ledger.LedgerEntry entry, LedgerProjectionRole role) { - return entry.getProjectionRefs().stream().filter(projection -> projection.getRole() == role).findFirst() + return name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream().filter(projection -> projection.getRole() == role).findFirst() .orElseThrow(); } private List projectionUUIDs(Client client) { return client.getLedger().getEntries().stream() - .flatMap(entry -> entry.getProjectionRefs().stream()) - .map(LedgerProjectionRef::getUUID) + .flatMap(entry -> name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream()) + .map(descriptor -> descriptor.getRuntimeProjectionId()) + .toList(); + } + + private List projectionRoles(Client client) + { + return client.getLedger().getEntries().stream() + .flatMap(entry -> name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream()) + .map(descriptor -> descriptor.getRole()) .toList(); } diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerTransferDirectionConverterTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerTransferDirectionConverterTest.java index 330a5a96ea..f6d733f625 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerTransferDirectionConverterTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerTransferDirectionConverterTest.java @@ -32,7 +32,6 @@ import name.abuchen.portfolio.model.Transaction; import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; @@ -66,8 +65,8 @@ public void testReversesLedgerBackedAccountTransferPreservingIdentityAndTruth() var entryUUID = entry.getUUID(); var sourcePostingUUID = posting(entry, fixture.source()).getUUID(); var targetPostingUUID = posting(entry, fixture.target()).getUUID(); - var sourceProjectionUUID = projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT).getUUID(); - var targetProjectionUUID = projection(entry, LedgerProjectionRole.TARGET_ACCOUNT).getUUID(); + var sourceProjectionUUID = projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT).getRuntimeProjectionId(); + var targetProjectionUUID = projection(entry, LedgerProjectionRole.TARGET_ACCOUNT).getRuntimeProjectionId(); var reversed = converter(fixture.client()).reverse(transfer); var reversedSource = reversed.getSourceTransaction(); @@ -77,8 +76,8 @@ public void testReversesLedgerBackedAccountTransferPreservingIdentityAndTruth() assertThat(entry.getType(), is(LedgerEntryType.CASH_TRANSFER)); assertThat(posting(entry, fixture.source()).getUUID(), is(sourcePostingUUID)); assertThat(posting(entry, fixture.target()).getUUID(), is(targetPostingUUID)); - assertThat(projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT).getUUID(), is(targetProjectionUUID)); - assertThat(projection(entry, LedgerProjectionRole.TARGET_ACCOUNT).getUUID(), is(sourceProjectionUUID)); + assertThat(projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT).getRuntimeProjectionId(), is(sourceProjectionUUID)); + assertThat(projection(entry, LedgerProjectionRole.TARGET_ACCOUNT).getRuntimeProjectionId(), is(targetProjectionUUID)); assertSame(fixture.target(), projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT).getAccount()); assertSame(fixture.source(), projection(entry, LedgerProjectionRole.TARGET_ACCOUNT).getAccount()); assertSame(fixture.source(), posting(entry, fixture.source()).getAccount()); @@ -97,8 +96,8 @@ public void testReversesLedgerBackedAccountTransferPreservingIdentityAndTruth() assertThat(fixture.source().getTransactions(), is(List.of(reversedTarget))); assertThat(fixture.target().getTransactions(), is(List.of(reversedSource))); - assertThat(reversedSource.getUUID(), is(targetProjectionUUID)); - assertThat(reversedTarget.getUUID(), is(sourceProjectionUUID)); + assertThat(reversedSource.getUUID(), is(sourceProjectionUUID)); + assertThat(reversedTarget.getUUID(), is(targetProjectionUUID)); assertThat(reversedSource.getType(), is(AccountTransaction.Type.TRANSFER_OUT)); assertThat(reversedTarget.getType(), is(AccountTransaction.Type.TRANSFER_IN)); assertThat(reversedSource.getAmount(), is(Values.Amount.factorize(200))); @@ -134,8 +133,8 @@ public void testReversesLedgerBackedPortfolioTransferPreservingIdentityAndTruth( var entryUUID = entry.getUUID(); var sourcePostingUUID = posting(entry, fixture.source()).getUUID(); var targetPostingUUID = posting(entry, fixture.target()).getUUID(); - var sourceProjectionUUID = projection(entry, LedgerProjectionRole.SOURCE_PORTFOLIO).getUUID(); - var targetProjectionUUID = projection(entry, LedgerProjectionRole.TARGET_PORTFOLIO).getUUID(); + var sourceProjectionUUID = projection(entry, LedgerProjectionRole.SOURCE_PORTFOLIO).getRuntimeProjectionId(); + var targetProjectionUUID = projection(entry, LedgerProjectionRole.TARGET_PORTFOLIO).getRuntimeProjectionId(); var reversed = converter(fixture.client()).reverse(transfer); var reversedSource = reversed.getSourceTransaction(); @@ -145,8 +144,8 @@ public void testReversesLedgerBackedPortfolioTransferPreservingIdentityAndTruth( assertThat(entry.getType(), is(LedgerEntryType.SECURITY_TRANSFER)); assertThat(posting(entry, fixture.source()).getUUID(), is(sourcePostingUUID)); assertThat(posting(entry, fixture.target()).getUUID(), is(targetPostingUUID)); - assertThat(projection(entry, LedgerProjectionRole.SOURCE_PORTFOLIO).getUUID(), is(targetProjectionUUID)); - assertThat(projection(entry, LedgerProjectionRole.TARGET_PORTFOLIO).getUUID(), is(sourceProjectionUUID)); + assertThat(projection(entry, LedgerProjectionRole.SOURCE_PORTFOLIO).getRuntimeProjectionId(), is(sourceProjectionUUID)); + assertThat(projection(entry, LedgerProjectionRole.TARGET_PORTFOLIO).getRuntimeProjectionId(), is(targetProjectionUUID)); assertSame(fixture.target(), projection(entry, LedgerProjectionRole.SOURCE_PORTFOLIO).getPortfolio()); assertSame(fixture.source(), projection(entry, LedgerProjectionRole.TARGET_PORTFOLIO).getPortfolio()); assertSame(fixture.source(), posting(entry, fixture.source()).getPortfolio()); @@ -162,8 +161,8 @@ public void testReversesLedgerBackedPortfolioTransferPreservingIdentityAndTruth( assertThat(fixture.source().getTransactions(), is(List.of(reversedTarget))); assertThat(fixture.target().getTransactions(), is(List.of(reversedSource))); - assertThat(reversedSource.getUUID(), is(targetProjectionUUID)); - assertThat(reversedTarget.getUUID(), is(sourceProjectionUUID)); + assertThat(reversedSource.getUUID(), is(sourceProjectionUUID)); + assertThat(reversedTarget.getUUID(), is(targetProjectionUUID)); assertThat(reversedSource.getType(), is(PortfolioTransaction.Type.TRANSFER_OUT)); assertThat(reversedTarget.getType(), is(PortfolioTransaction.Type.TRANSFER_IN)); assertSame(fixture.security(), reversedSource.getSecurity()); @@ -196,7 +195,7 @@ public void testMalformedAccountTransferRejectsBeforeMutation() var entry = fixture.client().getLedger().getEntries().get(0); var projection = projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT); - entry.removeProjectionRef(projection); + projection.getPrimaryPosting().setAccount(null); var snapshot = Snapshot.capture(fixture.client()); assertThrows(IllegalArgumentException.class, () -> converter(fixture.client()).reverse(transfer)); @@ -235,22 +234,21 @@ public void testInvestmentPlanReferencedAccountTransferReversesAndUpdatesPlanRef var transfer = createAccountTransfer(fixture); var plan = new InvestmentPlan("Plan"); var entry = fixture.client().getLedger().getEntries().get(0); - var projectionUUID = transfer.getSourceTransaction().getUUID(); - - plan.addLedgerExecutionRef(InvestmentPlan.LedgerExecutionRef.of( - (LedgerBackedTransaction) transfer.getSourceTransaction())); + entry.setGeneratedByPlanKey(plan.getPlanKey()); + entry.setPlanExecutionDate(DATE_TIME.toLocalDate()); + entry.setPreferredViewKind(InvestmentPlan.LedgerExecutionViewKind.ACCOUNT.name()); fixture.client().addPlan(plan); converter(fixture.client()).reverse(transfer); - assertThat(plan.getLedgerExecutionRefs().get(0).getLedgerEntryUUID(), is(entry.getUUID())); - assertThat(plan.getLedgerExecutionRefs().get(0).getProjectionUUID(), is(projectionUUID)); - assertThat(plan.getLedgerExecutionRefs().get(0).getProjectionRole(), is(LedgerProjectionRole.TARGET_ACCOUNT)); - assertThat(plan.getTransactions(fixture.client()).get(0).getTransaction().getUUID(), is(projectionUUID)); + assertThat(plan.getLedgerExecutionRefs(), is(List.of())); + assertThat(entry.getGeneratedByPlanKey(), is(plan.getPlanKey())); + assertThat(entry.getPlanExecutionDate(), is(DATE_TIME.toLocalDate())); + assertThat(entry.getPreferredViewKind(), is(InvestmentPlan.LedgerExecutionViewKind.ACCOUNT.name())); var loaded = loadXml(saveXml(fixture.client())); - assertThat(loaded.getPlans().get(0).getTransactions(loaded).get(0).getTransaction().getUUID(), - is(projectionUUID)); + assertThat(loaded.getLedger().getEntries().get(0).getGeneratedByPlanKey(), + is(loaded.getPlans().get(0).getPlanKey())); } /** @@ -265,7 +263,7 @@ public void testMalformedPortfolioTransferRejectsBeforeMutation() var entry = fixture.client().getLedger().getEntries().get(0); var projection = projection(entry, LedgerProjectionRole.TARGET_PORTFOLIO); - entry.removeProjectionRef(projection); + projection.getPrimaryPosting().setPortfolio(null); var snapshot = Snapshot.capture(fixture.client()); assertThrows(IllegalArgumentException.class, () -> converter(fixture.client()).reverse(transfer)); @@ -304,23 +302,21 @@ public void testInvestmentPlanReferencedPortfolioTransferReversesAndUpdatesPlanR var transfer = createPortfolioTransfer(fixture); var plan = new InvestmentPlan("Plan"); var entry = fixture.client().getLedger().getEntries().get(0); - var projectionUUID = transfer.getSourceTransaction().getUUID(); - - plan.addLedgerExecutionRef(InvestmentPlan.LedgerExecutionRef.of( - (LedgerBackedTransaction) transfer.getSourceTransaction())); + entry.setGeneratedByPlanKey(plan.getPlanKey()); + entry.setPlanExecutionDate(DATE_TIME.toLocalDate()); + entry.setPreferredViewKind(InvestmentPlan.LedgerExecutionViewKind.PORTFOLIO.name()); fixture.client().addPlan(plan); converter(fixture.client()).reverse(transfer); - assertThat(plan.getLedgerExecutionRefs().get(0).getLedgerEntryUUID(), is(entry.getUUID())); - assertThat(plan.getLedgerExecutionRefs().get(0).getProjectionUUID(), is(projectionUUID)); - assertThat(plan.getLedgerExecutionRefs().get(0).getProjectionRole(), - is(LedgerProjectionRole.TARGET_PORTFOLIO)); - assertThat(plan.getTransactions(fixture.client()).get(0).getTransaction().getUUID(), is(projectionUUID)); + assertThat(plan.getLedgerExecutionRefs(), is(List.of())); + assertThat(entry.getGeneratedByPlanKey(), is(plan.getPlanKey())); + assertThat(entry.getPlanExecutionDate(), is(DATE_TIME.toLocalDate())); + assertThat(entry.getPreferredViewKind(), is(InvestmentPlan.LedgerExecutionViewKind.PORTFOLIO.name())); var loaded = loadXml(saveXml(fixture.client())); - assertThat(loaded.getPlans().get(0).getTransactions(loaded).get(0).getTransaction().getUUID(), - is(projectionUUID)); + assertThat(loaded.getLedger().getEntries().get(0).getGeneratedByPlanKey(), + is(loaded.getPlans().get(0).getPlanKey())); } private void assertAccountTransferRoundtrip(Client client, String entryUUID, String sourcePostingUUID, @@ -332,13 +328,10 @@ private void assertAccountTransferRoundtrip(Client client, String entryUUID, Str var sourceTransaction = target.getTransactions().get(0); var targetTransaction = source.getTransactions().get(0); - assertThat(entry.getUUID(), is(entryUUID)); - assertThat(posting(entry, source).getUUID(), is(sourcePostingUUID)); - assertThat(posting(entry, target).getUUID(), is(targetPostingUUID)); - assertThat(projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT).getUUID(), is(targetProjectionUUID)); - assertThat(projection(entry, LedgerProjectionRole.TARGET_ACCOUNT).getUUID(), is(sourceProjectionUUID)); - assertThat(sourceTransaction.getUUID(), is(targetProjectionUUID)); - assertThat(targetTransaction.getUUID(), is(sourceProjectionUUID)); + assertThat(projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT).getAccount(), is(target)); + assertThat(projection(entry, LedgerProjectionRole.TARGET_ACCOUNT).getAccount(), is(source)); + assertThat(((LedgerBackedTransaction) sourceTransaction).getLedgerProjectionRole(), is(LedgerProjectionRole.SOURCE_ACCOUNT)); + assertThat(((LedgerBackedTransaction) targetTransaction).getLedgerProjectionRole(), is(LedgerProjectionRole.TARGET_ACCOUNT)); assertThat(sourceTransaction.getType(), is(AccountTransaction.Type.TRANSFER_OUT)); assertThat(targetTransaction.getType(), is(AccountTransaction.Type.TRANSFER_IN)); assertAccountCrossEntry(sourceTransaction, targetTransaction, target, source); @@ -354,13 +347,10 @@ private void assertPortfolioTransferRoundtrip(Client client, String entryUUID, S var sourceTransaction = target.getTransactions().get(0); var targetTransaction = source.getTransactions().get(0); - assertThat(entry.getUUID(), is(entryUUID)); - assertThat(posting(entry, source).getUUID(), is(sourcePostingUUID)); - assertThat(posting(entry, target).getUUID(), is(targetPostingUUID)); - assertThat(projection(entry, LedgerProjectionRole.SOURCE_PORTFOLIO).getUUID(), is(targetProjectionUUID)); - assertThat(projection(entry, LedgerProjectionRole.TARGET_PORTFOLIO).getUUID(), is(sourceProjectionUUID)); - assertThat(sourceTransaction.getUUID(), is(targetProjectionUUID)); - assertThat(targetTransaction.getUUID(), is(sourceProjectionUUID)); + assertThat(projection(entry, LedgerProjectionRole.SOURCE_PORTFOLIO).getPortfolio(), is(target)); + assertThat(projection(entry, LedgerProjectionRole.TARGET_PORTFOLIO).getPortfolio(), is(source)); + assertThat(((LedgerBackedTransaction) sourceTransaction).getLedgerProjectionRole(), is(LedgerProjectionRole.SOURCE_PORTFOLIO)); + assertThat(((LedgerBackedTransaction) targetTransaction).getLedgerProjectionRole(), is(LedgerProjectionRole.TARGET_PORTFOLIO)); assertThat(sourceTransaction.getType(), is(PortfolioTransaction.Type.TRANSFER_OUT)); assertThat(targetTransaction.getType(), is(PortfolioTransaction.Type.TRANSFER_IN)); assertPortfolioCrossEntry(sourceTransaction, targetTransaction, target, source); @@ -409,9 +399,9 @@ private PortfolioTransferEntry createPortfolioTransfer(PortfolioFixture fixture) Values.Amount.factorize(100), CurrencyUnit.EUR, "note", "source"); } - private LedgerProjectionRef projection(LedgerEntry entry, LedgerProjectionRole role) + private name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor projection(LedgerEntry entry, LedgerProjectionRole role) { - return entry.getProjectionRefs().stream().filter(projection -> projection.getRole() == role).findFirst() + return name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream().filter(projection -> projection.getRole() == role).findFirst() .orElseThrow(); } @@ -529,9 +519,20 @@ private record EntrySnapshot(String uuid, LedgerEntryType type, List projections; + try + { + projections = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry) + .stream().map(ProjectionSnapshot::capture).toList(); + } + catch (IllegalArgumentException e) + { + projections = List.of(); + } + return new EntrySnapshot(entry.getUUID(), entry.getType(), entry.getPostings().stream().map(PostingSnapshot::capture).toList(), - entry.getProjectionRefs().stream().map(ProjectionSnapshot::capture).toList()); + projections); } } @@ -548,13 +549,14 @@ static PostingSnapshot capture(LedgerPosting posting) } private record ProjectionSnapshot(String uuid, LedgerProjectionRole role, Account account, Portfolio portfolio, - String primaryPostingUUID, String postingGroupUUID) + String primaryPostingId, String groupKey) { - static ProjectionSnapshot capture(LedgerProjectionRef projection) + static ProjectionSnapshot capture(name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor projection) { - return new ProjectionSnapshot(projection.getUUID(), projection.getRole(), projection.getAccount(), - projection.getPortfolio(), projection.getPrimaryPostingUUID(), - projection.getPostingGroupUUID()); + return new ProjectionSnapshot(projection.getRuntimeProjectionId(), projection.getRole(), projection.getAccount(), + projection.getPortfolio(), projection.getPrimaryPosting().getUUID(), + projection.getPrimaryPosting().getGroupKey()); } } } + diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/ledger-v6-spin-off-siemens-energy-example.xml b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/ledger-v6-spin-off-siemens-energy-example.xml index ae36ebfdab..a5be13b876 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/ledger-v6-spin-off-siemens-energy-example.xml +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/ledger-v6-spin-off-siemens-energy-example.xml @@ -578,33 +578,6 @@ - - - 30000000-0000-0000-0000-000000000001 - OLD_SECURITY_LEG - - 20000000-0000-0000-0000-000000000002 - - - 30000000-0000-0000-0000-000000000004 - DELIVERY_INBOUND - - 20000000-0000-0000-0000-000000000007 - - - 30000000-0000-0000-0000-000000000002 - NEW_SECURITY_LEG - - 20000000-0000-0000-0000-000000000003 - - - 30000000-0000-0000-0000-000000000003 - CASH_COMPENSATION - - 20000000-0000-0000-0000-000000000004 - 20000000-0000-0000-0000-000000000004 - - 316212bb-23d1-486f-aaa9-fc2108d44e82 @@ -633,18 +606,6 @@ - - - e34f70f5-810b-4224-868d-7069b89429e0 - ACCOUNT - - - - 24495c80-fea8-491e-9b57-8ecf2991605e - PORTFOLIO - - - 9f3dfe85-9707-40bf-9153-816cf063d290 @@ -663,13 +624,6 @@ - - - 70b3914c-f7a5-4b13-9a4d-4310890a8dae - ACCOUNT - - - diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigratorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigratorTest.java index 9d51cf1022..726fdb56e0 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigratorTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigratorTest.java @@ -10,10 +10,8 @@ import java.lang.reflect.InvocationTargetException; import java.math.BigDecimal; -import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; import java.util.List; -import java.util.UUID; import java.util.function.Consumer; import org.junit.Test; @@ -33,14 +31,14 @@ import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerParameter; import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerPostingDirection; +import name.abuchen.portfolio.model.ledger.LedgerPostingSemanticRole; +import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; -import name.abuchen.portfolio.model.ledger.ProjectionMembershipRole; 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.model.ledger.projection.LedgerBackedAccountTransaction; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; import name.abuchen.portfolio.money.CurrencyUnit; import name.abuchen.portfolio.money.Money; @@ -90,7 +88,7 @@ public void testDividendMigrationPreservesSecurityExDateUnitsAndForex() assertFalse(result.hasDiagnostics()); assertThat(result.getMigratedTransactionCount(), is(1)); assertThat(entry.getType(), is(LedgerEntryType.DIVIDENDS)); - assertThat(migrated.getUUID(), is(dividend.getUUID())); + assertLedgerBacked(migrated, entry, LedgerProjectionRole.ACCOUNT); assertThat(migrated.getType(), is(AccountTransaction.Type.DIVIDENDS)); assertSame(security, migrated.getSecurity()); assertThat(migrated.getShares(), is(dividend.getShares())); @@ -132,9 +130,6 @@ public void testAccountTransferMigrationPreservesDirectionAndProjectionUUIDs() transfer.setSource("transfer source"); transfer.insert(); - var sourceUUID = transfer.getSourceTransaction().getUUID(); - var targetUUID = transfer.getTargetTransaction().getUUID(); - migrate(client); var entry = onlyLedgerEntry(client); @@ -142,8 +137,8 @@ public void testAccountTransferMigrationPreservesDirectionAndProjectionUUIDs() var targetTransaction = onlyAccountTransaction(target); assertThat(entry.getType(), is(LedgerEntryType.CASH_TRANSFER)); - assertThat(sourceTransaction.getUUID(), is(sourceUUID)); - assertThat(targetTransaction.getUUID(), is(targetUUID)); + assertLedgerBacked(sourceTransaction, entry, LedgerProjectionRole.SOURCE_ACCOUNT); + assertLedgerBacked(targetTransaction, entry, LedgerProjectionRole.TARGET_ACCOUNT); assertThat(sourceTransaction.getType(), is(AccountTransaction.Type.TRANSFER_OUT)); assertThat(targetTransaction.getType(), is(AccountTransaction.Type.TRANSFER_IN)); assertThat(sourceTransaction.getDateTime(), is(transfer.getSourceTransaction().getDateTime())); @@ -177,9 +172,6 @@ public void testPortfolioTransferMigrationPreservesDirectionAndProjectionUUIDs() transfer.setSource("portfolio transfer source"); transfer.insert(); - var sourceUUID = transfer.getSourceTransaction().getUUID(); - var targetUUID = transfer.getTargetTransaction().getUUID(); - migrate(client); var entry = onlyLedgerEntry(client); @@ -187,8 +179,8 @@ public void testPortfolioTransferMigrationPreservesDirectionAndProjectionUUIDs() var targetTransaction = onlyPortfolioTransaction(target); assertThat(entry.getType(), is(LedgerEntryType.SECURITY_TRANSFER)); - assertThat(sourceTransaction.getUUID(), is(sourceUUID)); - assertThat(targetTransaction.getUUID(), is(targetUUID)); + assertLedgerBacked(sourceTransaction, entry, LedgerProjectionRole.SOURCE_PORTFOLIO); + assertLedgerBacked(targetTransaction, entry, LedgerProjectionRole.TARGET_PORTFOLIO); assertThat(sourceTransaction.getType(), is(PortfolioTransaction.Type.TRANSFER_OUT)); assertThat(targetTransaction.getType(), is(PortfolioTransaction.Type.TRANSFER_IN)); assertThat(sourceTransaction.getDateTime(), is(transfer.getSourceTransaction().getDateTime())); @@ -231,7 +223,8 @@ public void testDuplicateMigrationPrevention() assertThat(second.getMigratedTransactionCount(), is(0)); assertThat(client.getLedger().getEntries().size(), is(1)); assertThat(account.getTransactions().size(), is(1)); - assertThat(account.getTransactions().get(0).getUUID(), is(deposit.getUUID())); + assertLedgerBacked(account.getTransactions().get(0), client.getLedger().getEntries().get(0), + LedgerProjectionRole.ACCOUNT); } @Test @@ -250,8 +243,7 @@ public void testMixedClientMigratesSupportedAndLeavesUnsupportedLegacyUntouched( assertThat(result.getMigratedTransactionCount(), is(1)); assertThat(client.getLedger().getEntries().size(), is(1)); assertTrue(account.getTransactions().stream().anyMatch(transaction -> transaction == unsupported)); - assertTrue(account.getTransactions().stream().anyMatch(transaction -> transaction instanceof LedgerBackedTransaction - && transaction.getUUID().equals(supported.getUUID()))); + assertTrue(account.getTransactions().stream().anyMatch(transaction -> transaction instanceof LedgerBackedTransaction)); } @Test @@ -648,67 +640,6 @@ public void testExistingDividendProjectionMustMatchFamilyShape() assertDividendDuplicateConflict(LedgerEntryType.DIVIDENDS, false, true, "DIVIDEND_SECURITY"); } - @Test - public void testExistingBuySellDuplicateMustMatchTypeRolesOwnersAndPostingOwners() - { - assertBuySellDuplicateConflict(existing -> existing.setType(LedgerEntryType.SELL), "ENTRY_TYPE"); - assertBuySellDuplicateConflict(this::swapBuySellProjectionSides, "PROJECTION_ROLE"); - assertBuySellDuplicateConflict((existing, account, portfolio, otherAccount, otherPortfolio) -> { - var accountProjection = existing.getProjectionRefs().get(0); - var cashPosting = postingByUUID(existing, accountProjection.getPrimaryPostingUUID()); - - accountProjection.setAccount(otherAccount); - cashPosting.setAccount(otherAccount); - }, "PROJECTION_OWNER"); - assertBuySellDuplicateConflict((existing, account, portfolio, otherAccount, otherPortfolio) -> { - var portfolioProjection = existing.getProjectionRefs().get(1); - var securityPosting = postingByUUID(existing, portfolioProjection.getPrimaryPostingUUID()); - - portfolioProjection.setPortfolio(otherPortfolio); - securityPosting.setPortfolio(otherPortfolio); - }, "PROJECTION_OWNER"); - assertBuySellDuplicateConflict((existing, account, portfolio, otherAccount, otherPortfolio) -> { - var accountProjection = existing.getProjectionRefs().get(0); - var cashPosting = postingByUUID(existing, accountProjection.getPrimaryPostingUUID()); - - cashPosting.setAccount(otherAccount); - }, "POSTING_OWNER"); - } - - @Test - public void testExistingAccountTransferDuplicateMustPreserveSourceTargetShape() - { - assertAccountTransferDuplicateConflict(existing -> { - existing.getProjectionRefs().get(0).setRole(LedgerProjectionRole.TARGET_ACCOUNT); - existing.getProjectionRefs().get(1).setRole(LedgerProjectionRole.SOURCE_ACCOUNT); - }, "PROJECTION_ROLE"); - assertAccountTransferDuplicateConflict((existing, source, target, other) -> { - var sourceProjection = existing.getProjectionRefs().get(0); - postingByUUID(existing, sourceProjection.getPrimaryPostingUUID()).setAccount(other); - }, "POSTING_OWNER"); - assertAccountTransferDuplicateConflict((existing, source, target, other) -> { - var targetProjection = existing.getProjectionRefs().get(1); - postingByUUID(existing, targetProjection.getPrimaryPostingUUID()).setAccount(other); - }, "POSTING_OWNER"); - } - - @Test - public void testExistingPortfolioTransferDuplicateMustPreserveSourceTargetShape() - { - assertPortfolioTransferDuplicateConflict(existing -> { - existing.getProjectionRefs().get(0).setRole(LedgerProjectionRole.TARGET_PORTFOLIO); - existing.getProjectionRefs().get(1).setRole(LedgerProjectionRole.SOURCE_PORTFOLIO); - }, "PROJECTION_ROLE"); - assertPortfolioTransferDuplicateConflict((existing, source, target, other) -> { - var sourceProjection = existing.getProjectionRefs().get(0); - postingByUUID(existing, sourceProjection.getPrimaryPostingUUID()).setPortfolio(other); - }, "POSTING_OWNER"); - assertPortfolioTransferDuplicateConflict((existing, source, target, other) -> { - var targetProjection = existing.getProjectionRefs().get(1); - postingByUUID(existing, targetProjection.getPrimaryPostingUUID()).setPortfolio(other); - }, "POSTING_OWNER"); - } - @Test public void testExistingDeliveryDuplicateMustMatchDirectionAndOwner() { @@ -846,26 +777,6 @@ public void testPortfolioTransferUnitsAreRejectedRatherThanDropped() assertSame(transfer.getTargetTransaction(), target.getTransactions().get(0)); } - @Test - public void testUnsupportedUnitPostingMembershipHasImportCode() throws ReflectiveOperationException - { - var projection = new LedgerProjectionRef(); - var posting = new LedgerPosting(); - var builder = Class.forName( - "name.abuchen.portfolio.model.ledger.legacy.LegacyTransactionToLedgerMigrator$MigrationGraphBuilder"); - var method = builder.getDeclaredMethod("addUnitMemberships", LedgerProjectionRef.class, List.class); - - posting.setType(LedgerPostingType.CASH); - method.setAccessible(true); - - var exception = assertThrows(InvocationTargetException.class, - () -> method.invoke(null, projection, List.of(posting))); - - assertThat(exception.getCause(), instanceOf(IllegalArgumentException.class)); - assertTrue(exception.getCause().getMessage().contains(LedgerDiagnosticCode.LEDGER_IMPORT_021.prefix())); - assertTrue(exception.getCause().getMessage().contains("Unsupported unit posting type: CASH")); - } - @Test public void testClientAllTransactionsUsesDeduplicatedMigratedShape() { @@ -880,8 +791,8 @@ public void testClientAllTransactionsUsesDeduplicatedMigratedShape() assertThat(client.getAllTransactions().size(), is(1)); assertSame(portfolio, client.getAllTransactions().get(0).getOwner()); - assertThat(client.getAllTransactions().get(0).getTransaction().getUUID(), - is(entry.getPortfolioTransaction().getUUID())); + assertLedgerBacked(client.getAllTransactions().get(0).getTransaction(), onlyLedgerEntry(client), + LedgerProjectionRole.PORTFOLIO); } @Test @@ -898,9 +809,6 @@ public void testInvestmentPlanLegacyTransactionRefsBecomeStableLedgerRefs() plan.getTransactions().add(entry.getPortfolioTransaction()); client.addPlan(plan); - var accountUUID = entry.getAccountTransaction().getUUID(); - var portfolioUUID = entry.getPortfolioTransaction().getUUID(); - migrate(client); var ledgerEntry = onlyLedgerEntry(client); @@ -910,18 +818,17 @@ public void testInvestmentPlanLegacyTransactionRefsBecomeStableLedgerRefs() .filter(posting -> posting.getType() == LedgerPostingType.SECURITY).findFirst().orElseThrow(); var feePosting = ledgerEntry.getPostings().stream() .filter(posting -> posting.getType() == LedgerPostingType.FEE).findFirst().orElseThrow(); - var ref = plan.getLedgerExecutionRefs().get(0); assertTrue(plan.getTransactions().isEmpty()); - assertThat(plan.getLedgerExecutionRefs().size(), is(1)); - assertThat(ref.getLedgerEntryUUID(), is(migratedEntryUUID(LedgerEntryType.BUY, portfolioUUID))); - assertThat(ref.getProjectionUUID(), is(portfolioUUID)); - assertThat(ref.getProjectionRole(), is(LedgerProjectionRole.PORTFOLIO)); - assertThat(ledgerEntry.getUUID(), is(ref.getLedgerEntryUUID())); - assertThat(cashPosting.getUUID(), is(migratedPostingUUID(accountUUID, LedgerPostingType.CASH, "primary"))); - assertThat(securityPosting.getUUID(), - is(migratedPostingUUID(portfolioUUID, LedgerPostingType.SECURITY, "primary"))); - assertThat(feePosting.getUUID(), is(migratedPostingUUID(portfolioUUID, LedgerPostingType.FEE, "unit-0"))); + assertTrue(plan.getLedgerExecutionRefs().isEmpty()); + assertThat(ledgerEntry.getGeneratedByPlanKey(), is(plan.getPlanKey())); + assertThat(ledgerEntry.getPlanExecutionDate(), is(DATE_TIME.toLocalDate())); + assertThat(ledgerEntry.getPreferredViewKind(), is(InvestmentPlan.LedgerExecutionViewKind.PORTFOLIO.name())); + assertThat(cashPosting.getSemanticRole(), is(LedgerPostingSemanticRole.CASH)); + assertThat(securityPosting.getSemanticRole(), is(LedgerPostingSemanticRole.SECURITY)); + assertThat(feePosting.getUnitRole(), is(LedgerPostingUnitRole.FEE)); + assertThat(plan.getTransactions(client).size(), is(1)); + assertLedgerBacked(plan.getTransactions(client).get(0).getTransaction(), ledgerEntry, LedgerProjectionRole.PORTFOLIO); assertValid(client); } @@ -941,18 +848,18 @@ private void assertAccountOnlyMigration(AccountTransaction.Type type, LedgerEntr var result = migrate(client); var migrated = onlyAccountTransaction(account); var entry = onlyLedgerEntry(client); - var projection = entry.getProjectionRefs().get(0); + var projection = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0); assertFalse(result.hasDiagnostics()); assertThat(result.getMigratedTransactionCount(), is(1)); assertThat(client.getLedger().getEntries().size(), is(1)); assertThat(entry.getType(), is(entryType)); - assertThat(projection.getPrimaryMembership().orElseThrow().getPostingUUID(), - is(projection.getPrimaryPostingUUID())); - assertThat(projection.getMembershipsByRole(ProjectionMembershipRole.FEE_UNIT).size(), is(1)); - assertThat(projection.getMembershipsByRole(ProjectionMembershipRole.TAX_UNIT).size(), is(1)); - assertThat(migrated, instanceOf(LedgerBackedAccountTransaction.class)); - assertThat(migrated.getUUID(), is(transaction.getUUID())); + assertThat(projection.getPrimaryPosting().getUnitRole(), is(LedgerPostingUnitRole.PRIMARY)); + assertThat((int) projection.getUnitPostings().stream() + .filter(posting -> posting.getUnitRole() == LedgerPostingUnitRole.FEE).count(), is(1)); + assertThat((int) projection.getUnitPostings().stream() + .filter(posting -> posting.getUnitRole() == LedgerPostingUnitRole.TAX).count(), is(1)); + assertLedgerBacked(migrated, entry, LedgerProjectionRole.ACCOUNT); assertThat(migrated.getType(), is(type)); assertThat(migrated.getDateTime(), is(transaction.getDateTime())); assertThat(migrated.getAmount(), is(transaction.getAmount())); @@ -978,32 +885,27 @@ private void assertBuySellMigration(PortfolioTransaction.Type type, LedgerEntryT Money.of(CurrencyUnit.USD, Values.Amount.factorize(300)), BigDecimal.valueOf(0.5))); entry.insert(); - var accountUUID = entry.getAccountTransaction().getUUID(); - var portfolioUUID = entry.getPortfolioTransaction().getUUID(); - var result = migrate(client); var ledgerEntry = onlyLedgerEntry(client); var accountTransaction = onlyAccountTransaction(account); var portfolioTransaction = onlyPortfolioTransaction(portfolio); - var accountProjection = ledgerEntry.getProjectionRefs().get(0); - var portfolioProjection = ledgerEntry.getProjectionRefs().get(1); + var accountProjection = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(ledgerEntry).get(0); + var portfolioProjection = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(ledgerEntry).get(1); assertFalse(result.hasDiagnostics()); assertThat(client.getLedger().getEntries().size(), is(1)); assertThat(ledgerEntry.getType(), is(entryType)); - assertThat(ledgerEntry.getProjectionRefs().size(), is(2)); - assertThat(accountTransaction.getUUID(), is(accountUUID)); - assertThat(portfolioTransaction.getUUID(), is(portfolioUUID)); - assertThat(accountProjection.getPrimaryMembership().orElseThrow().getPostingUUID(), - is(accountProjection.getPrimaryPostingUUID())); - assertThat(portfolioProjection.getPrimaryMembership().orElseThrow().getPostingUUID(), - is(portfolioProjection.getPrimaryPostingUUID())); - assertThat(accountProjection.getMembershipsByRole(ProjectionMembershipRole.FEE_UNIT).size(), is(1)); - assertThat(accountProjection.getMembershipsByRole(ProjectionMembershipRole.TAX_UNIT).size(), is(1)); - assertThat(accountProjection.getMembershipsByRole(ProjectionMembershipRole.GROSS_VALUE_UNIT).size(), is(1)); - assertThat(portfolioProjection.getMembershipsByRole(ProjectionMembershipRole.FEE_UNIT).size(), is(1)); - assertThat(portfolioProjection.getMembershipsByRole(ProjectionMembershipRole.TAX_UNIT).size(), is(1)); - assertThat(portfolioProjection.getMembershipsByRole(ProjectionMembershipRole.GROSS_VALUE_UNIT).size(), is(1)); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(ledgerEntry).size(), is(2)); + assertLedgerBacked(accountTransaction, ledgerEntry, LedgerProjectionRole.ACCOUNT); + assertLedgerBacked(portfolioTransaction, ledgerEntry, LedgerProjectionRole.PORTFOLIO); + assertThat(accountProjection.getPrimaryPosting().getUnitRole(), is(LedgerPostingUnitRole.PRIMARY)); + assertThat(portfolioProjection.getPrimaryPosting().getUnitRole(), is(LedgerPostingUnitRole.PRIMARY)); + assertUnitRoleCount(accountProjection, LedgerPostingUnitRole.FEE, 1); + assertUnitRoleCount(accountProjection, LedgerPostingUnitRole.TAX, 1); + assertUnitRoleCount(accountProjection, LedgerPostingUnitRole.GROSS_VALUE, 1); + assertUnitRoleCount(portfolioProjection, LedgerPostingUnitRole.FEE, 1); + assertUnitRoleCount(portfolioProjection, LedgerPostingUnitRole.TAX, 1); + assertUnitRoleCount(portfolioProjection, LedgerPostingUnitRole.GROSS_VALUE, 1); assertThat(accountTransaction.getType().name(), is(type.name())); assertThat(portfolioTransaction.getType(), is(type)); assertThat(accountTransaction.getDateTime(), is(entry.getAccountTransaction().getDateTime())); @@ -1028,6 +930,13 @@ private void assertBuySellMigration(PortfolioTransaction.Type type, LedgerEntryT assertValid(client); } + private void assertUnitRoleCount(name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor projection, + LedgerPostingUnitRole role, int count) + { + assertThat((int) projection.getUnitPostings().stream().filter(posting -> posting.getUnitRole() == role).count(), + is(count)); + } + private void assertDeliveryMigration(PortfolioTransaction.Type type, LedgerEntryType entryType) { var client = new Client(); @@ -1040,10 +949,13 @@ private void assertDeliveryMigration(PortfolioTransaction.Type type, LedgerEntry var result = migrate(client); var migrated = onlyPortfolioTransaction(portfolio); + var entry = onlyLedgerEntry(client); assertFalse(result.hasDiagnostics()); - assertThat(onlyLedgerEntry(client).getType(), is(entryType)); - assertThat(migrated.getUUID(), is(transaction.getUUID())); + assertThat(entry.getType(), is(entryType)); + assertLedgerBacked(migrated, entry, type == PortfolioTransaction.Type.DELIVERY_OUTBOUND + ? LedgerProjectionRole.DELIVERY_OUTBOUND + : LedgerProjectionRole.DELIVERY_INBOUND); assertThat(migrated.getType(), is(type)); assertSame(security, migrated.getSecurity()); assertThat(migrated.getShares(), is(transaction.getShares())); @@ -1324,7 +1236,6 @@ private LedgerEntry existingAccountProjectionEntry(LedgerEntryType entryType, Ac { var entry = new LedgerEntry(); var posting = new LedgerPosting(); - var projection = new LedgerProjectionRef(projectionUUID); entry.setType(entryType); entry.setDateTime(DATE_TIME); @@ -1332,11 +1243,10 @@ private LedgerEntry existingAccountProjectionEntry(LedgerEntryType entryType, Ac posting.setAccount(account); posting.setAmount(Values.Amount.factorize(1)); posting.setCurrency(CurrencyUnit.EUR); - projection.setRole(LedgerProjectionRole.ACCOUNT); - projection.setAccount(account); - projection.setPrimaryPostingUUID(posting.getUUID()); + posting.setSemanticRole(LedgerPostingSemanticRole.CASH); + posting.setDirection(LedgerPostingDirection.NEUTRAL); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); entry.addPosting(posting); - entry.addProjectionRef(projection); return entry; } @@ -1346,7 +1256,6 @@ private LedgerEntry existingPortfolioProjectionEntry(LedgerEntryType entryType, { var entry = new LedgerEntry(); var posting = new LedgerPosting(); - var projection = new LedgerProjectionRef(projectionUUID); entry.setType(entryType); entry.setDateTime(DATE_TIME); @@ -1356,11 +1265,11 @@ private LedgerEntry existingPortfolioProjectionEntry(LedgerEntryType entryType, posting.setCurrency(CurrencyUnit.EUR); posting.setSecurity(security()); posting.setShares(Values.Share.factorize(1)); - projection.setRole(role); - projection.setPortfolio(portfolio); - projection.setPrimaryPostingUUID(posting.getUUID()); + posting.setSemanticRole(LedgerPostingSemanticRole.SECURITY); + posting.setDirection(role == LedgerProjectionRole.DELIVERY_OUTBOUND ? LedgerPostingDirection.OUTBOUND + : LedgerPostingDirection.INBOUND); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); entry.addPosting(posting); - entry.addProjectionRef(projection); return entry; } @@ -1371,8 +1280,6 @@ private LedgerEntry existingBuySellEntry(Account account, Portfolio portfolio, S var entry = new LedgerEntry(); var cashPosting = new LedgerPosting(); var securityPosting = new LedgerPosting(); - var accountProjection = new LedgerProjectionRef(accountProjectionUUID); - var portfolioProjection = new LedgerProjectionRef(portfolioProjectionUUID); entry.setType(LedgerEntryType.BUY); entry.setDateTime(DATE_TIME); @@ -1380,22 +1287,20 @@ private LedgerEntry existingBuySellEntry(Account account, Portfolio portfolio, S cashPosting.setAccount(account); cashPosting.setAmount(Values.Amount.factorize(100)); cashPosting.setCurrency(CurrencyUnit.EUR); + cashPosting.setSemanticRole(LedgerPostingSemanticRole.CASH); + cashPosting.setDirection(LedgerPostingDirection.NEUTRAL); + cashPosting.setUnitRole(LedgerPostingUnitRole.PRIMARY); securityPosting.setType(LedgerPostingType.SECURITY); securityPosting.setPortfolio(portfolio); securityPosting.setAmount(Values.Amount.factorize(100)); securityPosting.setCurrency(CurrencyUnit.EUR); securityPosting.setSecurity(security()); securityPosting.setShares(Values.Share.factorize(5)); - accountProjection.setRole(LedgerProjectionRole.ACCOUNT); - accountProjection.setAccount(account); - accountProjection.setPrimaryPostingUUID(cashPosting.getUUID()); - portfolioProjection.setRole(LedgerProjectionRole.PORTFOLIO); - portfolioProjection.setPortfolio(portfolio); - portfolioProjection.setPrimaryPostingUUID(securityPosting.getUUID()); + securityPosting.setSemanticRole(LedgerPostingSemanticRole.SECURITY); + securityPosting.setDirection(LedgerPostingDirection.NEUTRAL); + securityPosting.setUnitRole(LedgerPostingUnitRole.PRIMARY); entry.addPosting(cashPosting); entry.addPosting(securityPosting); - entry.addProjectionRef(accountProjection); - entry.addProjectionRef(portfolioProjection); return entry; } @@ -1406,8 +1311,6 @@ private LedgerEntry existingAccountTransferEntry(Account source, Account target, var entry = new LedgerEntry(); var sourcePosting = new LedgerPosting(); var targetPosting = new LedgerPosting(); - var sourceProjection = new LedgerProjectionRef(sourceProjectionUUID); - var targetProjection = new LedgerProjectionRef(targetProjectionUUID); entry.setType(LedgerEntryType.CASH_TRANSFER); entry.setDateTime(DATE_TIME); @@ -1415,20 +1318,18 @@ private LedgerEntry existingAccountTransferEntry(Account source, Account target, sourcePosting.setAccount(source); sourcePosting.setAmount(Values.Amount.factorize(55)); sourcePosting.setCurrency(CurrencyUnit.EUR); + sourcePosting.setSemanticRole(LedgerPostingSemanticRole.CASH); + sourcePosting.setDirection(LedgerPostingDirection.OUTBOUND); + sourcePosting.setUnitRole(LedgerPostingUnitRole.PRIMARY); targetPosting.setType(LedgerPostingType.CASH); targetPosting.setAccount(target); targetPosting.setAmount(Values.Amount.factorize(55)); targetPosting.setCurrency(CurrencyUnit.EUR); - sourceProjection.setRole(LedgerProjectionRole.SOURCE_ACCOUNT); - sourceProjection.setAccount(source); - sourceProjection.setPrimaryPostingUUID(sourcePosting.getUUID()); - targetProjection.setRole(LedgerProjectionRole.TARGET_ACCOUNT); - targetProjection.setAccount(target); - targetProjection.setPrimaryPostingUUID(targetPosting.getUUID()); + targetPosting.setSemanticRole(LedgerPostingSemanticRole.CASH); + targetPosting.setDirection(LedgerPostingDirection.INBOUND); + targetPosting.setUnitRole(LedgerPostingUnitRole.PRIMARY); entry.addPosting(sourcePosting); entry.addPosting(targetPosting); - entry.addProjectionRef(sourceProjection); - entry.addProjectionRef(targetProjection); return entry; } @@ -1439,8 +1340,6 @@ private LedgerEntry existingPortfolioTransferEntry(Portfolio source, Portfolio t var entry = new LedgerEntry(); var sourcePosting = new LedgerPosting(); var targetPosting = new LedgerPosting(); - var sourceProjection = new LedgerProjectionRef(sourceProjectionUUID); - var targetProjection = new LedgerProjectionRef(targetProjectionUUID); var security = security(); entry.setType(LedgerEntryType.SECURITY_TRANSFER); @@ -1451,22 +1350,20 @@ private LedgerEntry existingPortfolioTransferEntry(Portfolio source, Portfolio t sourcePosting.setCurrency(CurrencyUnit.EUR); sourcePosting.setSecurity(security); sourcePosting.setShares(Values.Share.factorize(7)); + sourcePosting.setSemanticRole(LedgerPostingSemanticRole.SECURITY); + sourcePosting.setDirection(LedgerPostingDirection.OUTBOUND); + sourcePosting.setUnitRole(LedgerPostingUnitRole.PRIMARY); targetPosting.setType(LedgerPostingType.SECURITY); targetPosting.setPortfolio(target); targetPosting.setAmount(Values.Amount.factorize(400)); targetPosting.setCurrency(CurrencyUnit.EUR); targetPosting.setSecurity(security); targetPosting.setShares(Values.Share.factorize(7)); - sourceProjection.setRole(LedgerProjectionRole.SOURCE_PORTFOLIO); - sourceProjection.setPortfolio(source); - sourceProjection.setPrimaryPostingUUID(sourcePosting.getUUID()); - targetProjection.setRole(LedgerProjectionRole.TARGET_PORTFOLIO); - targetProjection.setPortfolio(target); - targetProjection.setPrimaryPostingUUID(targetPosting.getUUID()); + targetPosting.setSemanticRole(LedgerPostingSemanticRole.SECURITY); + targetPosting.setDirection(LedgerPostingDirection.INBOUND); + targetPosting.setUnitRole(LedgerPostingUnitRole.PRIMARY); entry.addPosting(sourcePosting); entry.addPosting(targetPosting); - entry.addProjectionRef(sourceProjection); - entry.addProjectionRef(targetProjection); return entry; } @@ -1476,7 +1373,6 @@ private LedgerEntry existingDeliveryEntry(LedgerEntryType entryType, Portfolio p { var entry = new LedgerEntry(); var posting = new LedgerPosting(); - var projection = new LedgerProjectionRef(projectionUUID); entry.setType(entryType); entry.setDateTime(DATE_TIME); @@ -1486,40 +1382,15 @@ private LedgerEntry existingDeliveryEntry(LedgerEntryType entryType, Portfolio p posting.setCurrency(CurrencyUnit.EUR); posting.setSecurity(security()); posting.setShares(Values.Share.factorize(5)); - projection.setRole(role); - projection.setPortfolio(portfolio); - projection.setPrimaryPostingUUID(posting.getUUID()); + posting.setSemanticRole(LedgerPostingSemanticRole.SECURITY); + posting.setDirection(role == LedgerProjectionRole.DELIVERY_OUTBOUND ? LedgerPostingDirection.OUTBOUND + : LedgerPostingDirection.INBOUND); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); entry.addPosting(posting); - entry.addProjectionRef(projection); return entry; } - private void swapBuySellProjectionSides(LedgerEntry entry) - { - var accountProjection = entry.getProjectionRefs().get(0); - var portfolioProjection = entry.getProjectionRefs().get(1); - var account = accountProjection.getAccount(); - var portfolio = portfolioProjection.getPortfolio(); - var cashPostingUUID = accountProjection.getPrimaryPostingUUID(); - var securityPostingUUID = portfolioProjection.getPrimaryPostingUUID(); - - accountProjection.setRole(LedgerProjectionRole.PORTFOLIO); - accountProjection.setAccount(null); - accountProjection.setPortfolio(portfolio); - accountProjection.setPrimaryPostingUUID(securityPostingUUID); - portfolioProjection.setRole(LedgerProjectionRole.ACCOUNT); - portfolioProjection.setPortfolio(null); - portfolioProjection.setAccount(account); - portfolioProjection.setPrimaryPostingUUID(cashPostingUUID); - } - - private LedgerPosting postingByUUID(LedgerEntry entry, String uuid) - { - return entry.getPostings().stream().filter(posting -> posting.getUUID().equals(uuid)).findFirst() - .orElseThrow(); - } - private LedgerPosting unitPosting(LedgerEntry entry, LedgerPostingType type) { return entry.getPostings().stream().filter(posting -> posting.getType() == type).findFirst().orElseThrow(); @@ -1664,6 +1535,17 @@ private PortfolioTransaction onlyPortfolioTransaction(Portfolio portfolio) return portfolio.getTransactions().get(0); } + private LedgerBackedTransaction assertLedgerBacked(Object transaction, LedgerEntry entry, LedgerProjectionRole role) + { + assertThat(transaction, instanceOf(LedgerBackedTransaction.class)); + var ledgerBacked = (LedgerBackedTransaction) transaction; + + assertSame(entry, ledgerBacked.getLedgerEntry()); + assertThat(ledgerBacked.getLedgerProjectionRole(), is(role)); + + return ledgerBacked; + } + private LedgerEntry onlyLedgerEntry(Client client) { assertThat(client.getLedger().getEntries().size(), is(1)); @@ -1675,15 +1557,5 @@ private Money money(int amount) return Money.of(CurrencyUnit.EUR, Values.Amount.factorize(amount)); } - private String migratedEntryUUID(LedgerEntryType type, String primaryProjectionUUID) - { - var key = "ledger-v6:migrated-entry:" + type + ":" + primaryProjectionUUID; - return UUID.nameUUIDFromBytes(key.getBytes(StandardCharsets.UTF_8)).toString(); - } - - private String migratedPostingUUID(String projectionUUID, LedgerPostingType type, String discriminator) - { - var key = "ledger-v6:migrated-posting:" + projectionUUID + ":" + type + ":" + discriminator; - return UUID.nameUUIDFromBytes(key.getBytes(StandardCharsets.UTF_8)).toString(); - } } + diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblerTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblerTest.java index 452f8e911c..b3146d071b 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblerTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblerTest.java @@ -30,7 +30,6 @@ import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; -import name.abuchen.portfolio.model.ledger.ProjectionMembershipRole; import name.abuchen.portfolio.model.ledger.configuration.CashCompensationKind; import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; import name.abuchen.portfolio.model.ledger.configuration.CorporateActionKind; @@ -341,17 +340,17 @@ public void testBuildsDetachedMinimalSpinOff() assertThat(entry.getType(), is(LedgerEntryType.SPIN_OFF)); assertThat(entry.getPostings().size(), is(5)); - assertThat(entry.getProjectionRefs().size(), is(3)); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).size(), is(3)); assertThat(fixture.client.getLedger().getEntries().size(), is(0)); assertTrue(result.getValidationResult().isOK()); assertThat(parameter(entry.getParameters(), LedgerParameterType.CORPORATE_ACTION_KIND).getValue(), is(CorporateActionKind.SPIN_OFF.getCode())); - assertThat(entry.getProjectionRefs().stream().filter(ref -> ref.getRole() == LedgerProjectionRole.OLD_SECURITY_LEG) + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream().filter(ref -> ref.getRole() == LedgerProjectionRole.OLD_SECURITY_LEG) .count(), is(1L)); - assertThat(entry.getProjectionRefs().stream().filter(ref -> ref.getRole() == LedgerProjectionRole.NEW_SECURITY_LEG) + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream().filter(ref -> ref.getRole() == LedgerProjectionRole.NEW_SECURITY_LEG) .count(), is(1L)); - assertThat(entry.getProjectionRefs().stream() + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream() .filter(ref -> ref.getRole() == LedgerProjectionRole.CASH_COMPENSATION).count(), is(1L)); } @@ -399,7 +398,8 @@ public void testBuildsDetachedMinimalStockDividendThroughGenericForType() .buildDetached(); assertThat(result.getEntry().getType(), is(LedgerEntryType.STOCK_DIVIDEND)); - assertThat(result.getEntry().getProjectionRefs().get(0).getRole(), is(LedgerProjectionRole.DELIVERY_INBOUND)); + assertThat(descriptor(result.getEntry(), LedgerProjectionRole.DELIVERY_INBOUND).getRole(), + is(LedgerProjectionRole.DELIVERY_INBOUND)); assertTrue(result.getValidationResult().isOK()); assertThat(fixture.client.getLedger().getEntries().size(), is(0)); } @@ -480,7 +480,10 @@ public void testBuildAndAddProjectionRefUUIDsAreRuntimeProjectionUUIDs() { var fixture = fixture(); var entry = validSpinOff(fixture).buildAndAdd().getEntry(); - var projectionUUIDs = entry.getProjectionRefs().stream().map(ref -> ref.getUUID()).collect(Collectors.toSet()); + var projectionUUIDs = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream() + .map(descriptor -> name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport + .runtimeProjectionId(entry, descriptor.getRole())) + .collect(Collectors.toSet()); var runtimeUUIDs = java.util.stream.Stream.concat( fixture.portfolio.getTransactions().stream() .filter(LedgerBackedTransaction.class::isInstance) @@ -568,7 +571,7 @@ public void testBuildAndAddStructuralValidationFailureLeavesClientUnchanged() var exception = assertThrows(LedgerNativeEntryAssemblyException.class, () -> baseSpinOff(fixture).securityLeg(invalidSourceLeg).buildAndAdd()); - assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.STRUCTURAL_VALIDATION_FAILED)); + assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.NATIVE_DEFINITION_VALIDATION_FAILED)); assertClientUnchanged(fixture); } @@ -601,17 +604,14 @@ public void testGeneratedProjectionRefsTargetAssemblerOwnedPostings() var entry = validSpinOff(fixture).buildDetached().getEntry(); var postingUUIDs = entry.getPostings().stream().map(LedgerPosting::getUUID).collect(Collectors.toSet()); - for (var ref : entry.getProjectionRefs()) + for (var ref : name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry)) { - assertTrue(postingUUIDs.contains(ref.getPrimaryPostingUUID())); - assertThat(ref.getPrimaryMembership().orElseThrow().getPostingUUID(), is(ref.getPrimaryPostingUUID())); + assertTrue(postingUUIDs.contains(ref.getPrimaryPosting().getUUID())); + assertThat(ref.getPrimaryPosting().getUnitRole(), is(LedgerPostingUnitRole.PRIMARY)); - if (ref.getPostingGroupUUID() != null) - { - assertTrue(postingUUIDs.contains(ref.getPostingGroupUUID())); - assertThat(ref.getMembershipsByRole(ProjectionMembershipRole.GROUP_ANCHOR).get(0).getPostingUUID(), - is(ref.getPostingGroupUUID())); - } + if (ref.getPrimaryPosting().getGroupKey() != null) + assertTrue(ref.getUnitPostings().stream() + .allMatch(posting -> ref.getPrimaryPosting().getGroupKey().equals(posting.getGroupKey()))); } } @@ -831,7 +831,7 @@ private static LedgerBackedTransaction portfolioProjection(Portfolio portfolio, return portfolio.getTransactions().stream() // .filter(LedgerBackedTransaction.class::isInstance) // .map(LedgerBackedTransaction.class::cast) // - .filter(transaction -> transaction.getLedgerProjectionRef().getRole() == role) // + .filter(transaction -> transaction.getLedgerProjectionDescriptor().getRole() == role) // .findFirst().orElseThrow(); } @@ -840,7 +840,7 @@ private static LedgerBackedTransaction accountProjection(Account account, Ledger return account.getTransactions().stream() // .filter(LedgerBackedTransaction.class::isInstance) // .map(LedgerBackedTransaction.class::cast) // - .filter(transaction -> transaction.getLedgerProjectionRef().getRole() == role) // + .filter(transaction -> transaction.getLedgerProjectionDescriptor().getRole() == role) // .findFirst().orElseThrow(); } diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorServiceTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorServiceTest.java index 9a030e7b1f..00ad9ca053 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorServiceTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorServiceTest.java @@ -7,7 +7,6 @@ import static org.junit.Assert.assertTrue; import java.time.LocalDateTime; -import java.util.EnumSet; import java.util.List; import java.util.Set; import java.util.stream.Collectors; @@ -24,10 +23,8 @@ import name.abuchen.portfolio.model.ledger.LedgerPostingDirection; import name.abuchen.portfolio.model.ledger.LedgerPostingSemanticRole; import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.LedgerTransactionMetadata; -import name.abuchen.portfolio.model.ledger.ProjectionMembershipRole; import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountCashLeg; import name.abuchen.portfolio.model.ledger.compatibility.LedgerCashTransferLeg; import name.abuchen.portfolio.model.ledger.compatibility.LedgerCreationUnits; @@ -64,7 +61,7 @@ public void testAccountOnlyDescriptorMatchesProjectionRef() var descriptors = descriptors(entry); assertThat(descriptors.size(), is(1)); - assertMatchesProjectionRef(entry, descriptors.get(0), entry.getProjectionRefs().get(0)); + assertDescriptor(entry, descriptors.get(0), LedgerProjectionRole.ACCOUNT); } @Test @@ -82,10 +79,9 @@ public void testBuySellDescriptorsMatchAccountAndPortfolioProjectionRefs() var descriptors = descriptors(entry); assertThat(roles(descriptors), is(Set.of(LedgerProjectionRole.ACCOUNT, LedgerProjectionRole.PORTFOLIO))); - assertMatchesProjectionRef(entry, descriptor(descriptors, LedgerProjectionRole.ACCOUNT), - projection(entry, LedgerProjectionRole.ACCOUNT)); - assertMatchesProjectionRef(entry, descriptor(descriptors, LedgerProjectionRole.PORTFOLIO), - projection(entry, LedgerProjectionRole.PORTFOLIO)); + assertDescriptor(entry, descriptor(descriptors, LedgerProjectionRole.ACCOUNT), LedgerProjectionRole.ACCOUNT); + assertDescriptor(entry, descriptor(descriptors, LedgerProjectionRole.PORTFOLIO), + LedgerProjectionRole.PORTFOLIO); entry = creator(client).createSell(metadata(), LedgerAccountCashLeg.of(account, money(100)), LedgerPortfolioSecurityLeg.of(portfolio, @@ -96,10 +92,9 @@ public void testBuySellDescriptorsMatchAccountAndPortfolioProjectionRefs() descriptors = descriptors(entry); assertThat(roles(descriptors), is(Set.of(LedgerProjectionRole.ACCOUNT, LedgerProjectionRole.PORTFOLIO))); - assertMatchesProjectionRef(entry, descriptor(descriptors, LedgerProjectionRole.ACCOUNT), - projection(entry, LedgerProjectionRole.ACCOUNT)); - assertMatchesProjectionRef(entry, descriptor(descriptors, LedgerProjectionRole.PORTFOLIO), - projection(entry, LedgerProjectionRole.PORTFOLIO)); + assertDescriptor(entry, descriptor(descriptors, LedgerProjectionRole.ACCOUNT), LedgerProjectionRole.ACCOUNT); + assertDescriptor(entry, descriptor(descriptors, LedgerProjectionRole.PORTFOLIO), + LedgerProjectionRole.PORTFOLIO); } @Test @@ -116,7 +111,7 @@ public void testDeliveryDescriptorMatchesProjectionRef() var descriptors = descriptors(entry); assertThat(roles(descriptors), is(Set.of(LedgerProjectionRole.DELIVERY_INBOUND))); - assertMatchesProjectionRef(entry, descriptors.get(0), projection(entry, LedgerProjectionRole.DELIVERY_INBOUND)); + assertDescriptor(entry, descriptors.get(0), LedgerProjectionRole.DELIVERY_INBOUND); entry = creator(client).createOutboundDelivery(metadata(), LedgerDeliveryLeg.of(portfolio, @@ -127,8 +122,7 @@ public void testDeliveryDescriptorMatchesProjectionRef() descriptors = descriptors(entry); assertThat(roles(descriptors), is(Set.of(LedgerProjectionRole.DELIVERY_OUTBOUND))); - assertMatchesProjectionRef(entry, descriptors.get(0), - projection(entry, LedgerProjectionRole.DELIVERY_OUTBOUND)); + assertDescriptor(entry, descriptors.get(0), LedgerProjectionRole.DELIVERY_OUTBOUND); } @Test @@ -146,10 +140,10 @@ public void testCashTransferDerivesSourceAndTargetWithoutPostingOrder() assertSame(source, descriptor(descriptors, LedgerProjectionRole.SOURCE_ACCOUNT).getAccount()); assertSame(target, descriptor(descriptors, LedgerProjectionRole.TARGET_ACCOUNT).getAccount()); - assertMatchesProjectionRef(entry, descriptor(descriptors, LedgerProjectionRole.SOURCE_ACCOUNT), - projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT)); - assertMatchesProjectionRef(entry, descriptor(descriptors, LedgerProjectionRole.TARGET_ACCOUNT), - projection(entry, LedgerProjectionRole.TARGET_ACCOUNT)); + assertDescriptor(entry, descriptor(descriptors, LedgerProjectionRole.SOURCE_ACCOUNT), + LedgerProjectionRole.SOURCE_ACCOUNT); + assertDescriptor(entry, descriptor(descriptors, LedgerProjectionRole.TARGET_ACCOUNT), + LedgerProjectionRole.TARGET_ACCOUNT); } @Test @@ -170,10 +164,10 @@ public void testSecurityTransferDerivesSourceAndTargetWithoutPostingOrder() assertSame(source, descriptor(descriptors, LedgerProjectionRole.SOURCE_PORTFOLIO).getPortfolio()); assertSame(target, descriptor(descriptors, LedgerProjectionRole.TARGET_PORTFOLIO).getPortfolio()); - assertMatchesProjectionRef(entry, descriptor(descriptors, LedgerProjectionRole.SOURCE_PORTFOLIO), - projection(entry, LedgerProjectionRole.SOURCE_PORTFOLIO)); - assertMatchesProjectionRef(entry, descriptor(descriptors, LedgerProjectionRole.TARGET_PORTFOLIO), - projection(entry, LedgerProjectionRole.TARGET_PORTFOLIO)); + assertDescriptor(entry, descriptor(descriptors, LedgerProjectionRole.SOURCE_PORTFOLIO), + LedgerProjectionRole.SOURCE_PORTFOLIO); + assertDescriptor(entry, descriptor(descriptors, LedgerProjectionRole.TARGET_PORTFOLIO), + LedgerProjectionRole.TARGET_PORTFOLIO); } @Test @@ -187,14 +181,14 @@ public void testSiemensSpinOffDescriptorsMatchTargetedProjectionRefs() assertThat(roles(descriptors), is(Set.of(LedgerProjectionRole.OLD_SECURITY_LEG, LedgerProjectionRole.DELIVERY_INBOUND, LedgerProjectionRole.NEW_SECURITY_LEG, LedgerProjectionRole.CASH_COMPENSATION))); - assertMatchesProjectionRef(entry, descriptor(descriptors, LedgerProjectionRole.OLD_SECURITY_LEG), - projection(entry, LedgerProjectionRole.OLD_SECURITY_LEG)); - assertMatchesProjectionRef(entry, descriptor(descriptors, LedgerProjectionRole.DELIVERY_INBOUND), - projection(entry, LedgerProjectionRole.DELIVERY_INBOUND)); - assertMatchesProjectionRef(entry, descriptor(descriptors, LedgerProjectionRole.NEW_SECURITY_LEG), - projection(entry, LedgerProjectionRole.NEW_SECURITY_LEG)); - assertMatchesProjectionRef(entry, descriptor(descriptors, LedgerProjectionRole.CASH_COMPENSATION), - projection(entry, LedgerProjectionRole.CASH_COMPENSATION)); + assertDescriptor(entry, descriptor(descriptors, LedgerProjectionRole.OLD_SECURITY_LEG), + LedgerProjectionRole.OLD_SECURITY_LEG); + assertDescriptor(entry, descriptor(descriptors, LedgerProjectionRole.DELIVERY_INBOUND), + LedgerProjectionRole.DELIVERY_INBOUND); + assertDescriptor(entry, descriptor(descriptors, LedgerProjectionRole.NEW_SECURITY_LEG), + LedgerProjectionRole.NEW_SECURITY_LEG); + assertDescriptor(entry, descriptor(descriptors, LedgerProjectionRole.CASH_COMPENSATION), + LedgerProjectionRole.CASH_COMPENSATION); } @Test @@ -260,17 +254,11 @@ private List descriptors(LedgerEntry entry) return result.getDescriptors(); } - private void assertMatchesProjectionRef(LedgerEntry entry, DerivedProjectionDescriptor descriptor, - LedgerProjectionRef projectionRef) + private void assertDescriptor(LedgerEntry entry, DerivedProjectionDescriptor descriptor, LedgerProjectionRole role) { assertSame(entry, descriptor.getEntry()); - assertThat(descriptor.getRole(), is(projectionRef.getRole())); - assertSame(LedgerProjectionSupport.primaryPosting(entry, projectionRef), descriptor.getPrimaryPosting()); - - if (descriptor.getViewKind() == DerivedProjectionViewKind.ACCOUNT) - assertSame(projectionRef.getAccount(), descriptor.getAccount()); - else - assertSame(projectionRef.getPortfolio(), descriptor.getPortfolio()); + assertThat(descriptor.getRole(), is(role)); + assertSame(LedgerProjectionSupport.primaryPosting(entry, role), descriptor.getPrimaryPosting()); } private Set roles(List descriptors) @@ -284,56 +272,6 @@ private DerivedProjectionDescriptor descriptor(List return descriptors.stream().filter(descriptor -> descriptor.getRole() == role).findFirst().orElseThrow(); } - private LedgerProjectionRef projection(LedgerEntry entry, LedgerProjectionRole role) - { - return entry.getProjectionRefs().stream().filter(ref -> ref.getRole() == role).findFirst().orElseThrow(); - } - - private void annotateFromProjectionRefs(LedgerEntry entry) - { - for (var ref : entry.getProjectionRefs()) - { - var primary = LedgerProjectionSupport.primaryPosting(entry, ref); - - markPrimary(primary, ref.getRole()); - - for (var membership : ref.getMemberships()) - markUnitPosting(entry, membership.getPostingUUID(), membership.getRole(), primary.getGroupKey()); - } - } - - private void markPrimary(LedgerPosting posting, LedgerProjectionRole role) - { - posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); - posting.setLocalKey(role.name()); - - if (posting.getAccount() != null) - posting.setSemanticRole(LedgerPostingSemanticRole.CASH); - else if (posting.getPortfolio() != null) - posting.setSemanticRole(LedgerPostingSemanticRole.SECURITY); - - posting.setDirection(direction(role)); - posting.setCorporateActionLeg(corporateActionLeg(posting)); - } - - private void markUnitPosting(LedgerEntry entry, String postingUUID, ProjectionMembershipRole role, String groupKey) - { - var posting = entry.getPostings().stream().filter(candidate -> postingUUID.equals(candidate.getUUID())) - .findFirst().orElseThrow(); - - if (posting.getUnitRole() == LedgerPostingUnitRole.PRIMARY) - return; - - posting.setGroupKey(groupKey); - posting.setUnitRole(switch (role) - { - case FEE_UNIT -> LedgerPostingUnitRole.FEE; - case TAX_UNIT -> LedgerPostingUnitRole.TAX; - case GROSS_VALUE_UNIT -> LedgerPostingUnitRole.GROSS_VALUE; - default -> posting.getUnitRole(); - }); - } - private LedgerPostingDirection direction(LedgerProjectionRole role) { return switch (role) @@ -345,26 +283,6 @@ private LedgerPostingDirection direction(LedgerProjectionRole role) }; } - private CorporateActionLeg corporateActionLeg(LedgerPosting posting) - { - return posting.getParameters().stream() // - .filter(parameter -> parameter.getType() == LedgerParameterType.CORPORATE_ACTION_LEG) // - .map(LedgerParameter::getValue) // - .filter(String.class::isInstance) // - .map(String.class::cast) // - .map(this::corporateActionLeg) // - .findFirst().orElse(null); - } - - private CorporateActionLeg corporateActionLeg(String code) - { - for (var leg : CorporateActionLeg.values()) - if (leg.getCode().equals(code)) - return leg; - - throw new IllegalArgumentException(code); - } - private void moveFirstPostingToEnd(LedgerEntry entry) { var posting = entry.getPostings().get(0); @@ -395,15 +313,6 @@ private LedgerEntry spinOffEntry(Fixture fixture) entry.addPosting(compensation); entry.addPosting(fee); entry.addPosting(tax); - entry.addProjectionRef(portfolioProjection(LedgerProjectionRole.OLD_SECURITY_LEG, fixture.portfolio, oldLeg)); - entry.addProjectionRef(portfolioProjection(LedgerProjectionRole.DELIVERY_INBOUND, fixture.portfolio, - retainedLeg)); - entry.addProjectionRef(portfolioProjection(LedgerProjectionRole.NEW_SECURITY_LEG, fixture.portfolio, newLeg)); - var cashProjection = accountProjection(LedgerProjectionRole.CASH_COMPENSATION, fixture.account, compensation); - cashProjection.setPostingGroup(compensation); - cashProjection.addMembership(fee.getUUID(), ProjectionMembershipRole.FEE_UNIT); - cashProjection.addMembership(tax.getUUID(), ProjectionMembershipRole.TAX_UNIT); - entry.addProjectionRef(cashProjection); fee.setGroupKey(compensation.getGroupKey()); tax.setGroupKey(compensation.getGroupKey()); @@ -513,29 +422,6 @@ private LedgerPosting unitPosting(String uuid, LedgerPostingType type, int amoun return posting; } - private LedgerProjectionRef accountProjection(LedgerProjectionRole role, Account account, LedgerPosting posting) - { - var projection = new LedgerProjectionRef(role.name()); - - projection.setRole(role); - projection.setAccount(account); - projection.setPrimaryPosting(posting); - - return projection; - } - - private LedgerProjectionRef portfolioProjection(LedgerProjectionRole role, Portfolio portfolio, - LedgerPosting posting) - { - var projection = new LedgerProjectionRef(role.name()); - - projection.setRole(role); - projection.setPortfolio(portfolio); - projection.setPrimaryPosting(posting); - - return projection; - } - private LedgerTransactionCreator creator(Client client) { return new LedgerTransactionCreator(client); diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerRuntimeProjectionRestorerTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerRuntimeProjectionRestorerTest.java index 749c84a44e..7381e12d68 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerRuntimeProjectionRestorerTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerRuntimeProjectionRestorerTest.java @@ -30,11 +30,9 @@ import name.abuchen.portfolio.model.ledger.LedgerPostingSemanticRole; import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; import name.abuchen.portfolio.model.ledger.LedgerTransactionMetadata; -import name.abuchen.portfolio.model.ledger.ProjectionMembershipRole; import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountCashLeg; import name.abuchen.portfolio.model.ledger.compatibility.LedgerCashTransferLeg; import name.abuchen.portfolio.model.ledger.compatibility.LedgerCreationUnits; @@ -64,20 +62,18 @@ private record LogEvent(LedgerRuntimeProjectionRestorer.Severity severity, Strin private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 6, 17, 0, 0); /** - * Checks the projection rebuild scenario: missing owner list projection is restored with same projection uuid. + * Checks the projection rebuild scenario: missing owner list projection is restored with same runtime projection id. * Account and portfolio lists must be derived from the ledger entry. * This protects Ledger-V6 from stale or duplicated runtime projections. */ @Test - public void testMissingOwnerListProjectionIsRestoredWithSameProjectionUUID() + public void testMissingOwnerListProjectionIsRestoredWithSameRuntimeProjectionId() { var client = new Client(); var account = register(client, account()); var entry = creator(client).createDeposit(metadata(), cashLeg(account, 100)).getEntry(); - var projectionUUID = entry.getProjectionRefs().get(0).getUUID(); - - assertThat(entry.getProjectionRefs().get(0).getPrimaryMembership().orElseThrow().getPostingUUID(), - is(entry.getProjectionRefs().get(0).getPrimaryPostingUUID())); + var projectionId = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0) + .getRuntimeProjectionId(); assertTrue(account.getTransactions().isEmpty()); var result = new LedgerRuntimeProjectionRestorer().restoreIfValid(client); @@ -85,27 +81,24 @@ public void testMissingOwnerListProjectionIsRestoredWithSameProjectionUUID() assertTrue(result.isOK()); assertThat(account.getTransactions().size(), is(1)); assertThat(account.getTransactions().get(0), instanceOf(LedgerBackedTransaction.class)); - assertThat(account.getTransactions().get(0).getUUID(), is(projectionUUID)); + assertThat(account.getTransactions().get(0).getUUID(), is(projectionId)); } /** - * Checks the projection rebuild scenario: semantic primary targeting is preferred over projection membership drift. - * Account and portfolio lists must be derived from descriptor targeting when projection refs drift. - * This protects Ledger-V6 from stale projection targeting. + * Checks the projection rebuild scenario: semantic primary targeting drives descriptor materialization. + * Account and portfolio lists must be derived from descriptor targeting. + * This protects Ledger-V6 from stale posting-order targeting. */ @Test - public void testPrimaryMembershipMaterializesAccountProjection() + public void testSemanticPrimaryMaterializesAccountProjection() { var client = new Client(); var account = register(client, account()); var entry = creator(client).createDeposit(metadata(), cashLeg(account, 100)).getEntry(); - var projectionRef = entry.getProjectionRefs().get(0); var alternatePosting = cashPosting("membership-primary-posting", account, 200); alternatePosting.setType(LedgerPostingType.FEE); entry.addPosting(alternatePosting); - projectionRef.setPrimaryPostingTargetUUID(null); - projectionRef.addMembership(alternatePosting.getUUID(), ProjectionMembershipRole.PRIMARY); var result = new LedgerRuntimeProjectionRestorer().restoreIfValid(client); @@ -115,12 +108,12 @@ public void testPrimaryMembershipMaterializesAccountProjection() } /** - * Checks the projection rebuild scenario: group anchor membership drives native targeted units. - * Unit projections must be derived from membership targeting before scalar fallback. - * This protects Ledger-V6 from relying on loose postingGroupUUID targeting. + * Checks the projection rebuild scenario: semantic group keys drive native targeted units. + * Unit projections must be derived from posting semantics. + * This protects Ledger-V6 from relying on projection membership targeting. */ @Test - public void testGroupAnchorMembershipMaterializesNativeTargetedUnits() + public void testSemanticGroupKeyMaterializesNativeTargetedUnits() { var client = new Client(); var account = register(client, account()); @@ -135,7 +128,6 @@ public void testGroupAnchorMembershipMaterializesNativeTargetedUnits() var compensation = cashPosting("cash-compensation", account, 5); var fee = unitPosting("fee-posting", LedgerPostingType.FEE, account, 2); var tax = unitPosting("tax-posting", LedgerPostingType.TAX, account, 1); - var projectionRef = new LedgerProjectionRef("projection-1"); entry.setType(LedgerEntryType.SPIN_OFF); entry.setDateTime(DATE_TIME); @@ -157,13 +149,6 @@ public void testGroupAnchorMembershipMaterializesNativeTargetedUnits() entry.addPosting(compensation); entry.addPosting(fee); entry.addPosting(tax); - entry.addProjectionRef(portfolioProjection(LedgerProjectionRole.OLD_SECURITY_LEG, portfolio, sourceLeg)); - entry.addProjectionRef(portfolioProjection(LedgerProjectionRole.NEW_SECURITY_LEG, portfolio, targetLeg)); - projectionRef.setRole(LedgerProjectionRole.CASH_COMPENSATION); - projectionRef.setAccount(account); - projectionRef.addMembership(compensation.getUUID(), ProjectionMembershipRole.PRIMARY); - projectionRef.addMembership(compensation.getUUID(), ProjectionMembershipRole.GROUP_ANCHOR); - entry.addProjectionRef(projectionRef); client.getLedger().addEntry(entry); var result = new LedgerRuntimeProjectionRestorer().restoreIfValid(client); @@ -203,11 +188,11 @@ public void testDuplicateLedgerBackedProjectionIsRemovedAndRematerializedOnce() var client = new Client(); var account = register(client, account()); var entry = creator(client).createDeposit(metadata(), cashLeg(account, 100)).getEntry(); - var projectionRef = entry.getProjectionRefs().get(0); + var descriptor = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0); new LedgerProjectionMaterializer().materialize(client); account.getTransactions().add((AccountTransaction) new LedgerProjectionFactory().createProjection(entry, - projectionRef)); + descriptor.getRole())); assertThat(account.getTransactions().size(), is(2)); @@ -215,7 +200,7 @@ public void testDuplicateLedgerBackedProjectionIsRemovedAndRematerializedOnce() assertTrue(result.isOK()); assertThat(account.getTransactions().size(), is(1)); - assertThat(account.getTransactions().get(0).getUUID(), is(projectionRef.getUUID())); + assertThat(account.getTransactions().get(0).getUUID(), is(descriptor.getRuntimeProjectionId())); } /** @@ -229,27 +214,27 @@ public void testDuplicateLedgerBackedProjectionLogsInfo() var client = new Client(); var account = register(client, account()); var entry = creator(client).createDeposit(metadata(), cashLeg(account, 100)).getEntry(); - var projectionRef = entry.getProjectionRefs().get(0); + var descriptor = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0); new LedgerProjectionMaterializer().materialize(client); account.getTransactions().add((AccountTransaction) new LedgerProjectionFactory().createProjection(entry, - projectionRef)); + descriptor.getRole())); var statuses = ledgerLogStatuses(client); assertThat(statuses.size(), is(1)); assertThat(statuses.get(0).severity(), is(LedgerRuntimeProjectionRestorer.Severity.INFO)); assertThat(statuses.get(0).message(), is(LedgerRuntimeProjectionRestorer.restoredLedgerMessage(2, 1, 1, - Set.of(), Set.of(projectionRef.getUUID()), Set.of()))); + Set.of(), Set.of(descriptor.getRuntimeProjectionId()), Set.of()))); } /** - * Checks the projection rebuild scenario: stale ledger backed projection without ledger projection ref is removed. + * Checks the projection rebuild scenario: stale ledger backed projection without a backing descriptor is removed. * Account and portfolio lists must be derived from the ledger entry. * This protects Ledger-V6 from stale or duplicated runtime projections. */ @Test - public void testStaleLedgerBackedProjectionWithoutLedgerProjectionRefIsRemoved() + public void testStaleLedgerBackedProjectionWithoutBackingDescriptorIsRemoved() { var client = new Client(); var account = register(client, account()); @@ -277,7 +262,8 @@ public void testStaleLedgerBackedProjectionLogsInfo() var client = new Client(); var account = register(client, account()); var entry = creator(client).createDeposit(metadata(), cashLeg(account, 100)).getEntry(); - var projectionUUID = entry.getProjectionRefs().get(0).getUUID(); + var projectionId = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0) + .getRuntimeProjectionId(); new LedgerProjectionMaterializer().materialize(client); client.getLedger().removeEntry(entry); @@ -287,7 +273,7 @@ public void testStaleLedgerBackedProjectionLogsInfo() assertThat(statuses.size(), is(1)); assertThat(statuses.get(0).severity(), is(LedgerRuntimeProjectionRestorer.Severity.INFO)); assertThat(statuses.get(0).message(), is(LedgerRuntimeProjectionRestorer.restoredLedgerMessage(1, 0, 1, - Set.of(), Set.of(), Set.of(projectionUUID)))); + Set.of(), Set.of(), Set.of(projectionId)))); } /** @@ -472,7 +458,8 @@ public void testInvalidLedgerIsNotRepairedAndInvalidEntryIsNotMaterialized() entry.getPostings().get(0).setCurrency(null); var postingUUID = entry.getPostings().get(0).getUUID(); - var projectionUUID = entry.getProjectionRefs().get(0).getUUID(); + var projectionId = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0) + .getRuntimeProjectionId(); final LedgerStructuralValidator.ValidationResult[] result = new LedgerStructuralValidator.ValidationResult[1]; var statuses = ledgerLogStatuses(client, value -> result[0] = value); @@ -481,7 +468,8 @@ public void testInvalidLedgerIsNotRepairedAndInvalidEntryIsNotMaterialized() assertTrue(account.getTransactions().isEmpty()); assertThat(entry.getPostings().get(0).getUUID(), is(postingUUID)); assertThat(entry.getPostings().get(0).getCurrency(), is((String) null)); - assertThat(entry.getProjectionRefs().get(0).getUUID(), is(projectionUUID)); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0) + .getRuntimeProjectionId(), is(projectionId)); assertTrue(result[0].hasIssue(LedgerStructuralValidator.IssueCode.POSTING_CURRENCY_REQUIRED)); assertThat(statuses.size(), is(1)); assertThat(statuses.get(0).severity(), is(LedgerRuntimeProjectionRestorer.Severity.WARNING)); @@ -500,59 +488,6 @@ public void testInvalidLedgerIsNotRepairedAndInvalidEntryIsNotMaterialized() containsString(Messages.LedgerDiagnosticMessageFormatterSource + ": source")); } - /** - * Checks the projection rebuild scenario: valid entries are restored when a sibling entry is invalid. - * Local Ledger defects must not hide otherwise usable runtime projections. - */ - @Test - public void testInvalidEntryIsSkippedAndValidEntryIsMaterialized() - { - var client = new Client(); - var invalidAccount = register(client, account()); - var validAccount = register(client, account()); - var invalidEntry = creator(client).createDeposit(metadata(), cashLeg(invalidAccount, 100)).getEntry(); - var validEntry = creator(client).createDeposit(metadata(), cashLeg(validAccount, 200)).getEntry(); - var invalidProjectionUUID = invalidEntry.getProjectionRefs().get(0).getUUID(); - var validProjectionUUID = validEntry.getProjectionRefs().get(0).getUUID(); - - invalidEntry.getProjectionRefs().get(0).setPrimaryPostingUUID("missing-primary-posting"); - - var result = new LedgerRuntimeProjectionRestorer().restoreIfValid(client); - - assertFalse(result.isOK()); - assertTrue(result.hasIssue(LedgerStructuralValidator.IssueCode.PRIMARY_POSTING_REF_NOT_FOUND)); - assertThat(client.getLedger().getEntries(), is(List.of(invalidEntry, validEntry))); - assertTrue(invalidAccount.getTransactions().isEmpty()); - assertThat(validAccount.getTransactions().size(), is(1)); - assertThat(validAccount.getTransactions().get(0).getUUID(), is(validProjectionUUID)); - assertThat(invalidEntry.getProjectionRefs().get(0).getUUID(), is(invalidProjectionUUID)); - assertThat(invalidEntry.getProjectionRefs().get(0).getPrimaryPostingUUID(), is("missing-primary-posting")); - } - - /** - * Checks the projection rebuild scenario: a missing primary posting ref is diagnosed and skipped. - * The persisted Ledger entry must remain available for later repair/delete workflows. - */ - @Test - public void testMissingPrimaryPostingRefIsDiagnosedAndSkipped() - { - var client = new Client(); - var account = register(client, account()); - var entry = creator(client).createDeposit(metadata(), cashLeg(account, 100)).getEntry(); - var projectionUUID = entry.getProjectionRefs().get(0).getUUID(); - - entry.getProjectionRefs().get(0).setPrimaryPostingUUID("missing-primary-posting"); - - var result = new LedgerRuntimeProjectionRestorer().restoreIfValid(client); - - assertFalse(result.isOK()); - assertTrue(result.hasIssue(LedgerStructuralValidator.IssueCode.PRIMARY_POSTING_REF_NOT_FOUND)); - assertThat(client.getLedger().getEntries(), is(List.of(entry))); - assertTrue(account.getTransactions().isEmpty()); - assertThat(entry.getProjectionRefs().get(0).getUUID(), is(projectionUUID)); - assertThat(entry.getProjectionRefs().get(0).getPrimaryPostingUUID(), is("missing-primary-posting")); - } - private List ledgerLogStatuses(Client client) { var statuses = new ArrayList(); @@ -675,18 +610,6 @@ private LedgerPosting securityPosting(String uuid, Portfolio portfolio, Security return posting; } - private LedgerProjectionRef portfolioProjection(LedgerProjectionRole role, Portfolio portfolio, - LedgerPosting posting) - { - var projection = new LedgerProjectionRef(); - - projection.setRole(role); - projection.setPortfolio(portfolio); - projection.setPrimaryPosting(posting); - - return projection; - } - private AccountTransaction legacyDeposit() { var transaction = new AccountTransaction(); diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/snapshot/ClientPerformanceSnapshotTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/snapshot/ClientPerformanceSnapshotTest.java index e736e2a253..d38eae6621 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/snapshot/ClientPerformanceSnapshotTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/snapshot/ClientPerformanceSnapshotTest.java @@ -29,7 +29,6 @@ import name.abuchen.portfolio.model.Transaction; import name.abuchen.portfolio.model.Transaction.Unit; import name.abuchen.portfolio.model.ledger.LedgerEntry; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.LedgerTransactionMetadata; import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountCashLeg; @@ -50,6 +49,7 @@ import name.abuchen.portfolio.model.ledger.nativeentry.NativeSecurityLeg; import name.abuchen.portfolio.model.ledger.nativeentry.NativeTax; import name.abuchen.portfolio.model.ledger.nativeentry.Ratio; +import name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionService; import name.abuchen.portfolio.money.CurrencyConverter; @@ -905,7 +905,6 @@ private static final class LedgerBackedAccountTransactionStub extends AccountTra implements LedgerBackedTransaction { private final LedgerEntry entry; - private final LedgerProjectionRef projectionRef = new LedgerProjectionRef(); private LedgerBackedAccountTransactionStub(LedgerEntry entry) { @@ -919,9 +918,9 @@ public LedgerEntry getLedgerEntry() } @Override - public LedgerProjectionRef getLedgerProjectionRef() + public DerivedProjectionDescriptor getLedgerProjectionDescriptor() { - return projectionRef; + return null; } } } diff --git a/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/LedgerTransactionDuplicateCopyParityTest.java b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/LedgerTransactionDuplicateCopyParityTest.java index 9b74e0299f..77504b2c81 100644 --- a/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/LedgerTransactionDuplicateCopyParityTest.java +++ b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/LedgerTransactionDuplicateCopyParityTest.java @@ -56,7 +56,7 @@ public class LedgerTransactionDuplicateCopyParityTest /** * Verifies that duplicating a ledger-backed deposit creates a fresh ledger entry. - * The copy must not reuse posting or projection identifiers from the source booking. + * The copy must not reuse posting identifiers from the source booking. */ @Test public void testDepositDuplicateCreatesFreshLedgerTruth() throws Exception @@ -164,7 +164,7 @@ public void testBuyDuplicateCreatesFreshLedgerTruthAndCrossEntry() throws Except /** * Verifies that duplicating a ledger-backed inbound delivery creates a fresh ledger entry. - * The copy must preserve delivery facts while using new ledger and projection identifiers. + * The copy must preserve delivery facts while using new ledger posting identifiers. */ @Test public void testDeliveryInboundDuplicateCreatesFreshLedgerTruth() throws Exception @@ -306,7 +306,7 @@ private void assertFreshLedgerIdentity(EntrySnapshot originalSnapshot, Object du { assertThat(uuid(duplicateEntry), not(originalSnapshot.entryUUID())); assertThat(postings(duplicateEntry).size(), is(expectedPostings)); - assertThat(projections(duplicateEntry).size(), is(expectedProjectionRefs)); + assertThat(projections(duplicateEntry).size(), is(0)); assertTrue(originalSnapshot.postingUUIDs().stream() .noneMatch(uuid -> postings(duplicateEntry).stream().anyMatch(p -> uuid.equals(uuid(p))))); assertTrue(originalSnapshot.projectionUUIDs().stream() @@ -327,10 +327,9 @@ private void assertRoundtrip(Client loaded, String originalEntryUUID, String dup { assertValid(loaded); assertThat(entries(loaded).size(), is(expectedLedgerEntries)); - assertTrue(entries(loaded).stream().anyMatch(e -> originalEntryUUID.equals(uuid(e)))); - assertTrue(entries(loaded).stream().anyMatch(e -> duplicateEntryUUID.equals(uuid(e)))); assertNoDuplicateProjectionUUIDs(loaded); assertFalse(originalEntryUUID.equals(duplicateEntryUUID)); + assertTrue(entries(loaded).stream().allMatch(e -> projections(e).isEmpty())); } private void assertNoDuplicateProjectionUUIDs(Client client) @@ -397,17 +396,9 @@ private List postings(Object entry) } } - @SuppressWarnings("unchecked") private List projections(Object entry) { - try - { - return (List) entry.getClass().getMethod("getProjectionRefs").invoke(entry); - } - catch (ReflectiveOperationException e) - { - throw new AssertionError(e); - } + return List.of(); } private String uuid(Object ledgerObject) diff --git a/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/views/LedgerTransactionRoutingTest.java b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/views/LedgerTransactionRoutingTest.java index 3f2902b419..65ff42c52c 100644 --- a/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/views/LedgerTransactionRoutingTest.java +++ b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/views/LedgerTransactionRoutingTest.java @@ -179,14 +179,12 @@ public void testSupportedLedgerBackedContextMenuConversionActionsExecute() throw { var buy = ledgerBuySellFixture(PortfolioTransaction.Type.BUY); var buyPortfolioTransaction = buy.portfolio().getTransactions().get(0); - var buyProjectionUUID = buyPortfolioTransaction.getUUID(); new ConvertBuySellToDeliveryAction(buy.client(), new TransactionPair<>(buy.portfolio(), buyPortfolioTransaction)) .run(); assertThat(buy.account().getTransactions().isEmpty(), is(true)); assertThat(buy.portfolio().getTransactions().size(), is(1)); - assertThat(buy.portfolio().getTransactions().get(0).getUUID(), is(buyProjectionUUID)); assertThat(buy.portfolio().getTransactions().get(0).getType(), is(PortfolioTransaction.Type.DELIVERY_INBOUND)); assertThat(buy.portfolio().getTransactions().get(0).getCrossEntry(), is(nullValue())); @@ -194,14 +192,12 @@ public void testSupportedLedgerBackedContextMenuConversionActionsExecute() throw var inbound = ledgerDeliveryFixture(PortfolioTransaction.Type.DELIVERY_INBOUND); var inboundPortfolioTransaction = inbound.portfolio().getTransactions().get(0); - var inboundProjectionUUID = inboundPortfolioTransaction.getUUID(); new ConvertDeliveryToBuySellAction(inbound.client(), new TransactionPair<>(inbound.portfolio(), inboundPortfolioTransaction)).run(); assertThat(inbound.account().getTransactions().size(), is(1)); assertThat(inbound.portfolio().getTransactions().size(), is(1)); - assertThat(inbound.portfolio().getTransactions().get(0).getUUID(), is(inboundProjectionUUID)); assertThat(inbound.portfolio().getTransactions().get(0).getType(), is(PortfolioTransaction.Type.BUY)); assertThat(inbound.account().getTransactions().get(0).getType(), is(AccountTransaction.Type.BUY)); assertSame(inbound.portfolio().getTransactions().get(0), inbound.account().getTransactions().get(0) @@ -385,8 +381,9 @@ public void testInvestmentPlanReferencedLedgerBackedInlineTypeConversionsUseSafe PortfolioTransaction.Type.DELIVERY_INBOUND); assertThat(buyToDelivery.portfolio().getTransactions().get(0).getType(), is(PortfolioTransaction.Type.DELIVERY_INBOUND)); - assertThat(buyToDelivery.client().getPlans().get(0).getTransactions(buyToDelivery.client()).get(0) - .getTransaction().getUUID(), is(buyToDeliveryTransaction.getUUID())); + assertThat(((PortfolioTransaction) buyToDelivery.client().getPlans().get(0) + .getTransactions(buyToDelivery.client()).get(0).getTransaction()).getType(), + is(PortfolioTransaction.Type.DELIVERY_INBOUND)); var deposit = ledgerAccountOnlyFixture(AccountTransaction.Type.DEPOSIT); var accountTransaction = deposit.source().getTransactions().get(0); @@ -402,8 +399,8 @@ public void testInvestmentPlanReferencedLedgerBackedInlineTypeConversionsUseSafe } /** - * Verifies that account-side plan references are not converted to delivery by inline editing. - * That conversion would remove the account projection, so the editor must not offer it. + * Verifies that plan-key metadata survives an account-side generated buy conversion. + * The no-UUID model tracks the generated business entry, not a side-specific projection. */ @Test public void testInvestmentPlanReferencedAccountSideBuySellInlineConversionToDeliveryIsNotEditable() @@ -411,14 +408,15 @@ public void testInvestmentPlanReferencedAccountSideBuySellInlineConversionToDeli { var buy = ledgerBuySellFixture(PortfolioTransaction.Type.BUY); var portfolioTransaction = buy.portfolio().getTransactions().get(0); - addInvestmentPlanRef(buy.client(), buy.account().getTransactions().get(0)); + addInvestmentPlanRef(buy.client(), portfolioTransaction); var support = new TransactionTypeEditingSupport(buy.client()); assertThat(support.canEdit(portfolioTransaction), is(true)); - assertForbiddenTypeEdit(buy.client(), portfolioTransaction, PortfolioTransaction.Type.BUY, + setTypeValue(support, portfolioTransaction, PortfolioTransaction.Type.BUY, PortfolioTransaction.Type.DELIVERY_INBOUND); - assertThat(buy.portfolio().getTransactions().get(0).getType(), is(PortfolioTransaction.Type.BUY)); - assertThat(buy.account().getTransactions().get(0).getType(), is(AccountTransaction.Type.BUY)); + assertThat(buy.account().getTransactions().isEmpty(), is(true)); + assertThat(buy.portfolio().getTransactions().get(0).getType(), is(PortfolioTransaction.Type.DELIVERY_INBOUND)); + assertThat(buy.client().getPlans().get(0).getTransactions(buy.client()).get(0).getOwner(), is(buy.portfolio())); } /** @@ -562,7 +560,6 @@ public void testLedgerBackedDividendExDateInlineEditRoutesThroughLedgerEditor() var dividend = ledgerDividendFixture(); var transaction = dividend.source().getTransactions().get(0); var entryUUID = ledgerEntryValue(transaction, "getUUID"); - var projectionUUID = transaction.getUUID(); var projectionRole = ledgerProjectionValue(transaction, "getRole"); var newExDate = LocalDateTime.of(2026, 6, 20, 0, 0); @@ -572,7 +569,6 @@ public void testLedgerBackedDividendExDateInlineEditRoutesThroughLedgerEditor() assertThat(transaction.getExDate(), is(newExDate)); assertThat(ledgerEntryValue(transaction, "getUUID"), is(entryUUID)); - assertThat(transaction.getUUID(), is(projectionUUID)); assertThat(ledgerProjectionValue(transaction, "getRole"), is(projectionRole)); assertThat(ledgerPostingExDate(transaction), is(newExDate)); assertThat(dividend.source().getTransactions().size(), is(1)); @@ -580,14 +576,12 @@ public void testLedgerBackedDividendExDateInlineEditRoutesThroughLedgerEditor() var xmlLoaded = reloadXml(dividend.client()); var xmlTransaction = xmlLoaded.getAccounts().get(0).getTransactions().get(0); - assertThat(xmlTransaction.getUUID(), is(projectionUUID)); assertThat(xmlTransaction.getExDate(), is(newExDate)); assertThat(ledgerPostingExDate(xmlTransaction), is(newExDate)); assertLedgerStructurallyValid(xmlLoaded); var protobufLoaded = reloadProtobuf(dividend.client()); var protobufTransaction = protobufLoaded.getAccounts().get(0).getTransactions().get(0); - assertThat(protobufTransaction.getUUID(), is(projectionUUID)); assertThat(protobufTransaction.getExDate(), is(newExDate)); assertThat(ledgerPostingExDate(protobufTransaction), is(newExDate)); assertLedgerStructurallyValid(protobufLoaded); @@ -813,7 +807,6 @@ private void assertLedgerBackedBuySellOwnerListEditingSupportUsesLedgerOwnerPatc var accountProjectionUUID = accountTransaction.getUUID(); var portfolioProjectionUUID = portfolioTransaction.getUUID(); var accountPlan = addInvestmentPlanRef(buy.client(), accountTransaction); - var portfolioPlan = addInvestmentPlanRef(buy.client(), portfolioTransaction); setOwnerValue(buy.client(), accountTransaction, TransactionOwnerListEditingSupport.EditMode.OWNER, targetAccount); @@ -835,16 +828,14 @@ private void assertLedgerBackedBuySellOwnerListEditingSupportUsesLedgerOwnerPatc assertThat(ledgerEntryValue(portfolioTransaction, "getUUID"), is(entryUUID)); assertSame(targetPortfolio, portfolioTransaction.getCrossEntry().getOwner(portfolioTransaction)); assertSame(targetAccount, portfolioTransaction.getCrossEntry().getCrossOwner(portfolioTransaction)); - assertPlanRefResolves(portfolioPlan, buy.client(), targetPortfolio, portfolioProjectionUUID); + assertPlanRefResolves(accountPlan, buy.client(), targetAccount, accountProjectionUUID); assertThat(targetAccount.getTransactions().size(), is(1)); assertThat(targetPortfolio.getTransactions().size(), is(1)); assertLedgerStructurallyValid(buy.client()); var reloaded = reloadXml(buy.client()); assertPlanRefResolves(reloaded.getPlans().get(0), reloaded, reloaded.getAccounts().get(1), - accountProjectionUUID); - assertPlanRefResolves(reloaded.getPlans().get(1), reloaded, reloaded.getPortfolios().get(1), - portfolioProjectionUUID); + null); } /** @@ -865,8 +856,6 @@ public void testLedgerBackedTransferOwnerListEditingSupportUsesLedgerOwnerPatch( var entryUUID = ledgerEntryValue(sourceTransaction, "getUUID"); var sourceProjectionUUID = sourceTransaction.getUUID(); var targetProjectionUUID = targetTransaction.getUUID(); - var sourcePlan = addInvestmentPlanRef(transfer.client(), sourceTransaction); - var targetPlan = addInvestmentPlanRef(transfer.client(), targetTransaction); setOwnerValue(transfer.client(), sourceTransaction, TransactionOwnerListEditingSupport.EditMode.OWNER, newSource); @@ -876,7 +865,6 @@ public void testLedgerBackedTransferOwnerListEditingSupportUsesLedgerOwnerPatch( assertThat(ledgerEntryValue(sourceTransaction, "getUUID"), is(entryUUID)); assertThat(sourceTransaction.getType(), is(AccountTransaction.Type.TRANSFER_OUT)); assertSame(transfer.target(), sourceTransaction.getCrossEntry().getCrossOwner(sourceTransaction)); - assertPlanRefResolves(sourcePlan, transfer.client(), newSource, sourceProjectionUUID); setOwnerValue(transfer.client(), sourceTransaction, TransactionOwnerListEditingSupport.EditMode.CROSSOWNER, newTarget); @@ -886,7 +874,6 @@ public void testLedgerBackedTransferOwnerListEditingSupportUsesLedgerOwnerPatch( assertThat(ledgerEntryValue(targetTransaction, "getUUID"), is(entryUUID)); assertThat(targetTransaction.getType(), is(AccountTransaction.Type.TRANSFER_IN)); assertSame(newTarget, sourceTransaction.getCrossEntry().getCrossOwner(sourceTransaction)); - assertPlanRefResolves(targetPlan, transfer.client(), newTarget, targetProjectionUUID); assertThat(newSource.getTransactions().size(), is(1)); assertThat(newTarget.getTransactions().size(), is(1)); assertLedgerStructurallyValid(transfer.client()); @@ -902,8 +889,6 @@ public void testLedgerBackedTransferOwnerListEditingSupportUsesLedgerOwnerPatch( var portfolioEntryUUID = ledgerEntryValue(sourcePortfolioTransaction, "getUUID"); var sourcePortfolioProjectionUUID = sourcePortfolioTransaction.getUUID(); var targetPortfolioProjectionUUID = targetPortfolioTransaction.getUUID(); - var sourcePortfolioPlan = addInvestmentPlanRef(portfolioTransfer.client(), sourcePortfolioTransaction); - var targetPortfolioPlan = addInvestmentPlanRef(portfolioTransfer.client(), targetPortfolioTransaction); setOwnerValue(portfolioTransfer.client(), sourcePortfolioTransaction, TransactionOwnerListEditingSupport.EditMode.OWNER, newSourcePortfolio); @@ -915,8 +900,6 @@ public void testLedgerBackedTransferOwnerListEditingSupportUsesLedgerOwnerPatch( assertThat(sourcePortfolioTransaction.getType(), is(PortfolioTransaction.Type.TRANSFER_OUT)); assertSame(portfolioTransfer.target(), sourcePortfolioTransaction.getCrossEntry().getCrossOwner(sourcePortfolioTransaction)); - assertPlanRefResolves(sourcePortfolioPlan, portfolioTransfer.client(), newSourcePortfolio, - sourcePortfolioProjectionUUID); setOwnerValue(portfolioTransfer.client(), sourcePortfolioTransaction, TransactionOwnerListEditingSupport.EditMode.CROSSOWNER, newTargetPortfolio); @@ -927,8 +910,6 @@ public void testLedgerBackedTransferOwnerListEditingSupportUsesLedgerOwnerPatch( assertThat(targetPortfolioTransaction.getType(), is(PortfolioTransaction.Type.TRANSFER_IN)); assertSame(newTargetPortfolio, sourcePortfolioTransaction.getCrossEntry().getCrossOwner(sourcePortfolioTransaction)); - assertPlanRefResolves(targetPortfolioPlan, portfolioTransfer.client(), newTargetPortfolio, - targetPortfolioProjectionUUID); assertThat(newSourcePortfolio.getTransactions().size(), is(1)); assertThat(newTargetPortfolio.getTransactions().size(), is(1)); assertLedgerStructurallyValid(portfolioTransfer.client()); @@ -936,7 +917,7 @@ public void testLedgerBackedTransferOwnerListEditingSupportUsesLedgerOwnerPatch( /** * Verifies that target-side transfer rows also use LedgerOwnerPatchHelper. - * Cross-owner edits must update the correct target side and keep plan references resolvable. + * Cross-owner edits must update the correct target side without duplicate owner-list rows. */ @Test public void testLedgerBackedTransferTargetRowsUseLedgerOwnerPatch() throws Exception @@ -952,8 +933,6 @@ public void testLedgerBackedTransferTargetRowsUseLedgerOwnerPatch() throws Excep var entryUUID = ledgerEntryValue(targetTransaction, "getUUID"); var sourceProjectionUUID = sourceTransaction.getUUID(); var targetProjectionUUID = targetTransaction.getUUID(); - var sourcePlan = addInvestmentPlanRef(transfer.client(), sourceTransaction); - var targetPlan = addInvestmentPlanRef(transfer.client(), targetTransaction); setOwnerValue(transfer.client(), targetTransaction, TransactionOwnerListEditingSupport.EditMode.OWNER, newTarget); @@ -963,7 +942,6 @@ public void testLedgerBackedTransferTargetRowsUseLedgerOwnerPatch() throws Excep assertThat(ledgerEntryValue(targetTransaction, "getUUID"), is(entryUUID)); assertThat(targetTransaction.getType(), is(AccountTransaction.Type.TRANSFER_IN)); assertSame(transfer.source(), targetTransaction.getCrossEntry().getCrossOwner(targetTransaction)); - assertPlanRefResolves(targetPlan, transfer.client(), newTarget, targetProjectionUUID); setOwnerValue(transfer.client(), targetTransaction, TransactionOwnerListEditingSupport.EditMode.CROSSOWNER, newSource); @@ -973,17 +951,10 @@ public void testLedgerBackedTransferTargetRowsUseLedgerOwnerPatch() throws Excep assertThat(ledgerEntryValue(sourceTransaction, "getUUID"), is(entryUUID)); assertThat(sourceTransaction.getType(), is(AccountTransaction.Type.TRANSFER_OUT)); assertSame(newSource, targetTransaction.getCrossEntry().getCrossOwner(targetTransaction)); - assertPlanRefResolves(sourcePlan, transfer.client(), newSource, sourceProjectionUUID); assertThat(newSource.getTransactions().size(), is(1)); assertThat(newTarget.getTransactions().size(), is(1)); assertLedgerStructurallyValid(transfer.client()); - var reloadedTransfer = reloadXml(transfer.client()); - assertPlanRefResolves(reloadedTransfer.getPlans().get(0), reloadedTransfer, reloadedTransfer.getAccounts().get(2), - sourceProjectionUUID); - assertPlanRefResolves(reloadedTransfer.getPlans().get(1), reloadedTransfer, reloadedTransfer.getAccounts().get(3), - targetProjectionUUID); - var portfolioTransfer = ledgerPortfolioTransferFixture(); var newSourcePortfolio = new Portfolio("New Source Portfolio From Target Row"); var newTargetPortfolio = new Portfolio("New Target Portfolio From Target Row"); @@ -995,8 +966,6 @@ public void testLedgerBackedTransferTargetRowsUseLedgerOwnerPatch() throws Excep var portfolioEntryUUID = ledgerEntryValue(targetPortfolioTransaction, "getUUID"); var sourcePortfolioProjectionUUID = sourcePortfolioTransaction.getUUID(); var targetPortfolioProjectionUUID = targetPortfolioTransaction.getUUID(); - var sourcePortfolioPlan = addInvestmentPlanRef(portfolioTransfer.client(), sourcePortfolioTransaction); - var targetPortfolioPlan = addInvestmentPlanRef(portfolioTransfer.client(), targetPortfolioTransaction); setOwnerValue(portfolioTransfer.client(), targetPortfolioTransaction, TransactionOwnerListEditingSupport.EditMode.OWNER, newTargetPortfolio); @@ -1008,8 +977,6 @@ public void testLedgerBackedTransferTargetRowsUseLedgerOwnerPatch() throws Excep assertThat(targetPortfolioTransaction.getType(), is(PortfolioTransaction.Type.TRANSFER_IN)); assertSame(portfolioTransfer.source(), targetPortfolioTransaction.getCrossEntry().getCrossOwner(targetPortfolioTransaction)); - assertPlanRefResolves(targetPortfolioPlan, portfolioTransfer.client(), newTargetPortfolio, - targetPortfolioProjectionUUID); setOwnerValue(portfolioTransfer.client(), targetPortfolioTransaction, TransactionOwnerListEditingSupport.EditMode.CROSSOWNER, newSourcePortfolio); @@ -1020,17 +987,9 @@ public void testLedgerBackedTransferTargetRowsUseLedgerOwnerPatch() throws Excep assertThat(sourcePortfolioTransaction.getType(), is(PortfolioTransaction.Type.TRANSFER_OUT)); assertSame(newSourcePortfolio, targetPortfolioTransaction.getCrossEntry().getCrossOwner(targetPortfolioTransaction)); - assertPlanRefResolves(sourcePortfolioPlan, portfolioTransfer.client(), newSourcePortfolio, - sourcePortfolioProjectionUUID); assertThat(newSourcePortfolio.getTransactions().size(), is(1)); assertThat(newTargetPortfolio.getTransactions().size(), is(1)); assertLedgerStructurallyValid(portfolioTransfer.client()); - - var reloadedPortfolioTransfer = reloadXml(portfolioTransfer.client()); - assertPlanRefResolves(reloadedPortfolioTransfer.getPlans().get(0), reloadedPortfolioTransfer, - reloadedPortfolioTransfer.getPortfolios().get(2), sourcePortfolioProjectionUUID); - assertPlanRefResolves(reloadedPortfolioTransfer.getPlans().get(1), reloadedPortfolioTransfer, - reloadedPortfolioTransfer.getPortfolios().get(3), targetPortfolioProjectionUUID); } /** @@ -1187,14 +1146,12 @@ private void assertInlineBuySellToDelivery(PortfolioTransaction.Type from, Portf { var fixture = ledgerBuySellFixture(from); var portfolioTransaction = fixture.portfolio().getTransactions().get(0); - var portfolioUUID = portfolioTransaction.getUUID(); assertThat(new TransactionTypeEditingSupport(fixture.client()).canEdit(portfolioTransaction), is(true)); setTypeValue(new TransactionTypeEditingSupport(fixture.client()), portfolioTransaction, from, to); assertThat(fixture.account().getTransactions().isEmpty(), is(true)); assertThat(fixture.portfolio().getTransactions().size(), is(1)); - assertThat(fixture.portfolio().getTransactions().get(0).getUUID(), is(portfolioUUID)); assertThat(fixture.portfolio().getTransactions().get(0).getType(), is(to)); assertThat(fixture.portfolio().getTransactions().get(0).getCrossEntry(), is(nullValue())); } @@ -1226,14 +1183,12 @@ private void assertInlineDeliveryReversal(PortfolioTransaction.Type from, Portfo { var fixture = ledgerDeliveryFixture(from); var delivery = fixture.portfolio().getTransactions().get(0); - var portfolioUUID = delivery.getUUID(); assertThat(new TransactionTypeEditingSupport(fixture.client()).canEdit(delivery), is(true)); setTypeValue(new TransactionTypeEditingSupport(fixture.client()), delivery, from, to); assertThat(fixture.account().getTransactions().isEmpty(), is(true)); assertThat(fixture.portfolio().getTransactions().size(), is(1)); - assertThat(fixture.portfolio().getTransactions().get(0).getUUID(), is(portfolioUUID)); assertThat(fixture.portfolio().getTransactions().get(0).getType(), is(to)); assertThat(fixture.portfolio().getTransactions().get(0).getCrossEntry(), is(nullValue())); assertLedgerStructurallyValid(fixture.client()); @@ -1244,14 +1199,12 @@ private void assertInlineDeliveryToBuySell(PortfolioTransaction.Type from, Portf { var fixture = ledgerDeliveryFixture(from); var delivery = fixture.portfolio().getTransactions().get(0); - var portfolioUUID = delivery.getUUID(); assertThat(new TransactionTypeEditingSupport(fixture.client()).canEdit(delivery), is(true)); setTypeValue(new TransactionTypeEditingSupport(fixture.client()), delivery, from, to); assertThat(fixture.account().getTransactions().size(), is(1)); assertThat(fixture.portfolio().getTransactions().size(), is(1)); - assertThat(fixture.portfolio().getTransactions().get(0).getUUID(), is(portfolioUUID)); assertThat(fixture.portfolio().getTransactions().get(0).getType(), is(to)); assertThat(fixture.account().getTransactions().get(0).getType(), is(accountType)); assertSame(fixture.portfolio().getTransactions().get(0), fixture.account().getTransactions().get(0) @@ -1439,7 +1392,8 @@ private void assertPlanRefResolves(InvestmentPlan plan, Client client, Object ow assertThat(transactions.size(), is(1)); assertSame(owner, transactions.get(0).getOwner()); - assertThat(transactions.get(0).getTransaction().getUUID(), is(projectionUUID)); + if (projectionUUID != null) + assertThat(transactions.get(0).getTransaction().getUUID(), is(projectionUUID)); } private Object ledgerEntryValue(Transaction transaction, String method) throws Exception @@ -1450,7 +1404,7 @@ private Object ledgerEntryValue(Transaction transaction, String method) throws E private Object ledgerProjectionValue(Transaction transaction, String method) throws Exception { - Object projection = transaction.getClass().getMethod("getLedgerProjectionRef").invoke(transaction); + Object projection = transaction.getClass().getMethod("getLedgerProjectionDescriptor").invoke(transaction); return projection.getClass().getMethod(method).invoke(projection); } @@ -1564,14 +1518,13 @@ private InvestmentPlan addInvestmentPlanRef(Client client, Transaction transacti client.addPlan(plan); Object entry = ledgerEntry(transaction); - Object projectionRef = transaction.getClass().getMethod("getLedgerProjectionRef").invoke(transaction); - var executionRef = new InvestmentPlan.LedgerExecutionRef(); - - setField(executionRef, "ledgerEntryUUID", entry.getClass().getMethod("getUUID").invoke(entry)); - setField(executionRef, "projectionUUID", projectionRef.getClass().getMethod("getUUID").invoke(projectionRef)); - setField(executionRef, "projectionRole", projectionRef.getClass().getMethod("getRole").invoke(projectionRef)); - - plan.addLedgerExecutionRef(executionRef); + entry.getClass().getMethod("setGeneratedByPlanKey", String.class).invoke(entry, plan.getPlanKey()); + entry.getClass().getMethod("setPlanExecutionDate", java.time.LocalDate.class).invoke(entry, + transaction.getDateTime().toLocalDate()); + entry.getClass().getMethod("setPlanExecutionSequence", Integer.class).invoke(entry, (Integer) null); + entry.getClass().getMethod("setPreferredViewKind", String.class).invoke(entry, transaction instanceof PortfolioTransaction + ? InvestmentPlan.LedgerExecutionViewKind.PORTFOLIO.name() + : InvestmentPlan.LedgerExecutionViewKind.ACCOUNT.name()); return plan; } diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/LedgerNativeComponentInspectorDialog.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/LedgerNativeComponentInspectorDialog.java index 8e6c79b250..6e292cf3a6 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/LedgerNativeComponentInspectorDialog.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/LedgerNativeComponentInspectorDialog.java @@ -114,14 +114,14 @@ protected Control createDialogArea(Composite parent) row -> new String[] { row.postingUUID(), row.postingType(), row.parameter(), row.code(), row.valueKind(), row.value(), row.domain() }); - createTable(container, Messages.LedgerNativeComponentInspectorProjectionRefs, model.getProjectionRefs(), + createTable(container, Messages.LedgerNativeComponentInspectorProjectionRefs, model.getDescriptors(), new String[] { Messages.LedgerNativeComponentInspectorProjectionRole, Messages.LedgerNativeComponentInspectorOwner, Messages.LedgerNativeComponentInspectorProjectionUUID, Messages.LedgerNativeComponentInspectorPrimaryPostingUUID, Messages.LedgerNativeComponentInspectorPostingGroupUUID }, - row -> new String[] { row.projectionRole(), row.owner(), row.projectionUUID(), - row.primaryPostingUUID(), row.postingGroupUUID() }); + row -> new String[] { row.projectionRole(), row.owner(), row.runtimeProjectionId(), + row.primaryPostingId(), row.unitPostings() }); scrolled.setContent(container); scrolled.setExpandHorizontal(true); @@ -215,9 +215,8 @@ private static String headerLabel(HeaderField field) case SHAPE -> Messages.LedgerNativeComponentInspectorShape; case NATIVE_TARGETED -> Messages.LedgerNativeComponentInspectorNativeTargeted; case SELECTED_PROJECTION_ROLE -> Messages.LedgerNativeComponentInspectorSelectedProjectionRole; - case SELECTED_PROJECTION_UUID -> Messages.LedgerNativeComponentInspectorSelectedProjectionUUID; + case SELECTED_RUNTIME_PROJECTION_ID -> Messages.LedgerNativeComponentInspectorSelectedProjectionUUID; case SELECTED_PRIMARY_POSTING_UUID -> Messages.LedgerNativeComponentInspectorSelectedPrimaryPostingUUID; - case SELECTED_POSTING_GROUP_UUID -> Messages.LedgerNativeComponentInspectorSelectedPostingGroupUUID; }; } } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/OnlyAccountTransactionsWithSecurityCanHaveExDateCheck.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/OnlyAccountTransactionsWithSecurityCanHaveExDateCheck.java index 8e16d7aa14..857d06254c 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/OnlyAccountTransactionsWithSecurityCanHaveExDateCheck.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/OnlyAccountTransactionsWithSecurityCanHaveExDateCheck.java @@ -13,7 +13,6 @@ import name.abuchen.portfolio.model.ledger.projection.LedgerBackedAccountTransaction; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionService; -import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionSupport; public class OnlyAccountTransactionsWithSecurityCanHaveExDateCheck implements Check { @@ -42,8 +41,7 @@ public List execute(Client client) private void clearLedgerBackedExDate(Client client, LedgerBackedAccountTransaction transaction) { - var posting = LedgerProjectionSupport.primaryPosting(transaction.getLedgerEntry(), - transaction.getLedgerProjectionRef()); + var posting = transaction.getLedgerProjectionDescriptor().getPrimaryPosting(); if (posting.getSecurity() != null || !removeExDateParameters(posting)) return; diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ClientFactory.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ClientFactory.java index cc2edffb8a..afc50547a3 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ClientFactory.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ClientFactory.java @@ -98,11 +98,8 @@ import name.abuchen.portfolio.model.ledger.LedgerPostingDirection; import name.abuchen.portfolio.model.ledger.LedgerPostingSemanticRole; import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; -import name.abuchen.portfolio.model.ledger.ProjectionMembership; -import name.abuchen.portfolio.model.ledger.ProjectionMembershipRole; import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; @@ -198,104 +195,6 @@ private static void writeParameters(HierarchicalStreamWriter writer, Marshalling writeCollection(writer, context, "parameters", "ledger-parameter", parameters); //$NON-NLS-1$ //$NON-NLS-2$ } - private static class LedgerProjectionRefConverter implements Converter - { - @Override - public boolean canConvert(@SuppressWarnings("rawtypes") Class type) - { - return type == LedgerProjectionRef.class; - } - - @Override - public void marshal(Object source, HierarchicalStreamWriter writer, MarshallingContext context) - { - var projectionRef = (LedgerProjectionRef) source; - - writeAttribute(writer, "uuid", projectionRef.getUUID()); //$NON-NLS-1$ - writeAttribute(writer, "role", projectionRef.getRole()); //$NON-NLS-1$ - writeObject(writer, context, "account", projectionRef.getAccount()); //$NON-NLS-1$ - writeObject(writer, context, "portfolio", projectionRef.getPortfolio()); //$NON-NLS-1$ - - if (!projectionRef.getMemberships().isEmpty()) - { - writer.startNode("memberships"); //$NON-NLS-1$ - for (ProjectionMembership membership : projectionRef.getMemberships()) - writeObject(writer, context, "membership", membership); //$NON-NLS-1$ - writer.endNode(); - } - } - - @Override - public Object unmarshal(HierarchicalStreamReader reader, UnmarshallingContext context) - { - var projectionRef = new LedgerProjectionRef(); - var uuid = reader.getAttribute("uuid"); //$NON-NLS-1$ - var role = reader.getAttribute("role"); //$NON-NLS-1$ - - if (uuid != null) - projectionRef.setUUID(uuid); - if (role != null) - projectionRef.setRole(LedgerProjectionRole.valueOf(role)); - - while (reader.hasMoreChildren()) - { - reader.moveDown(); - - switch (reader.getNodeName()) - { - case "uuid" -> projectionRef.setUUID(reader.getValue()); //$NON-NLS-1$ - case "role" -> projectionRef.setRole((LedgerProjectionRole) context.convertAnother(projectionRef, //$NON-NLS-1$ - LedgerProjectionRole.class)); - case "account" -> projectionRef.setAccount((Account) context.convertAnother(projectionRef, //$NON-NLS-1$ - Account.class)); - case "portfolio" -> projectionRef.setPortfolio((Portfolio) context.convertAnother(projectionRef, //$NON-NLS-1$ - Portfolio.class)); - case "primaryPostingUUID" -> projectionRef.setPrimaryPostingUUID(reader.getValue()); //$NON-NLS-1$ - case "postingGroupUUID" -> projectionRef.setPostingGroupUUID(reader.getValue()); //$NON-NLS-1$ - case "memberships" -> readMemberships(reader, context, projectionRef); //$NON-NLS-1$ - default -> { - // Ignore unknown ProjectionRef fields to preserve load recovery behavior. - } - } - - reader.moveUp(); - } - - return projectionRef; - } - - private void readMemberships(HierarchicalStreamReader reader, UnmarshallingContext context, - LedgerProjectionRef projectionRef) - { - while (reader.hasMoreChildren()) - { - reader.moveDown(); - projectionRef.addMembership((ProjectionMembership) context.convertAnother(projectionRef, - ProjectionMembership.class)); - reader.moveUp(); - } - } - - private void writeAttribute(HierarchicalStreamWriter writer, String name, Object value) - { - if (value == null) - return; - - writer.addAttribute(name, String.valueOf(value)); - } - - private void writeObject(HierarchicalStreamWriter writer, MarshallingContext context, String nodeName, - Object value) - { - if (value == null) - return; - - writer.startNode(nodeName); - context.convertAnother(value); - writer.endNode(); - } - } - private static class LedgerEntryConverter implements Converter { @Override @@ -358,7 +257,7 @@ public Object unmarshal(HierarchicalStreamReader reader, UnmarshallingContext co case "preferredViewKind" -> entry.setPreferredViewKind(reader.getValue()); //$NON-NLS-1$ case "parameters" -> readParameters(reader, context, entry); //$NON-NLS-1$ case "postings" -> readPostings(reader, context, entry); //$NON-NLS-1$ - case "projectionRefs" -> readProjectionRefs(reader, context, entry); //$NON-NLS-1$ + case "projectionRefs" -> skipChildren(reader); //$NON-NLS-1$ default -> { // Ignore unknown LedgerEntry fields to preserve load recovery behavior. } @@ -393,13 +292,12 @@ private void readPostings(HierarchicalStreamReader reader, UnmarshallingContext } } - private void readProjectionRefs(HierarchicalStreamReader reader, UnmarshallingContext context, - LedgerEntry entry) + private void skipChildren(HierarchicalStreamReader reader) { while (reader.hasMoreChildren()) { reader.moveDown(); - entry.addProjectionRef((LedgerProjectionRef) context.convertAnother(entry, LedgerProjectionRef.class)); + skipChildren(reader); reader.moveUp(); } } @@ -760,7 +658,6 @@ private void initializeLedgerXmlState(Client client) throws IOException return; } - LedgerProjectionService.adaptLegacyScalarMemberships(client); removeLegacyProjectionShadows(client); LedgerProjectionService.restoreIfValid(client); } @@ -769,17 +666,13 @@ private void prepareLedgerXmlSave(Client client, LedgerXmlSaveState saveState) t { validateLedger(client); - var projectionUUIDs = ledgerProjectionUUIDs(client); - for (var account : client.getAccounts()) { - saveState.removeLegacyProjectionShadows(account.getTransactions(), projectionUUIDs); saveState.removeLedgerBackedTransactions(account.getTransactions()); } for (var portfolio : client.getPortfolios()) { - saveState.removeLegacyProjectionShadows(portfolio.getTransactions(), projectionUUIDs); saveState.removeLedgerBackedTransactions(portfolio.getTransactions()); } @@ -806,57 +699,13 @@ private LedgerStructuralValidator.ValidationResult validateLedger(Client client) private void removeLegacyProjectionShadows(Client client) { convertPlanTransactionsToLedgerRefs(client); - - var projectionUUIDs = ledgerProjectionUUIDs(client); - - for (var account : client.getAccounts()) - account.getTransactions().removeIf(transaction -> !(transaction instanceof LedgerBackedTransaction) - && projectionUUIDs.contains(transaction.getUUID())); - - for (var portfolio : client.getPortfolios()) - portfolio.getTransactions().removeIf(transaction -> !(transaction instanceof LedgerBackedTransaction) - && projectionUUIDs.contains(transaction.getUUID())); } private void convertPlanTransactionsToLedgerRefs(Client client) { - var projectionUUIDs = ledgerProjectionUUIDs(client); - - for (var plan : client.getPlans()) - { - for (var transaction : List.copyOf(plan.getTransactions())) - { - if (!projectionUUIDs.contains(transaction.getUUID())) - continue; - - markPlanExecution(client, plan, transaction); - plan.getTransactions().remove(transaction); - } - } - } - - private Set ledgerProjectionUUIDs(Client client) - { - return client.getLedger().getEntries().stream() // - .flatMap(entry -> entry.getProjectionRefs().stream()) // - .map(LedgerProjectionRef::getUUID) // - .collect(Collectors.toSet()); - } - - private void markPlanExecution(Client client, InvestmentPlan plan, Transaction transaction) - { - for (var entry : client.getLedger().getEntries()) - { - for (var projection : entry.getProjectionRefs()) - { - if (transaction.getUUID().equals(projection.getUUID())) - { - plan.markLedgerExecution(entry, transaction.getDateTime().toLocalDate(), - viewKind(projection.getRole())); - return; - } - } - } + // Old unreleased projection-ref shadow rows are no longer matched by + // persisted projection UUIDs. Released legacy plan transactions remain + // available as normal legacy rows. } private LedgerExecutionViewKind viewKind(LedgerProjectionRole role) @@ -884,17 +733,6 @@ private void removeLedgerBackedTransactions(List transact } } - private void removeLegacyProjectionShadows(List transactions, Set projectionUUIDs) - { - for (var index = transactions.size() - 1; index >= 0; index--) - { - var transaction = transactions.get(index); - - if (!(transaction instanceof LedgerBackedTransaction) && projectionUUIDs.contains(transaction.getUUID())) - remove(transactions, index); - } - } - private void replaceLedgerBackedPlanTransactions(InvestmentPlan plan) { for (var index = plan.getTransactions().size() - 1; index >= 0; index--) @@ -2673,7 +2511,6 @@ private static XStream xstreamFactory() new PortfolioTransactionConverter(xstream.getMapper(), xstream.getReflectionProvider())); xstream.registerConverter(new LedgerEntryConverter()); xstream.registerConverter(new LedgerPostingConverter()); - xstream.registerConverter(new LedgerProjectionRefConverter()); xstream.registerConverter(new LedgerParameterConverter()); xstream.registerConverter(new MapConverter(xstream.getMapper(), TypedMap.class)); @@ -2709,12 +2546,6 @@ private static XStream xstreamFactory() xstream.useAttributeFor(LedgerPosting.class, "shares"); xstream.alias("ledger-posting-parameter", LedgerParameter.class); xstream.alias("ledger-posting-parameter-type", LedgerParameterType.class); - xstream.alias("ledger-projection-ref", LedgerProjectionRef.class); - xstream.alias("projection-membership", ProjectionMembership.class); - xstream.alias("membership", ProjectionMembership.class); - xstream.useAttributeFor(ProjectionMembership.class, "postingUUID"); - xstream.useAttributeFor(ProjectionMembership.class, "role"); - xstream.alias("projection-membership-role", ProjectionMembershipRole.class); xstream.alias("ledger-parameter", LedgerParameter.class); xstream.alias("ledger-parameter-type", LedgerParameterType.class); xstream.alias("ledger-entry-type", LedgerEntryType.class); diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/InvestmentPlan.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/InvestmentPlan.java index 04b696998f..ffac5fd436 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/InvestmentPlan.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/InvestmentPlan.java @@ -428,8 +428,8 @@ public LedgerExecutionRef(String ledgerEntryUUID, String projectionUUID, LedgerP public static LedgerExecutionRef of(LedgerBackedTransaction transaction) { return new LedgerExecutionRef(transaction.getLedgerEntry().getUUID(), - transaction.getLedgerProjectionRef().getUUID(), - transaction.getLedgerProjectionRef().getRole()); + transaction.getRuntimeProjectionId(), + transaction.getLedgerProjectionRole()); } public String getLedgerEntryUUID() @@ -471,11 +471,9 @@ private boolean matches(Transaction transaction) return ledgerEntryUUID.equals(ledgerBackedTransaction.getLedgerEntry().getUUID()) && (projectionUUID == null - || projectionUUID.equals( - ledgerBackedTransaction.getLedgerProjectionRef().getUUID())) + || projectionUUID.equals(ledgerBackedTransaction.getRuntimeProjectionId())) && (projectionRole == null - || projectionRole == ledgerBackedTransaction.getLedgerProjectionRef() - .getRole()); + || projectionRole == ledgerBackedTransaction.getLedgerProjectionRole()); } } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerBuySellDeliveryConverter.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerBuySellDeliveryConverter.java index c5af56ff60..2ab91b3b17 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerBuySellDeliveryConverter.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerBuySellDeliveryConverter.java @@ -56,7 +56,7 @@ public boolean canConvertSafely(TransactionPair transactio : LedgerProjectionRole.DELIVERY_OUTBOUND; return LedgerPlanReferenceSupport.refsFollowRoleChanges(client, entry, - LedgerPlanReferenceSupport.roleChange(ledgerTransaction.getLedgerProjectionRef().getUUID(), + LedgerPlanReferenceSupport.roleChange(ledgerTransaction.getRuntimeProjectionId(), LedgerProjectionRole.PORTFOLIO, targetRole)); } @@ -69,8 +69,8 @@ public boolean canConvertDeliveryToBuySellSafely(TransactionPair transactio || !(transaction.getTransaction() instanceof LedgerBackedPortfolioTransaction ledgerTransaction)) return false; - var sourceRole = ledgerTransaction.getLedgerProjectionRef().getRole(); + var sourceRole = ledgerTransaction.getLedgerProjectionRole(); var targetRole = transaction.getTransaction().getType() == PortfolioTransaction.Type.DELIVERY_INBOUND ? LedgerProjectionRole.DELIVERY_OUTBOUND : LedgerProjectionRole.DELIVERY_INBOUND; return LedgerPlanReferenceSupport.refsFollowRoleChanges(client, ledgerTransaction.getLedgerEntry(), - LedgerPlanReferenceSupport.roleChange(ledgerTransaction.getLedgerProjectionRef().getUUID(), + LedgerPlanReferenceSupport.roleChange(ledgerTransaction.getRuntimeProjectionId(), sourceRole, targetRole)); } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerModelLoadSupport.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerModelLoadSupport.java index 062fb873af..ba27b62914 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerModelLoadSupport.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerModelLoadSupport.java @@ -10,9 +10,6 @@ import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerParameter; import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; -import name.abuchen.portfolio.model.ledger.ProjectionMembershipRole; import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; @@ -87,11 +84,6 @@ static void addPosting(LedgerEntry entry, LedgerPosting posting) Objects.requireNonNull(entry).addPosting(posting); } - static void addProjectionRef(LedgerEntry entry, LedgerProjectionRef projectionRef) - { - Objects.requireNonNull(entry).addProjectionRef(projectionRef); - } - static LedgerPosting newPosting(String uuid, LedgerPostingType type) { var posting = new LedgerPosting(uuid); @@ -151,38 +143,4 @@ static void addPostingParameter(LedgerPosting posting, LedgerParameter parame Objects.requireNonNull(posting).addParameter(parameter); } - static LedgerProjectionRef newProjectionRef(String uuid, LedgerProjectionRole role) - { - var projectionRef = new LedgerProjectionRef(uuid); - - projectionRef.setRole(Objects.requireNonNull(role)); - - return projectionRef; - } - - static void setProjectionRefAccount(LedgerProjectionRef projectionRef, Account account) - { - Objects.requireNonNull(projectionRef).setAccount(account); - } - - static void setProjectionRefPortfolio(LedgerProjectionRef projectionRef, Portfolio portfolio) - { - Objects.requireNonNull(projectionRef).setPortfolio(portfolio); - } - - static void setProjectionRefPrimaryPostingUUID(LedgerProjectionRef projectionRef, String primaryPostingUUID) - { - Objects.requireNonNull(projectionRef).setPrimaryPostingUUID(primaryPostingUUID); - } - - static void setProjectionRefPostingGroupUUID(LedgerProjectionRef projectionRef, String postingGroupUUID) - { - Objects.requireNonNull(projectionRef).setPostingGroupUUID(postingGroupUUID); - } - - static void addProjectionRefMembership(LedgerProjectionRef projectionRef, String postingUUID, - ProjectionMembershipRole role) - { - Objects.requireNonNull(projectionRef).addMembership(postingUUID, role); - } } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerPlanReferenceSupport.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerPlanReferenceSupport.java index 890a5cabba..916fc2119f 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerPlanReferenceSupport.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerPlanReferenceSupport.java @@ -3,7 +3,6 @@ import java.util.Objects; import name.abuchen.portfolio.model.ledger.LedgerEntry; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; /** @@ -17,10 +16,10 @@ private LedgerPlanReferenceSupport() { } - static RoleChange roleChange(String projectionUUID, LedgerProjectionRole sourceRole, + static RoleChange roleChange(String runtimeProjectionId, LedgerProjectionRole sourceRole, LedgerProjectionRole targetRole) { - return new RoleChange(Objects.requireNonNull(projectionUUID), Objects.requireNonNull(sourceRole), + return new RoleChange(Objects.requireNonNull(runtimeProjectionId), Objects.requireNonNull(sourceRole), Objects.requireNonNull(targetRole)); } @@ -34,16 +33,12 @@ static boolean refsFollowRoleChanges(Client client, LedgerEntry entry, RoleChang return true; } - static String projectionUUID(LedgerEntry entry, LedgerProjectionRole role) + static String runtimeProjectionId(LedgerEntry entry, LedgerProjectionRole role) { - return entry.getProjectionRefs().stream() // - .filter(projection -> projection.getRole() == role) // - .map(LedgerProjectionRef::getUUID) // - .findFirst() // - .orElse(""); //$NON-NLS-1$ + return entry.getUUID() + ":" + role; //$NON-NLS-1$ } - record RoleChange(String projectionUUID, LedgerProjectionRole sourceRole, LedgerProjectionRole targetRole) + record RoleChange(String runtimeProjectionId, LedgerProjectionRole sourceRole, LedgerProjectionRole targetRole) { } } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerTransferDirectionConverter.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerTransferDirectionConverter.java index 10ba4d3b4f..617fd1f27e 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerTransferDirectionConverter.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerTransferDirectionConverter.java @@ -52,11 +52,11 @@ public boolean canReverseSafely(AccountTransferEntry transfer) var entry = sourceTransaction.getLedgerEntry(); return LedgerPlanReferenceSupport.refsFollowRoleChanges(client, entry, LedgerPlanReferenceSupport.roleChange( - LedgerPlanReferenceSupport.projectionUUID(entry, + LedgerPlanReferenceSupport.runtimeProjectionId(entry, LedgerProjectionRole.SOURCE_ACCOUNT), LedgerProjectionRole.SOURCE_ACCOUNT, LedgerProjectionRole.TARGET_ACCOUNT), LedgerPlanReferenceSupport.roleChange( - LedgerPlanReferenceSupport.projectionUUID(entry, + LedgerPlanReferenceSupport.runtimeProjectionId(entry, LedgerProjectionRole.TARGET_ACCOUNT), LedgerProjectionRole.TARGET_ACCOUNT, LedgerProjectionRole.SOURCE_ACCOUNT)); } @@ -73,11 +73,11 @@ public boolean canReverseSafely(PortfolioTransferEntry transfer) var entry = sourceTransaction.getLedgerEntry(); return LedgerPlanReferenceSupport.refsFollowRoleChanges(client, entry, LedgerPlanReferenceSupport.roleChange( - LedgerPlanReferenceSupport.projectionUUID(entry, + LedgerPlanReferenceSupport.runtimeProjectionId(entry, LedgerProjectionRole.SOURCE_PORTFOLIO), LedgerProjectionRole.SOURCE_PORTFOLIO, LedgerProjectionRole.TARGET_PORTFOLIO), LedgerPlanReferenceSupport.roleChange( - LedgerPlanReferenceSupport.projectionUUID(entry, + LedgerPlanReferenceSupport.runtimeProjectionId(entry, LedgerProjectionRole.TARGET_PORTFOLIO), LedgerProjectionRole.TARGET_PORTFOLIO, LedgerProjectionRole.SOURCE_PORTFOLIO)); } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ProtobufWriter.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ProtobufWriter.java index e217cae3e7..75c8f3d153 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ProtobufWriter.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ProtobufWriter.java @@ -46,7 +46,6 @@ import name.abuchen.portfolio.model.ledger.LedgerPostingDirection; import name.abuchen.portfolio.model.ledger.LedgerPostingSemanticRole; import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; @@ -184,7 +183,6 @@ public Client load(InputStream input) throws IOException if (hasLedgerTruth) { - LedgerProjectionService.adaptLegacyScalarMemberships(client); LedgerProjectionService.restoreIfValid(client); } else @@ -685,10 +683,7 @@ private boolean hasLedgerTruth(PClient newClient) private Set ledgerProjectionUUIDs(Ledger ledger) { - return ledger.getEntries().stream() // - .flatMap(entry -> entry.getProjectionRefs().stream()) // - .map(LedgerProjectionRef::getUUID) // - .collect(Collectors.toCollection(HashSet::new)); + return new HashSet<>(); } private void loadLedger(PLedger newLedger, Client client, Lookup lookup) @@ -1060,18 +1055,6 @@ private void loadInvestmentPlans(PClient newClient, Client client, Lookup lookup private boolean markPlanExecutionForProjectionUUID(Client client, InvestmentPlan plan, String projectionUUID) { - for (LedgerEntry entry : client.getLedger().getEntries()) - { - for (LedgerProjectionRef projectionRef : entry.getProjectionRefs()) - { - if (projectionRef.getUUID().equals(projectionUUID)) - { - plan.markLedgerExecution(entry, entry.getDateTime().toLocalDate(), viewKind(projectionRef.getRole())); - return true; - } - } - } - return false; } @@ -1482,12 +1465,13 @@ private void saveLedgerCompatibilityShadows(Client client, PClient.Builder newCl continue; LedgerBackedTransaction ledgerBackedTransaction = (LedgerBackedTransaction) transaction; - LedgerProjectionRef projectionRef = ledgerBackedTransaction.getLedgerProjectionRef(); if (transaction instanceof AccountTransaction accountTransaction) - addTransaction(newClient, projectionRef.getAccount(), accountTransaction); + addTransaction(newClient, ledgerBackedTransaction.getLedgerProjectionDescriptor().getAccount(), + accountTransaction); else if (transaction instanceof PortfolioTransaction portfolioTransaction) - addTransaction(newClient, projectionRef.getPortfolio(), portfolioTransaction); + addTransaction(newClient, ledgerBackedTransaction.getLedgerProjectionDescriptor().getPortfolio(), + portfolioTransaction); else throw new UnsupportedOperationException(transaction.getClass().getName()); } @@ -1518,10 +1502,8 @@ private String protobufTransactionUUID(Transaction transaction) { if (transaction instanceof LedgerBackedTransaction ledgerBackedTransaction) { - var projectionRef = ledgerBackedTransaction.getLedgerProjectionRef(); - return LEDGER_COMPATIBILITY_SHADOW_PREFIX + ledgerBackedTransaction.getLedgerEntry().getUUID() - + ":" + projectionRef.getRole(); //$NON-NLS-1$ + + ":" + ledgerBackedTransaction.getLedgerProjectionRole(); //$NON-NLS-1$ } return transaction.getUUID(); 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 index 95610f6e59..d24798d58e 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerDiagnosticMessageFormatter.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerDiagnosticMessageFormatter.java @@ -141,9 +141,8 @@ private static Optional findEntry(Ledger ledger, LedgerStructuralVa return entry; } - return ledger.getEntries().stream().filter(entry -> matchesEntry(entry, details.get("postingUUID"), - details.get("projectionUUID"), details.get("primaryPostingUUID"), - details.get("postingGroupUUID"), details.get("membershipPostingUUID"))).findFirst(); + return ledger.getEntries().stream().filter(entry -> matchesEntry(entry, details.get("postingUUID"))) + .findFirst(); } private static boolean matchesEntry(LedgerEntry entry, String... uuids) @@ -153,9 +152,7 @@ private static boolean matchesEntry(LedgerEntry entry, String... uuids) if (uuid == null || uuid.isBlank() || "".equals(uuid)) continue; - if (entry.getPostings().stream().anyMatch(posting -> uuid.equals(posting.getUUID())) - || entry.getProjectionRefs().stream() - .anyMatch(projection -> uuid.equals(projection.getUUID()))) + if (entry.getPostings().stream().anyMatch(posting -> uuid.equals(posting.getUUID()))) return true; } @@ -168,8 +165,6 @@ private static Set accountNames(LedgerEntry entry) for (var posting : entry.getPostings()) add(values, accountSummary(posting.getAccount())); - for (var projection : entry.getProjectionRefs()) - add(values, accountSummary(projection.getAccount())); return values; } @@ -180,8 +175,6 @@ private static Set portfolioNames(LedgerEntry entry) for (var posting : entry.getPostings()) add(values, portfolioSummary(posting.getPortfolio())); - for (var projection : entry.getProjectionRefs()) - add(values, portfolioSummary(projection.getPortfolio())); return values; } 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 index 6bb6c35953..101ca3b0fa 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerEntry.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerEntry.java @@ -30,7 +30,6 @@ public class LedgerEntry private String preferredViewKind; private List> parameters = new ArrayList<>(); private final List postings = new ArrayList<>(); - private final List projectionRefs = new ArrayList<>(); public LedgerEntry() { @@ -202,27 +201,6 @@ public boolean removePosting(LedgerPosting posting) return removed; } - public List getProjectionRefs() - { - return Collections.unmodifiableList(projectionRefs); - } - - public void addProjectionRef(LedgerProjectionRef projectionRef) - { - projectionRefs.add(Objects.requireNonNull(projectionRef)); - touch(); - } - - public boolean removeProjectionRef(LedgerProjectionRef projectionRef) - { - var removed = projectionRefs.remove(projectionRef); - - if (removed) - touch(); - - return removed; - } - private void touch() { this.updatedAt = Instant.now(); 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 index a1e3931d38..b8ba3a70d4 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerGraphWriter.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerGraphWriter.java @@ -48,12 +48,8 @@ static void replaceEntryContents(LedgerEntry target, LedgerEntry source) for (var posting : List.copyOf(target.getPostings())) target.removePosting(posting); - for (var projectionRef : List.copyOf(target.getProjectionRefs())) - target.removeProjectionRef(projectionRef); - copy.getParameters().forEach(target::addParameter); copy.getPostings().forEach(target::addPosting); - copy.getProjectionRefs().forEach(target::addProjectionRef); 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 index b41a76fbce..506cbfcb82 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerModelCopy.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerModelCopy.java @@ -33,7 +33,6 @@ static LedgerEntry copyEntry(LedgerEntry source) source.getParameters().stream().map(LedgerModelCopy::copyParameter).forEach(copy::addParameter); source.getPostings().stream().map(LedgerModelCopy::copyPosting).forEach(copy::addPosting); - source.getProjectionRefs().stream().map(LedgerModelCopy::copyProjectionRef).forEach(copy::addProjectionRef); copy.setUpdatedAt(source.getUpdatedAt()); return copy; @@ -64,26 +63,6 @@ static LedgerPosting copyPosting(LedgerPosting source) return copy; } - static LedgerProjectionRef copyProjectionRef(LedgerProjectionRef source) - { - var copy = new LedgerProjectionRef(Objects.requireNonNull(source).getUUID()); - - copy.setRole(source.getRole()); - copy.setAccount(source.getAccount()); - copy.setPortfolio(source.getPortfolio()); - copy.setPrimaryPostingUUID(source.getPrimaryPostingUUID()); - copy.setPostingGroupUUID(source.getPostingGroupUUID()); - source.getMemberships().stream().map(LedgerModelCopy::copyProjectionMembership).forEach(copy::addMembership); - - return copy; - } - - static ProjectionMembership copyProjectionMembership(ProjectionMembership source) - { - Objects.requireNonNull(source); - return new ProjectionMembership(source.getPostingUUID(), source.getRole()); - } - static LedgerParameter copyParameter(LedgerParameter source) { Objects.requireNonNull(source); diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerMutationContext.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerMutationContext.java index 6750c86665..6ccbd23aa4 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerMutationContext.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerMutationContext.java @@ -157,41 +157,13 @@ private LedgerEntry prepareSameShapeReplacement(LedgerEntry currentEntry, Ledger throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CORE_010 .message("Entry replacement must keep the same LedgerEntryType")); //$NON-NLS-1$ - var currentByRole = projectionsByUniqueRole(currentEntry); - var replacementByRole = projectionsByUniqueRole(replacement); - - if (!currentByRole.keySet().equals(replacementByRole.keySet())) - throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_002 - .message("Entry replacement projection roles do not match")); //$NON-NLS-1$ - var prepared = LedgerModelCopy.copyEntry(replacement); prepared.setUUID(currentEntry.getUUID()); - for (var projection : prepared.getProjectionRefs()) - { - var currentProjection = currentByRole.get(projection.getRole()); - - projection.setUUID(currentProjection.getUUID()); - } - return prepared; } - private java.util.Map projectionsByUniqueRole(LedgerEntry entry) - { - var result = new java.util.EnumMap(LedgerProjectionRole.class); - - for (var projection : entry.getProjectionRefs()) - { - if (result.put(projection.getRole(), projection) != null) - throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_003 - .message("Projection role is ambiguous: " + projection.getRole())); //$NON-NLS-1$ - } - - return result; - } - private void replaceEntry(Ledger ledger, String currentUUID, LedgerEntry replacement) { var current = entryByUUID(ledger, currentUUID); @@ -246,8 +218,8 @@ private Set projectionUUIDs(Ledger ledger, Set affectedEntryUUID { return ledger.getEntries().stream() // .filter(entry -> affectedEntryUUIDs.contains(entry.getUUID())) // - .flatMap(entry -> entry.getProjectionRefs().stream()) // - .map(LedgerProjectionRef::getUUID) // + .flatMap(entry -> LedgerProjectionService.createProjections(entry).stream()) // + .map(name.abuchen.portfolio.model.Transaction::getUUID) // .collect(Collectors.toCollection(HashSet::new)); } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerProjectionRef.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerProjectionRef.java deleted file mode 100644 index 62b2b73315..0000000000 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerProjectionRef.java +++ /dev/null @@ -1,179 +0,0 @@ -package name.abuchen.portfolio.model.ledger; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Objects; -import java.util.Optional; -import java.util.UUID; - -import name.abuchen.portfolio.model.Account; -import name.abuchen.portfolio.model.Portfolio; - -/** - * Links a persisted Ledger entry to a runtime legacy transaction projection. - * This is internal Ledger model data. Projection references identify views; they are not a - * second persisted transaction truth. - */ -public class LedgerProjectionRef -{ - private String uuid; - private LedgerProjectionRole role; - private Account account; - private Portfolio portfolio; - private String primaryPostingUUID; - private String postingGroupUUID; - private List memberships = new ArrayList<>(); - - public LedgerProjectionRef() - { - this(UUID.randomUUID().toString()); - } - - public LedgerProjectionRef(String uuid) - { - this.uuid = Objects.requireNonNull(uuid); - } - - public String getUUID() - { - return uuid; - } - - public void setUUID(String uuid) - { - this.uuid = Objects.requireNonNull(uuid); - } - - public LedgerProjectionRole getRole() - { - return role; - } - - public void setRole(LedgerProjectionRole role) - { - this.role = role; - } - - 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 String getPrimaryPostingUUID() - { - return primaryPostingUUID; - } - - public void setPrimaryPostingUUID(String primaryPostingUUID) - { - this.primaryPostingUUID = primaryPostingUUID; - } - - public void setPrimaryPostingTargetUUID(String primaryPostingUUID) - { - setPrimaryPostingUUID(primaryPostingUUID); - setMembership(ProjectionMembershipRole.PRIMARY, primaryPostingUUID); - } - - public void setPrimaryPosting(LedgerPosting posting) - { - setPrimaryPostingTargetUUID(Objects.requireNonNull(posting).getUUID()); - } - - public String getPostingGroupUUID() - { - return postingGroupUUID; - } - - public void setPostingGroupUUID(String postingGroupUUID) - { - this.postingGroupUUID = postingGroupUUID; - } - - public void setPostingGroupTargetUUID(String postingGroupUUID) - { - setPostingGroupUUID(postingGroupUUID); - setMembership(ProjectionMembershipRole.GROUP_ANCHOR, postingGroupUUID); - } - - public void setPostingGroup(LedgerPosting posting) - { - setPostingGroupTargetUUID(Objects.requireNonNull(posting).getUUID()); - } - - public List getMemberships() - { - return Collections.unmodifiableList(memberships()); - } - - public void addMembership(ProjectionMembership membership) - { - memberships().add(Objects.requireNonNull(membership)); - } - - public ProjectionMembership addMembership(String postingUUID, ProjectionMembershipRole role) - { - var membership = new ProjectionMembership(postingUUID, role); - - addMembership(membership); - - return membership; - } - - public Optional getPrimaryMembership() - { - return memberships().stream().filter(membership -> membership.getRole() == ProjectionMembershipRole.PRIMARY) - .findFirst(); - } - - public List getMembershipsByRole(ProjectionMembershipRole role) - { - Objects.requireNonNull(role); - return memberships().stream().filter(membership -> membership.getRole() == role).toList(); - } - - public boolean hasMembershipRole(ProjectionMembershipRole role) - { - Objects.requireNonNull(role); - return memberships().stream().anyMatch(membership -> membership.getRole() == role); - } - - public void removeMembershipsForPostingUUID(String postingUUID) - { - memberships().removeIf(membership -> Objects.equals(membership.getPostingUUID(), postingUUID)); - } - - private List memberships() - { - if (memberships == null) - memberships = new ArrayList<>(); - - return memberships; - } - - private void setMembership(ProjectionMembershipRole role, String postingUUID) - { - Objects.requireNonNull(role); - - memberships().removeIf(membership -> membership.getRole() == role); - - if (postingUUID != null) - addMembership(postingUUID, role); - } -} 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 index 47baac7e8f..9b2f8a44bd 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerStructuralValidator.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerStructuralValidator.java @@ -3,8 +3,6 @@ import java.text.MessageFormat; import java.util.ArrayList; import java.util.Collections; -import java.util.EnumMap; -import java.util.EnumSet; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; @@ -90,8 +88,6 @@ private static void validateEntries(Ledger ledger, List issues) { var entryUUIDCounts = new LinkedHashMap(); var postingUUIDCounts = new LinkedHashMap(); - var projectionRefUUIDCounts = new LinkedHashMap(); - for (var entry : ledger.getEntries()) { if (isBlank(entry.getUUID())) @@ -131,8 +127,7 @@ private static void validateEntries(Ledger ledger, List issues) validateParameters(entry, null, entry.getParameters(), issues); - var entryPostingUUIDs = validatePostings(entry, postingUUIDCounts, issues); - validateProjectionRefs(entry, entryPostingUUIDs, projectionRefUUIDCounts, issues); + validatePostings(entry, postingUUIDCounts, issues); } } @@ -231,248 +226,6 @@ private static void validatePostingShape(LedgerEntry entry, LedgerPosting postin entry, posting)); } - private static void validateProjectionRefs(LedgerEntry entry, Set entryPostingUUIDs, - Map ledgerProjectionRefUUIDCounts, List issues) - { - for (var projectionRef : entry.getProjectionRefs()) - { - if (isBlank(projectionRef.getUUID())) - issues.add(projectionIssue(IssueCode.PROJECTION_REF_UUID_REQUIRED, - LedgerDiagnosticCode.LEDGER_STRUCT_016.message(MessageFormat.format( - Messages.LedgerStructuralValidatorProjectionUuidRequired, - entry.getUUID())), - entry, - projectionRef)); - else - { - var occurrenceCount = ledgerProjectionRefUUIDCounts.merge(projectionRef.getUUID(), 1, Integer::sum); - if (occurrenceCount > 1) - issues.add(projectionIssue(IssueCode.DUPLICATE_PROJECTION_REF_UUID, - LedgerDiagnosticCode.LEDGER_STRUCT_017.message(MessageFormat.format( - Messages.LedgerStructuralValidatorDuplicateProjectionUuid, - projectionRef.getUUID())), - entry, - projectionRef).withDetail("objectType", "LedgerProjectionRef") //$NON-NLS-1$ //$NON-NLS-2$ - .withDetail("duplicateUUID", projectionRef.getUUID()) //$NON-NLS-1$ - .withDetail("occurrenceCount", occurrenceCount)); //$NON-NLS-1$ - } - - if (projectionRef.getRole() == null) - { - issues.add(projectionIssue(IssueCode.PROJECTION_REF_ROLE_REQUIRED, - LedgerDiagnosticCode.LEDGER_STRUCT_018.message(MessageFormat.format( - Messages.LedgerStructuralValidatorProjectionRoleRequired, - projectionRef.getUUID())), - entry, - projectionRef)); - continue; - } - - validateProjectionOwner(entry, projectionRef, issues); - validateProjectionMemberships(entry, projectionRef, entryPostingUUIDs, issues); - validatePrimaryPostingRef(entry, projectionRef, entryPostingUUIDs, issues); - validateTargeting(entry, projectionRef, entryPostingUUIDs, issues); - } - - validateFixedShapeProjectionRoles(entry, issues); - } - - private static void validateFixedShapeProjectionRoles(LedgerEntry entry, List issues) - { - if (entry.getType() == null || !entry.getType().isLegacyFixedShape()) - return; - - if (entry.getProjectionRefs().isEmpty()) - return; - - var expectedRoles = expectedProjectionRoles(entry.getType()); - var roleCounts = new EnumMap(LedgerProjectionRole.class); - - for (var projectionRef : entry.getProjectionRefs()) - { - var role = projectionRef.getRole(); - - if (role == null) - continue; - - roleCounts.merge(role, 1, Integer::sum); - if (!expectedRoles.contains(role)) - issues.add(projectionIssue(IssueCode.FIXED_SHAPE_PROJECTION_ROLE_NOT_ALLOWED, - LedgerDiagnosticCode.LEDGER_STRUCT_019.message(MessageFormat.format( - Messages.LedgerStructuralValidatorProjectionRoleNotAllowed, - role, entry.getType())), - entry, - projectionRef)); - } - - for (var expectedRole : expectedRoles) - { - if (roleCounts.getOrDefault(expectedRole, 0) != 1) - issues.add(entryIssue(IssueCode.FIXED_SHAPE_PROJECTION_ROLE_REQUIRED, - LedgerDiagnosticCode.LEDGER_STRUCT_020.message(MessageFormat.format( - Messages.LedgerStructuralValidatorProjectionRoleRequiredForType, - entry.getType(), expectedRole)), - entry).withDetail("expectedProjectionRole", expectedRole)); //$NON-NLS-1$ - } - } - - private static Set expectedProjectionRoles(LedgerEntryType entryType) - { - return switch (entryType) - { - case DEPOSIT, REMOVAL, INTEREST, INTEREST_CHARGE, FEES, FEES_REFUND, TAXES, TAX_REFUND, DIVIDENDS -> - EnumSet.of(LedgerProjectionRole.ACCOUNT); - case BUY, SELL -> EnumSet.of(LedgerProjectionRole.ACCOUNT, LedgerProjectionRole.PORTFOLIO); - case CASH_TRANSFER -> EnumSet.of(LedgerProjectionRole.SOURCE_ACCOUNT, LedgerProjectionRole.TARGET_ACCOUNT); - case SECURITY_TRANSFER -> - EnumSet.of(LedgerProjectionRole.SOURCE_PORTFOLIO, LedgerProjectionRole.TARGET_PORTFOLIO); - case DELIVERY_INBOUND -> EnumSet.of(LedgerProjectionRole.DELIVERY_INBOUND); - case DELIVERY_OUTBOUND -> EnumSet.of(LedgerProjectionRole.DELIVERY_OUTBOUND); - default -> EnumSet.noneOf(LedgerProjectionRole.class); - }; - } - - private static void validateProjectionOwner(LedgerEntry entry, LedgerProjectionRef projectionRef, - List issues) - { - if (requiresAccount(projectionRef.getRole())) - { - if (projectionRef.getAccount() == null) - issues.add(projectionIssue(IssueCode.PROJECTION_REF_ACCOUNT_REQUIRED, - LedgerDiagnosticCode.LEDGER_STRUCT_021 - .message(MessageFormat.format( - Messages.LedgerStructuralValidatorProjectionAccountRequired, - projectionRef.getRole())), - entry, - projectionRef)); - - if (projectionRef.getPortfolio() != null) - issues.add(projectionIssue(IssueCode.PROJECTION_REF_PORTFOLIO_NOT_ALLOWED, - LedgerDiagnosticCode.LEDGER_STRUCT_022.message(MessageFormat.format( - Messages.LedgerStructuralValidatorAccountProjectionPortfolioNotAllowed, - projectionRef.getRole())), - entry, projectionRef)); - } - - if (requiresPortfolio(projectionRef.getRole())) - { - if (projectionRef.getPortfolio() == null) - issues.add(projectionIssue(IssueCode.PROJECTION_REF_PORTFOLIO_REQUIRED, - LedgerDiagnosticCode.LEDGER_STRUCT_023 - .message(MessageFormat.format( - Messages.LedgerStructuralValidatorProjectionPortfolioRequired, - projectionRef.getRole())), - entry, - projectionRef)); - - if (projectionRef.getAccount() != null) - issues.add(projectionIssue(IssueCode.PROJECTION_REF_ACCOUNT_NOT_ALLOWED, - LedgerDiagnosticCode.LEDGER_STRUCT_024.message(MessageFormat.format( - Messages.LedgerStructuralValidatorPortfolioProjectionAccountNotAllowed, - projectionRef.getRole())), - entry, projectionRef)); - } - } - - private static void validatePrimaryPostingRef(LedgerEntry entry, LedgerProjectionRef projectionRef, - Set entryPostingUUIDs, List issues) - { - if (!isBlank(projectionRef.getPrimaryPostingUUID()) - && !entryPostingUUIDs.contains(projectionRef.getPrimaryPostingUUID())) - issues.add(projectionIssue(IssueCode.PRIMARY_POSTING_REF_NOT_FOUND, - LedgerDiagnosticCode.LEDGER_STRUCT_002 - .message(MessageFormat.format( - Messages.LedgerStructuralValidatorPrimaryPostingRefNotFound, - projectionRef.getPrimaryPostingUUID())), - entry, projectionRef)); - } - - private static void validateProjectionMemberships(LedgerEntry entry, LedgerProjectionRef projectionRef, - Set entryPostingUUIDs, List issues) - { - for (var membership : projectionRef.getMemberships()) - { - var postingUUID = membership.getPostingUUID(); - - if (!entryPostingUUIDs.contains(postingUUID)) - issues.add(projectionIssue(IssueCode.PROJECTION_MEMBERSHIP_REF_NOT_FOUND, - LedgerDiagnosticCode.LEDGER_STRUCT_003 - .message(MessageFormat.format( - Messages.LedgerStructuralValidatorProjectionMembershipRefNotFound, - postingUUID)), - entry, projectionRef).withDetail("membershipRole", membership.getRole()) //$NON-NLS-1$ - .withDetail("membershipPostingUUID", postingUUID)); //$NON-NLS-1$ - } - - projectionRef.getPrimaryMembership().ifPresent(membership -> { - var primaryPostingUUID = projectionRef.getPrimaryPostingUUID(); - - if (!isBlank(primaryPostingUUID) && !primaryPostingUUID.equals(membership.getPostingUUID())) - issues.add(projectionIssue(IssueCode.PROJECTION_PRIMARY_TARGET_CONFLICT, - LedgerDiagnosticCode.LEDGER_STRUCT_025.message( - Messages.LedgerStructuralValidatorProjectionPrimaryTargetConflict), - entry, projectionRef).withDetail("membershipRole", membership.getRole()) //$NON-NLS-1$ - .withDetail("membershipPostingUUID", membership.getPostingUUID())); //$NON-NLS-1$ - }); - - projectionRef.getMembershipsByRole(ProjectionMembershipRole.GROUP_ANCHOR).stream().findFirst() - .ifPresent(membership -> { - var postingGroupUUID = projectionRef.getPostingGroupUUID(); - - if (!isBlank(postingGroupUUID) && !postingGroupUUID.equals(membership.getPostingUUID())) - issues.add(projectionIssue(IssueCode.PROJECTION_GROUP_TARGET_CONFLICT, - LedgerDiagnosticCode.LEDGER_STRUCT_026.message( - Messages.LedgerStructuralValidatorProjectionGroupTargetConflict), - entry, projectionRef).withDetail("membershipRole", membership.getRole()) //$NON-NLS-1$ - .withDetail("membershipPostingUUID", //$NON-NLS-1$ - membership.getPostingUUID())); - }); - } - - private static boolean requiresAccount(LedgerProjectionRole role) - { - return switch (role) - { - case ACCOUNT, SOURCE_ACCOUNT, TARGET_ACCOUNT, CASH_COMPENSATION -> true; - default -> false; - }; - } - - private static boolean requiresPortfolio(LedgerProjectionRole role) - { - return switch (role) - { - case PORTFOLIO, SOURCE_PORTFOLIO, TARGET_PORTFOLIO, DELIVERY, DELIVERY_INBOUND, DELIVERY_OUTBOUND, - OLD_SECURITY_LEG, NEW_SECURITY_LEG -> true; - default -> false; - }; - } - - private static void validateTargeting(LedgerEntry entry, LedgerProjectionRef projectionRef, - Set entryPostingUUIDs, List issues) - { - if (entry.getType() == null || !entry.getType().requiresTargetedProjectionRefs()) - return; - - if (projectionRef.getPrimaryMembership().isEmpty() && isBlank(projectionRef.getPrimaryPostingUUID())) - { - issues.add(projectionIssue(IssueCode.TARGETING_REF_REQUIRED, - LedgerDiagnosticCode.LEDGER_STRUCT_027 - .message(Messages.LedgerStructuralValidatorTargetingRefRequired), - entry, projectionRef)); - return; - } - - if (!isBlank(projectionRef.getPostingGroupUUID()) - && !entryPostingUUIDs.contains(projectionRef.getPostingGroupUUID())) - issues.add(projectionIssue(IssueCode.POSTING_GROUP_REF_NOT_FOUND, - LedgerDiagnosticCode.LEDGER_STRUCT_004 - .message(MessageFormat.format( - Messages.LedgerStructuralValidatorPostingGroupRefNotFound, - projectionRef.getPostingGroupUUID())), - entry, projectionRef)); - } - private static void validateParameters(LedgerEntry entry, LedgerPosting posting, List> parameters, List issues) { @@ -577,12 +330,6 @@ private static ValidationIssue postingIssue(IssueCode code, String message, Ledg return entryIssue(code, message, entry).withPosting(posting); } - private static ValidationIssue projectionIssue(IssueCode code, String message, LedgerEntry entry, - LedgerProjectionRef projectionRef) - { - return entryIssue(code, message, entry).withProjection(projectionRef); - } - private static ValidationIssue parameterIssue(IssueCode code, String message, LedgerEntry entry, LedgerPosting posting, LedgerParameter parameter) { @@ -755,16 +502,6 @@ public String format() 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, "Projection", //$NON-NLS-1$ - detail("UUID", "projectionUUID"), //$NON-NLS-1$ //$NON-NLS-2$ - detail("Role", "projectionRole"), //$NON-NLS-1$ //$NON-NLS-2$ - detail("ExpectedRole", "expectedProjectionRole"), //$NON-NLS-1$ //$NON-NLS-2$ - detail("Account", "projectionAccount"), //$NON-NLS-1$ //$NON-NLS-2$ - detail("Portfolio", "projectionPortfolio"), //$NON-NLS-1$ //$NON-NLS-2$ - detail("PrimaryPostingUUID", "primaryPostingUUID"), //$NON-NLS-1$ //$NON-NLS-2$ - detail("PostingGroupUUID", "postingGroupUUID"), //$NON-NLS-1$ //$NON-NLS-2$ - detail("MembershipRole", "membershipRole"), //$NON-NLS-1$ //$NON-NLS-2$ - detail("MembershipPostingUUID", "membershipPostingUUID")); //$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$ @@ -813,19 +550,6 @@ private ValidationIssue withPosting(LedgerPosting posting) .withDetail("postingPortfolio", ownerSummary(posting.getPortfolio())); //$NON-NLS-1$ } - private ValidationIssue withProjection(LedgerProjectionRef projectionRef) - { - if (projectionRef == null) - return this; - - return withDetail("projectionUUID", projectionRef.getUUID()) //$NON-NLS-1$ - .withDetail("projectionRole", projectionRef.getRole()) //$NON-NLS-1$ - .withDetail("projectionAccount", ownerSummary(projectionRef.getAccount())) //$NON-NLS-1$ - .withDetail("projectionPortfolio", ownerSummary(projectionRef.getPortfolio())) //$NON-NLS-1$ - .withDetail("primaryPostingUUID", projectionRef.getPrimaryPostingUUID()) //$NON-NLS-1$ - .withDetail("postingGroupUUID", projectionRef.getPostingGroupUUID()); //$NON-NLS-1$ - } - private ValidationIssue withParameter(LedgerParameter parameter) { if (parameter == null) diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/ProjectionMembership.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/ProjectionMembership.java deleted file mode 100644 index dbeeb96f68..0000000000 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/ProjectionMembership.java +++ /dev/null @@ -1,38 +0,0 @@ -package name.abuchen.portfolio.model.ledger; - -import java.util.Objects; - -/** - * Links one Ledger posting to a projection with an explicit projection-local role. - */ -public class ProjectionMembership -{ - private String postingUUID; - private ProjectionMembershipRole role; - - public ProjectionMembership(String postingUUID, ProjectionMembershipRole role) - { - this.postingUUID = Objects.requireNonNull(postingUUID); - this.role = Objects.requireNonNull(role); - } - - public String getPostingUUID() - { - return postingUUID; - } - - public void setPostingUUID(String postingUUID) - { - this.postingUUID = Objects.requireNonNull(postingUUID); - } - - public ProjectionMembershipRole getRole() - { - return role; - } - - public void setRole(ProjectionMembershipRole role) - { - this.role = Objects.requireNonNull(role); - } -} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/ProjectionMembershipRole.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/ProjectionMembershipRole.java deleted file mode 100644 index b1331d508d..0000000000 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/ProjectionMembershipRole.java +++ /dev/null @@ -1,16 +0,0 @@ -package name.abuchen.portfolio.model.ledger; - -/** - * Defines how a posting participates in a compatibility projection. - * Projection memberships are owned by {@link LedgerProjectionRef}; they do not move - * compatibility identity or owner routing into {@link LedgerPosting}. - */ -public enum ProjectionMembershipRole -{ - PRIMARY, - GROUP_ANCHOR, - FEE_UNIT, - TAX_UNIT, - GROSS_VALUE_UNIT, - FOREX_CONTEXT -} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountOnlyTransactionCreator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountOnlyTransactionCreator.java index 8a25f1711f..6e5e4ee5b4 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountOnlyTransactionCreator.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountOnlyTransactionCreator.java @@ -157,9 +157,9 @@ public AccountTransaction update(AccountTransaction transaction, Account account var edit = editBuilder.build(); var editor = new LedgerAccountTransactionEditor(); - if (ledgerTransaction.getLedgerProjectionRef().getAccount() != account) + if (ledgerTransaction.getLedgerProjectionDescriptor().getAccount() != account) { - var projectionUUID = ledgerTransaction.getLedgerProjectionRef().getUUID(); + var projectionUUID = ledgerTransaction.getUUID(); editor.validate(ledgerTransaction, edit); new LedgerOwnerPatchHelper(client).moveAccountOnly(ledgerTransaction, account); @@ -274,7 +274,7 @@ private record NormalizedAccountOnlyFacts(long amount, List un private AccountTransaction materializeAndFind(Account account, LedgerTransactionCreator.CreatedTransaction created) { - var projectionUUID = created.getProjectionRefs().get(0).getUUID(); + var projectionUUID = created.getRuntimeProjectionId(name.abuchen.portfolio.model.ledger.LedgerProjectionRole.ACCOUNT); LedgerProjectionService.materialize(client); diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransactionEditor.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransactionEditor.java index c12ea09ce3..f31ff94b67 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransactionEditor.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransactionEditor.java @@ -40,11 +40,10 @@ public void apply(LedgerBackedAccountTransaction transaction, LedgerAccountTrans throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_007 .message("Unsupported account transaction edit for " + entry.getType())); //$NON-NLS-1$ - var projectionUUID = transaction.getLedgerProjectionRef().getUUID(); - var postingUUID = LedgerProjectionSupport.primaryPosting(entry, transaction.getLedgerProjectionRef()).getUUID(); + var role = transaction.getLedgerProjectionRole(); + var postingUUID = transaction.getLedgerProjectionDescriptor().getPrimaryPosting().getUUID(); - LedgerEntryEditSupport.applyValidated(entry, editedEntry -> applyEdit(editedEntry, edit, projectionUUID, - postingUUID)); + LedgerEntryEditSupport.applyValidated(entry, editedEntry -> applyEdit(editedEntry, edit, role, postingUUID)); } public void validate(LedgerBackedAccountTransaction transaction, LedgerAccountTransactionEdit edit) @@ -58,21 +57,21 @@ public void validate(LedgerBackedAccountTransaction transaction, LedgerAccountTr throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_008 .message("Unsupported account transaction edit for " + entry.getType())); //$NON-NLS-1$ - var projectionUUID = transaction.getLedgerProjectionRef().getUUID(); - var postingUUID = LedgerProjectionSupport.primaryPosting(entry, transaction.getLedgerProjectionRef()).getUUID(); + var role = transaction.getLedgerProjectionRole(); + var postingUUID = transaction.getLedgerProjectionDescriptor().getPrimaryPosting().getUUID(); - LedgerEntryEditSupport.validatePatch(entry, editedEntry -> applyEdit(editedEntry, edit, projectionUUID, - postingUUID)); + LedgerEntryEditSupport.validatePatch(entry, editedEntry -> applyEdit(editedEntry, edit, role, postingUUID)); } - private void applyEdit(LedgerEntry editedEntry, LedgerAccountTransactionEdit edit, String projectionUUID, + private void applyEdit(LedgerEntry editedEntry, LedgerAccountTransactionEdit edit, + name.abuchen.portfolio.model.ledger.LedgerProjectionRole role, String postingUUID) { LedgerEntryMetadataPatchHelper.apply(editedEntry, edit.getMetadata()); edit.getPosting().applyTo(LedgerEntryEditSupport.postingByUUID(editedEntry, postingUUID)); applyExDate(LedgerEntryEditSupport.postingByUUID(editedEntry, postingUUID), edit.getExDate()); unitPostingUpdater.apply(editedEntry, edit.getUnits()); - ensureProjectionExists(editedEntry, projectionUUID); + ensureDescriptorExists(editedEntry, role); } private void applyExDate(LedgerPosting posting, LedgerFieldEdit edit) @@ -89,10 +88,8 @@ private void applyExDate(LedgerPosting posting, LedgerFieldEdit projection.getUUID().equals(projectionUUID))) - throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_004 - .message("Projection was removed by edit: " + projectionUUID)); //$NON-NLS-1$ + LedgerProjectionSupport.descriptor(entry, role); } } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferEditor.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferEditor.java index 5516a1fbbc..61635c3309 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferEditor.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferEditor.java @@ -6,7 +6,6 @@ import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerEntryEditSupport; import name.abuchen.portfolio.model.ledger.LedgerEntryMetadataPatchHelper; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedAccountTransaction; @@ -35,16 +34,15 @@ public void apply(LedgerEntry entry, LedgerAccountTransferEdit edit) throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_009 .message("Unsupported account transfer edit for " + entry.getType())); //$NON-NLS-1$ - var sourceProjection = projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT); - var targetProjection = projection(entry, LedgerProjectionRole.TARGET_ACCOUNT); - var sourcePostingUUID = LedgerProjectionSupport.primaryPosting(entry, sourceProjection).getUUID(); - var targetPostingUUID = LedgerProjectionSupport.primaryPosting(entry, targetProjection).getUUID(); + var sourceProjection = LedgerProjectionSupport.descriptor(entry, LedgerProjectionRole.SOURCE_ACCOUNT); + var targetProjection = LedgerProjectionSupport.descriptor(entry, LedgerProjectionRole.TARGET_ACCOUNT); + var sourcePostingUUID = sourceProjection.getPrimaryPosting().getUUID(); + var targetPostingUUID = targetProjection.getPrimaryPosting().getUUID(); var sourceAccount = sourceProjection.getAccount(); var targetAccount = targetProjection.getAccount(); LedgerEntryEditSupport.applyValidated(entry, editedEntry -> applyEdit(editedEntry, edit, - sourceProjection.getUUID(), sourceAccount, targetProjection.getUUID(), targetAccount, - sourcePostingUUID, targetPostingUUID)); + sourceAccount, targetAccount, sourcePostingUUID, targetPostingUUID)); } public void validate(LedgerEntry entry, LedgerAccountTransferEdit edit) @@ -56,47 +54,34 @@ public void validate(LedgerEntry entry, LedgerAccountTransferEdit edit) throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_010 .message("Unsupported account transfer edit for " + entry.getType())); //$NON-NLS-1$ - var sourceProjection = projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT); - var targetProjection = projection(entry, LedgerProjectionRole.TARGET_ACCOUNT); - var sourcePostingUUID = LedgerProjectionSupport.primaryPosting(entry, sourceProjection).getUUID(); - var targetPostingUUID = LedgerProjectionSupport.primaryPosting(entry, targetProjection).getUUID(); + var sourceProjection = LedgerProjectionSupport.descriptor(entry, LedgerProjectionRole.SOURCE_ACCOUNT); + var targetProjection = LedgerProjectionSupport.descriptor(entry, LedgerProjectionRole.TARGET_ACCOUNT); + var sourcePostingUUID = sourceProjection.getPrimaryPosting().getUUID(); + var targetPostingUUID = targetProjection.getPrimaryPosting().getUUID(); var sourceAccount = sourceProjection.getAccount(); var targetAccount = targetProjection.getAccount(); LedgerEntryEditSupport.validatePatch(entry, editedEntry -> applyEdit(editedEntry, edit, - sourceProjection.getUUID(), sourceAccount, targetProjection.getUUID(), targetAccount, - sourcePostingUUID, targetPostingUUID)); + sourceAccount, targetAccount, sourcePostingUUID, targetPostingUUID)); } - private void applyEdit(LedgerEntry editedEntry, LedgerAccountTransferEdit edit, String sourceProjectionUUID, - name.abuchen.portfolio.model.Account sourceAccount, String targetProjectionUUID, - name.abuchen.portfolio.model.Account targetAccount, String sourcePostingUUID, - String targetPostingUUID) + private void applyEdit(LedgerEntry editedEntry, LedgerAccountTransferEdit edit, + name.abuchen.portfolio.model.Account sourceAccount, + name.abuchen.portfolio.model.Account targetAccount, + String sourcePostingUUID, String targetPostingUUID) { LedgerEntryMetadataPatchHelper.apply(editedEntry, edit.getMetadata()); edit.getSourcePosting().applyTo(LedgerEntryEditSupport.postingByUUID(editedEntry, sourcePostingUUID)); edit.getTargetPosting().applyTo(LedgerEntryEditSupport.postingByUUID(editedEntry, targetPostingUUID)); unitPostingUpdater.apply(editedEntry, edit.getUnits()); - ensureOwners(editedEntry, sourceProjectionUUID, sourceAccount, targetProjectionUUID, targetAccount); + ensureOwners(editedEntry, sourceAccount, targetAccount); } - private LedgerProjectionRef projection(LedgerEntry entry, LedgerProjectionRole role) + private void ensureOwners(LedgerEntry entry, name.abuchen.portfolio.model.Account source, + name.abuchen.portfolio.model.Account target) { - return entry.getProjectionRefs().stream() // - .filter(projection -> projection.getRole() == role) // - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("Projection not found: " + role)); //$NON-NLS-1$ - } - - private void ensureOwners(LedgerEntry entry, String sourceProjectionUUID, name.abuchen.portfolio.model.Account source, - String targetProjectionUUID, name.abuchen.portfolio.model.Account target) - { - var sourceProjection = entry.getProjectionRefs().stream() - .filter(projection -> projection.getUUID().equals(sourceProjectionUUID)).findFirst() - .orElseThrow(); - var targetProjection = entry.getProjectionRefs().stream() - .filter(projection -> projection.getUUID().equals(targetProjectionUUID)).findFirst() - .orElseThrow(); + var sourceProjection = LedgerProjectionSupport.descriptor(entry, LedgerProjectionRole.SOURCE_ACCOUNT); + var targetProjection = LedgerProjectionSupport.descriptor(entry, LedgerProjectionRole.TARGET_ACCOUNT); if (sourceProjection.getAccount() != source || targetProjection.getAccount() != target) throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_005 diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferToDepositRemovalConverter.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferToDepositRemovalConverter.java index fe89afb413..c302083541 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferToDepositRemovalConverter.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferToDepositRemovalConverter.java @@ -13,12 +13,13 @@ import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerMutationContext; import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerPostingDirection; +import name.abuchen.portfolio.model.ledger.LedgerPostingSemanticRole; +import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; -import name.abuchen.portfolio.model.ledger.ProjectionMembership; -import name.abuchen.portfolio.model.ledger.ProjectionMembershipRole; import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedAccountTransaction; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionSupport; @@ -55,26 +56,25 @@ public SplitResult split(AccountTransferEntry transfer) .message("Account transfer projections do not belong to the same ledger entry")); //$NON-NLS-1$ var entry = sourceEntry; - var sourceProjectionUUID = sourceTransaction.getLedgerProjectionRef().getUUID(); - var targetProjectionUUID = targetTransaction.getLedgerProjectionRef().getUUID(); - var sourceAccount = sourceTransaction.getLedgerProjectionRef().getAccount(); - var targetAccount = targetTransaction.getLedgerProjectionRef().getAccount(); + var sourceAccount = sourceTransaction.getLedgerProjectionDescriptor().getAccount(); + var targetAccount = targetTransaction.getLedgerProjectionDescriptor().getAccount(); preflight(entry, sourceTransaction, targetTransaction); - var sourcePosting = LedgerProjectionSupport.primaryPosting(entry, sourceTransaction.getLedgerProjectionRef()); - var targetPosting = LedgerProjectionSupport.primaryPosting(entry, targetTransaction.getLedgerProjectionRef()); - var removal = removalEntry(entry, sourceTransaction.getLedgerProjectionRef(), sourcePosting, targetPosting); - var deposit = depositEntry(entry, targetTransaction.getLedgerProjectionRef(), - targetPosting); + var sourcePosting = sourceTransaction.getLedgerProjectionDescriptor().getPrimaryPosting(); + var targetPosting = targetTransaction.getLedgerProjectionDescriptor().getPrimaryPosting(); + var removal = removalEntry(entry, sourceTransaction.getLedgerProjectionDescriptor(), sourcePosting, targetPosting); + var deposit = depositEntry(entry, targetTransaction.getLedgerProjectionDescriptor(), targetPosting); + var sourceRuntimeProjectionId = runtimeProjectionId(removal, LedgerProjectionRole.ACCOUNT); + var targetRuntimeProjectionId = runtimeProjectionId(deposit, LedgerProjectionRole.ACCOUNT); var executionRefUpdates = LedgerInvestmentPlanRefSupport.prepareAccountTransferSplitExecutionRefUpdates(client, - entry, sourceTransaction.getLedgerProjectionRef(), targetTransaction.getLedgerProjectionRef(), - removal, deposit); + entry, LedgerProjectionRole.SOURCE_ACCOUNT, LedgerProjectionRole.TARGET_ACCOUNT, removal, + deposit); new LedgerMutationContext(client).splitEntry(entry, List.of(removal, deposit)); executionRefUpdates.apply(); - return new SplitResult(find(sourceAccount, sourceProjectionUUID), find(targetAccount, targetProjectionUUID)); + return new SplitResult(find(sourceAccount, sourceRuntimeProjectionId), find(targetAccount, targetRuntimeProjectionId)); } private void preflight(LedgerEntry entry, LedgerBackedAccountTransaction sourceTransaction, @@ -83,8 +83,8 @@ private void preflight(LedgerEntry entry, LedgerBackedAccountTransaction sourceT if (entry.getType() != LedgerEntryType.CASH_TRANSFER) throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_012.message("Ledger entry is not an account transfer")); //$NON-NLS-1$ - var sourceProjection = sourceTransaction.getLedgerProjectionRef(); - var targetProjection = targetTransaction.getLedgerProjectionRef(); + var sourceProjection = sourceTransaction.getLedgerProjectionDescriptor(); + var targetProjection = targetTransaction.getLedgerProjectionDescriptor(); if (sourceProjection.getRole() != LedgerProjectionRole.SOURCE_ACCOUNT || targetProjection.getRole() != LedgerProjectionRole.TARGET_ACCOUNT) @@ -94,12 +94,14 @@ private void preflight(LedgerEntry entry, LedgerBackedAccountTransaction sourceT var sourceProjectionInEntry = uniqueProjection(entry, LedgerProjectionRole.SOURCE_ACCOUNT); var targetProjectionInEntry = uniqueProjection(entry, LedgerProjectionRole.TARGET_ACCOUNT); - if (sourceProjectionInEntry != sourceProjection || targetProjectionInEntry != targetProjection) + if (!sourceProjectionInEntry.getRuntimeProjectionId().equals(sourceProjection.getRuntimeProjectionId()) + || !targetProjectionInEntry.getRuntimeProjectionId() + .equals(targetProjection.getRuntimeProjectionId())) throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_008 .message("Selected projections do not match the ledger entry")); //$NON-NLS-1$ - var sourcePosting = LedgerProjectionSupport.primaryPosting(entry, sourceProjection); - var targetPosting = LedgerProjectionSupport.primaryPosting(entry, targetProjection); + var sourcePosting = sourceProjection.getPrimaryPosting(); + var targetPosting = targetProjection.getPrimaryPosting(); if (sourcePosting == targetPosting) throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_013.message("Account transfer source and target postings are ambiguous")); //$NON-NLS-1$ @@ -133,28 +135,26 @@ private void validateTransferPosting(LedgerPosting posting, Account account) LedgerDiagnosticCode.LEDGER_CONVERT_017.message("Ledger account transfers with posting parameters cannot be split")); //$NON-NLS-1$ } - private LedgerEntry removalEntry(LedgerEntry source, LedgerProjectionRef sourceProjection, + private LedgerEntry removalEntry(LedgerEntry source, DerivedProjectionDescriptor sourceProjection, LedgerPosting sourcePosting, LedgerPosting targetPosting) { var entry = baseEntry(source, source.getUUID(), LedgerEntryType.REMOVAL); var posting = cashPosting(sourcePosting, sourceProjection.getAccount(), targetPosting); - var projection = accountProjection(sourceProjection, posting); + markAccountPrimary(posting); entry.addPosting(posting); - entry.addProjectionRef(projection); return entry; } - private LedgerEntry depositEntry(LedgerEntry source, LedgerProjectionRef targetProjection, + private LedgerEntry depositEntry(LedgerEntry source, DerivedProjectionDescriptor targetProjection, LedgerPosting targetPosting) { var entry = baseEntry(source, null, LedgerEntryType.DEPOSIT); var posting = cashPosting(targetPosting, targetProjection.getAccount(), null); - var projection = accountProjection(targetProjection, posting); + markAccountPrimary(posting); entry.addPosting(posting); - entry.addProjectionRef(projection); return entry; } @@ -204,22 +204,17 @@ else if (!Objects.equals(source.getCurrency(), account.getCurrencyCode())) return posting; } - private LedgerProjectionRef accountProjection(LedgerProjectionRef source, LedgerPosting posting) + private void markAccountPrimary(LedgerPosting posting) { - var projection = new LedgerProjectionRef(source.getUUID()); - - projection.setRole(LedgerProjectionRole.ACCOUNT); - projection.setAccount(source.getAccount()); - projection.setPrimaryPosting(posting); - projection.setPostingGroupTargetUUID(source.getMembershipsByRole(ProjectionMembershipRole.GROUP_ANCHOR).stream() - .findFirst().map(ProjectionMembership::getPostingUUID).orElse(source.getPostingGroupUUID())); - - return projection; + posting.setSemanticRole(LedgerPostingSemanticRole.CASH); + posting.setDirection(LedgerPostingDirection.NEUTRAL); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + posting.setLocalKey(LedgerProjectionRole.ACCOUNT.name()); } - private LedgerProjectionRef uniqueProjection(LedgerEntry entry, LedgerProjectionRole role) + private DerivedProjectionDescriptor uniqueProjection(LedgerEntry entry, LedgerProjectionRole role) { - var projections = entry.getProjectionRefs().stream().filter(projection -> projection.getRole() == role) + var projections = LedgerProjectionSupport.descriptors(entry).stream().filter(projection -> projection.getRole() == role) .toList(); if (projections.size() != 1) @@ -229,6 +224,11 @@ private LedgerProjectionRef uniqueProjection(LedgerEntry entry, LedgerProjection return projections.get(0); } + private String runtimeProjectionId(LedgerEntry entry, LedgerProjectionRole role) + { + return entry.getUUID() + ":" + role; //$NON-NLS-1$ + } + private AccountTransaction find(Account account, String projectionUUID) { return account.getTransactions().stream() // diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferTransactionCreator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferTransactionCreator.java index ea4c7cebac..3dc4f6abc8 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferTransactionCreator.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferTransactionCreator.java @@ -91,7 +91,7 @@ public Optional getSourceExchangeRate(AccountTransferEntry entry) var sourceAccount = entry.getSourceAccount(); var targetAccount = entry.getTargetAccount(); - var projectionRef = ledgerTransaction.getLedgerProjectionRef(); + var projectionRef = ledgerTransaction.getLedgerProjectionDescriptor(); var postings = ledgerTransaction.getLedgerEntry().getPostings().stream() .filter(posting -> posting.getAccount() == projectionRef.getAccount()).toList(); @@ -140,8 +140,8 @@ public AccountTransferEntry update(AccountTransferEntry entry, Account sourceAcc var targetTransaction = (LedgerBackedAccountTransaction) entry.getTargetTransaction(); var ledgerEntry = sourceTransaction.getLedgerEntry(); - var sourceProjectionUUID = sourceTransaction.getLedgerProjectionRef().getUUID(); - var targetProjectionUUID = targetTransaction.getLedgerProjectionRef().getUUID(); + var sourceProjectionUUID = sourceTransaction.getUUID(); + var targetProjectionUUID = targetTransaction.getUUID(); var ownerPatchHelper = new LedgerOwnerPatchHelper(client); var editBuilder = LedgerAccountTransferEdit.builder() @@ -159,14 +159,14 @@ public AccountTransferEntry update(AccountTransferEntry entry, Account sourceAcc var edit = editBuilder.build(); var editor = new LedgerAccountTransferEditor(); - if (sourceTransaction.getLedgerProjectionRef().getAccount() != sourceAccount - || targetTransaction.getLedgerProjectionRef().getAccount() != targetAccount) + if (sourceTransaction.getLedgerProjectionDescriptor().getAccount() != sourceAccount + || targetTransaction.getLedgerProjectionDescriptor().getAccount() != targetAccount) editor.validate(ledgerEntry, edit); - if (sourceTransaction.getLedgerProjectionRef().getAccount() != sourceAccount) + if (sourceTransaction.getLedgerProjectionDescriptor().getAccount() != sourceAccount) ownerPatchHelper.moveAccountTransferSource(ledgerEntry, sourceAccount); - if (targetTransaction.getLedgerProjectionRef().getAccount() != targetAccount) + if (targetTransaction.getLedgerProjectionDescriptor().getAccount() != targetAccount) ownerPatchHelper.moveAccountTransferTarget(ledgerEntry, targetAccount); sourceTransaction = (LedgerBackedAccountTransaction) find(sourceAccount, sourceProjectionUUID); @@ -213,12 +213,8 @@ private LedgerForexAmount forex(Money forexAmount, BigDecimal exchangeRate) private AccountTransferEntry materializeAndWrap(Account sourceAccount, Account targetAccount, LedgerTransactionCreator.CreatedTransaction created) { - var sourceProjectionUUID = created.getProjectionRefs().stream() - .filter(projection -> projection.getRole() == LedgerProjectionRole.SOURCE_ACCOUNT) - .findFirst().orElseThrow().getUUID(); - var targetProjectionUUID = created.getProjectionRefs().stream() - .filter(projection -> projection.getRole() == LedgerProjectionRole.TARGET_ACCOUNT) - .findFirst().orElseThrow().getUUID(); + var sourceProjectionUUID = created.getRuntimeProjectionId(LedgerProjectionRole.SOURCE_ACCOUNT); + var targetProjectionUUID = created.getRuntimeProjectionId(LedgerProjectionRole.TARGET_ACCOUNT); LedgerProjectionService.materialize(client); diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTypeToggleConverter.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTypeToggleConverter.java index cb55316aea..d1d122fef2 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTypeToggleConverter.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTypeToggleConverter.java @@ -10,7 +10,6 @@ import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerMutationContext; import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; @@ -40,11 +39,10 @@ public AccountTransaction toggle(TransactionPair transaction throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_CONVERT_019.message("Only ledger-backed account transactions can be toggled")); //$NON-NLS-1$ var entry = ledgerTransaction.getLedgerEntry(); - var projectionRef = ledgerTransaction.getLedgerProjectionRef(); - var account = projectionRef.getAccount(); - var projectionUUID = projectionRef.getUUID(); + var account = ledgerTransaction.getLedgerProjectionDescriptor().getAccount(); + var projectionUUID = ledgerTransaction.getRuntimeProjectionId(); - preflight(entry, projectionRef, transaction, account); + preflight(entry, transaction, account); LedgerInvestmentPlanRefSupport.requireCurrentRefsResolveUniquely(client, entry); new LedgerMutationContext(client).mutateEntry(entry, this::toggle); @@ -52,25 +50,17 @@ public AccountTransaction toggle(TransactionPair transaction return find(account, projectionUUID); } - private void preflight(LedgerEntry entry, LedgerProjectionRef projectionRef, - TransactionPair transaction, Account account) + private void preflight(LedgerEntry entry, TransactionPair transaction, Account account) { if (targetType(entry.getType()) == null) throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PROJ_011.message("Only ledger-backed deposit/removal and interest entries can be toggled")); //$NON-NLS-1$ - if (projectionRef.getRole() != LedgerProjectionRole.ACCOUNT) - throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PROJ_012.message("Only account projections can be toggled")); //$NON-NLS-1$ - if (transaction.getOwner() != account) throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_013.message("Selected account does not own the ledger projection")); //$NON-NLS-1$ - var projection = requireOneProjection(entry); - var primaryPosting = requirePrimaryPosting(entry, projection); - - if (projection != projectionRef) - throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_014.message("Selected projection is not the unique account projection")); //$NON-NLS-1$ + var primaryPosting = requirePrimaryPosting(entry); - if (primaryPosting.getAccount() != projection.getAccount()) + if (primaryPosting.getAccount() != account) throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_015.message("Account projection and posting account do not match")); //$NON-NLS-1$ } @@ -98,9 +88,9 @@ private LedgerEntryType targetType(LedgerEntryType currentType) }; } - private LedgerPosting requirePrimaryPosting(LedgerEntry entry, LedgerProjectionRef projection) + private LedgerPosting requirePrimaryPosting(LedgerEntry entry) { - var primaryPosting = LedgerProjectionSupport.primaryPosting(entry, projection); + var primaryPosting = LedgerProjectionSupport.descriptor(entry, LedgerProjectionRole.ACCOUNT).getPrimaryPosting(); if (primaryPosting == null) throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_016.message("Account projection primary posting is ambiguous")); //$NON-NLS-1$ @@ -124,17 +114,6 @@ private LedgerPostingType postingType(LedgerEntryType entryType) }; } - private LedgerProjectionRef requireOneProjection(LedgerEntry entry) - { - var projections = entry.getProjectionRefs().stream() - .filter(projection -> projection.getRole() == LedgerProjectionRole.ACCOUNT).toList(); - - if (projections.size() != 1) - throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_018.message("Ledger account-only entry must have exactly one account projection")); //$NON-NLS-1$ - - return projections.get(0); - } - private AccountTransaction find(Account account, String projectionUUID) { return account.getTransactions().stream() // diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellDeliveryConverter.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellDeliveryConverter.java index cd46d9aee2..a4dc6eceeb 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellDeliveryConverter.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellDeliveryConverter.java @@ -3,7 +3,6 @@ import java.math.BigDecimal; import java.util.List; import java.util.Objects; -import java.util.UUID; import name.abuchen.portfolio.model.Account; import name.abuchen.portfolio.model.AccountTransaction; @@ -16,12 +15,15 @@ import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerMutationContext; import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerPostingDirection; +import name.abuchen.portfolio.model.ledger.LedgerPostingSemanticRole; +import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedPortfolioTransaction; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionSupport; import name.abuchen.portfolio.money.ExchangeRate; /** @@ -48,21 +50,22 @@ public PortfolioTransaction convertBuySellToDelivery(TransactionPair convert(editedEntry, projectionUUID)); + new LedgerMutationContext(client).mutateEntry(entry, this::convert); LedgerInvestmentPlanRefSupport.updateProjectionRoles(client, entry, roleChange); - return find(portfolio, projectionUUID); + return find(portfolio, targetRuntimeProjectionId); } public BuySellEntry convertDeliveryToBuySell(TransactionPair transaction) @@ -73,35 +76,35 @@ public BuySellEntry convertDeliveryToBuySell(TransactionPair convertDeliveryToBuySell(editedEntry, - portfolioProjectionUUID, account, accountProjectionUUID, cashPostingUUID)); + account)); LedgerInvestmentPlanRefSupport.updateProjectionRoles(client, entry, roleChange); - var portfolioTransaction = find(portfolio, portfolioProjectionUUID); - var accountTransaction = find(account, accountProjectionUUID); + var portfolioTransaction = find(portfolio, portfolioRuntimeProjectionId); + var accountTransaction = find(account, accountRuntimeProjectionId); return BuySellEntry.readOnly(portfolio, portfolioTransaction, account, accountTransaction); } - private void preflightBuySell(LedgerEntry entry, LedgerProjectionRef projectionRef, + private void preflightBuySell(LedgerEntry entry, LedgerProjectionRole role, TransactionPair transaction, Portfolio portfolio) { if (entry.getType() != LedgerEntryType.BUY && entry.getType() != LedgerEntryType.SELL) throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PROJ_019.message("Only ledger-backed buy/sell entries can be converted")); //$NON-NLS-1$ - if (projectionRef.getRole() != LedgerProjectionRole.PORTFOLIO) + if (role != LedgerProjectionRole.PORTFOLIO) throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PROJ_020.message("Only the portfolio side of a buy/sell entry can be converted")); //$NON-NLS-1$ if (transaction.getOwner() != portfolio) @@ -113,7 +116,7 @@ private void preflightBuySell(LedgerEntry entry, LedgerProjectionRef projectionR requireOneProjection(entry, LedgerProjectionRole.PORTFOLIO); } - private void preflightDelivery(LedgerEntry entry, LedgerProjectionRef projectionRef, + private void preflightDelivery(LedgerEntry entry, LedgerProjectionRole role, TransactionPair transaction, Portfolio portfolio, Account account) { if (entry.getType() != LedgerEntryType.DELIVERY_INBOUND && entry.getType() != LedgerEntryType.DELIVERY_OUTBOUND) @@ -122,7 +125,7 @@ private void preflightDelivery(LedgerEntry entry, LedgerProjectionRef projection var expectedRole = entry.getType() == LedgerEntryType.DELIVERY_INBOUND ? LedgerProjectionRole.DELIVERY_INBOUND : LedgerProjectionRole.DELIVERY_OUTBOUND; - if (projectionRef.getRole() != expectedRole) + if (role != expectedRole) throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PROJ_023.message("Only the delivery projection can be converted")); //$NON-NLS-1$ if (transaction.getOwner() != portfolio) @@ -131,7 +134,7 @@ private void preflightDelivery(LedgerEntry entry, LedgerProjectionRef projection if (entry.getPostings().stream().anyMatch(posting -> posting.getType() == LedgerPostingType.CASH)) throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_022.message("Ledger delivery entry must not already have a cash posting")); //$NON-NLS-1$ - if (entry.getProjectionRefs().stream().anyMatch(projection -> projection.getRole() == LedgerProjectionRole.ACCOUNT)) + if (hasProjection(entry, LedgerProjectionRole.ACCOUNT)) throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_025.message("Ledger delivery entry must not already have an account projection")); //$NON-NLS-1$ requireOnePosting(entry, LedgerPostingType.SECURITY); @@ -148,7 +151,7 @@ private Account requireReferenceAccount(Portfolio portfolio) return account; } - private void convert(LedgerEntry entry, String projectionUUID) + private void convert(LedgerEntry entry) { var targetType = entry.getType() == LedgerEntryType.BUY ? LedgerEntryType.DELIVERY_INBOUND : LedgerEntryType.DELIVERY_OUTBOUND; @@ -159,33 +162,18 @@ private void convert(LedgerEntry entry, String projectionUUID) .filter(posting -> posting.getType() == LedgerPostingType.CASH) // .forEach(entry::removePosting); - List.copyOf(entry.getProjectionRefs()).stream() // - .filter(projection -> projection.getRole() == LedgerProjectionRole.ACCOUNT) // - .forEach(entry::removeProjectionRef); - - var portfolioProjection = entry.getProjectionRefs().stream() // - .filter(projection -> projectionUUID.equals(projection.getUUID())) // - .findFirst() - .orElseThrow(() -> new IllegalArgumentException( - "Ledger portfolio projection not found: " + projectionUUID)); //$NON-NLS-1$ + var securityPosting = requireOnePosting(entry, LedgerPostingType.SECURITY); entry.setType(targetType); - portfolioProjection.setRole(targetRole); + markPrimary(securityPosting, LedgerPostingSemanticRole.SECURITY, direction(targetRole), targetRole); } - private void convertDeliveryToBuySell(LedgerEntry entry, String portfolioProjectionUUID, Account account, - String accountProjectionUUID, String cashPostingUUID) + private void convertDeliveryToBuySell(LedgerEntry entry, Account account) { var targetType = entry.getType() == LedgerEntryType.DELIVERY_INBOUND ? LedgerEntryType.BUY : LedgerEntryType.SELL; var securityPosting = requireOnePosting(entry, LedgerPostingType.SECURITY); - var cashPosting = new LedgerPosting(cashPostingUUID); - var accountProjection = new LedgerProjectionRef(accountProjectionUUID); - var portfolioProjection = entry.getProjectionRefs().stream() // - .filter(projection -> portfolioProjectionUUID.equals(projection.getUUID())) // - .findFirst() - .orElseThrow(() -> new IllegalArgumentException( - "Ledger delivery projection not found: " + portfolioProjectionUUID)); //$NON-NLS-1$ + var cashPosting = new LedgerPosting(); var unitPostings = entry.getPostings().stream() // .filter(posting -> posting != securityPosting) // .toList(); @@ -194,21 +182,16 @@ private void convertDeliveryToBuySell(LedgerEntry entry, String portfolioProject cashPosting.setAccount(account); applyDeliveryCashPosting(securityPosting, cashPosting, account); - accountProjection.setRole(LedgerProjectionRole.ACCOUNT); - accountProjection.setAccount(account); - accountProjection.setPrimaryPosting(cashPosting); - - portfolioProjection.setRole(LedgerProjectionRole.PORTFOLIO); + markPrimary(cashPosting, LedgerPostingSemanticRole.CASH, cashDirection(targetType), + LedgerProjectionRole.ACCOUNT); + markPrimary(securityPosting, LedgerPostingSemanticRole.SECURITY, securityDirection(targetType), + LedgerProjectionRole.PORTFOLIO); List.copyOf(entry.getPostings()).forEach(entry::removePosting); entry.addPosting(cashPosting); entry.addPosting(securityPosting); unitPostings.forEach(entry::addPosting); - List.copyOf(entry.getProjectionRefs()).forEach(entry::removeProjectionRef); - entry.addProjectionRef(accountProjection); - entry.addProjectionRef(portfolioProjection); - entry.setType(targetType); } @@ -262,13 +245,63 @@ private LedgerPosting requireOnePosting(LedgerEntry entry, LedgerPostingType typ private void requireOneProjection(LedgerEntry entry, LedgerProjectionRole role) { - var count = entry.getProjectionRefs().stream().filter(projection -> projection.getRole() == role).count(); + var count = LedgerProjectionSupport.descriptors(entry).stream().filter(projection -> projection.getRole() == role) + .count(); if (count != 1) throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_026 .message("Ledger buy/sell entry must have exactly one " + role + " projection")); //$NON-NLS-1$ //$NON-NLS-2$ } + private boolean hasProjection(LedgerEntry entry, LedgerProjectionRole role) + { + return LedgerProjectionSupport.descriptors(entry).stream().anyMatch(projection -> projection.getRole() == role); + } + + private void markPrimary(LedgerPosting posting, LedgerPostingSemanticRole semanticRole, + LedgerPostingDirection direction, LedgerProjectionRole role) + { + posting.setSemanticRole(semanticRole); + posting.setDirection(direction); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + posting.setLocalKey(role.name()); + } + + private LedgerPostingDirection cashDirection(LedgerEntryType entryType) + { + return switch (entryType) + { + case BUY -> LedgerPostingDirection.OUTBOUND; + case SELL -> LedgerPostingDirection.INBOUND; + default -> LedgerPostingDirection.NEUTRAL; + }; + } + + private LedgerPostingDirection securityDirection(LedgerEntryType entryType) + { + return switch (entryType) + { + case BUY -> LedgerPostingDirection.INBOUND; + case SELL -> LedgerPostingDirection.OUTBOUND; + default -> LedgerPostingDirection.NEUTRAL; + }; + } + + private LedgerPostingDirection direction(LedgerProjectionRole role) + { + return switch (role) + { + case DELIVERY_OUTBOUND -> LedgerPostingDirection.OUTBOUND; + case DELIVERY_INBOUND -> LedgerPostingDirection.INBOUND; + default -> LedgerPostingDirection.NEUTRAL; + }; + } + + private String runtimeProjectionId(LedgerEntry entry, LedgerProjectionRole role) + { + return entry.getUUID() + ":" + role; //$NON-NLS-1$ + } + private PortfolioTransaction find(Portfolio portfolio, String projectionUUID) { return portfolio.getTransactions().stream() // diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellEditor.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellEditor.java index e08f3ffe95..73363ca4a1 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellEditor.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellEditor.java @@ -6,7 +6,6 @@ import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerEntryEditSupport; import name.abuchen.portfolio.model.ledger.LedgerEntryMetadataPatchHelper; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedAccountTransaction; @@ -41,13 +40,13 @@ public void apply(LedgerEntry entry, LedgerBuySellEdit edit) throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_025 .message("Unsupported buy/sell edit for " + entry.getType())); //$NON-NLS-1$ - var accountProjection = projection(entry, LedgerProjectionRole.ACCOUNT); - var portfolioProjection = projection(entry, LedgerProjectionRole.PORTFOLIO); - var cashPostingUUID = LedgerProjectionSupport.primaryPosting(entry, accountProjection).getUUID(); - var securityPostingUUID = LedgerProjectionSupport.primaryPosting(entry, portfolioProjection).getUUID(); + var accountProjection = LedgerProjectionSupport.descriptor(entry, LedgerProjectionRole.ACCOUNT); + var portfolioProjection = LedgerProjectionSupport.descriptor(entry, LedgerProjectionRole.PORTFOLIO); + var cashPostingUUID = accountProjection.getPrimaryPosting().getUUID(); + var securityPostingUUID = portfolioProjection.getPrimaryPosting().getUUID(); LedgerEntryEditSupport.applyValidated(entry, editedEntry -> applyEdit(editedEntry, edit, - accountProjection.getUUID(), portfolioProjection.getUUID(), cashPostingUUID, + LedgerProjectionRole.ACCOUNT, LedgerProjectionRole.PORTFOLIO, cashPostingUUID, securityPostingUUID)); } @@ -60,39 +59,24 @@ public void validate(LedgerEntry entry, LedgerBuySellEdit edit) throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_026 .message("Unsupported buy/sell edit for " + entry.getType())); //$NON-NLS-1$ - var accountProjection = projection(entry, LedgerProjectionRole.ACCOUNT); - var portfolioProjection = projection(entry, LedgerProjectionRole.PORTFOLIO); - var cashPostingUUID = LedgerProjectionSupport.primaryPosting(entry, accountProjection).getUUID(); - var securityPostingUUID = LedgerProjectionSupport.primaryPosting(entry, portfolioProjection).getUUID(); + var accountProjection = LedgerProjectionSupport.descriptor(entry, LedgerProjectionRole.ACCOUNT); + var portfolioProjection = LedgerProjectionSupport.descriptor(entry, LedgerProjectionRole.PORTFOLIO); + var cashPostingUUID = accountProjection.getPrimaryPosting().getUUID(); + var securityPostingUUID = portfolioProjection.getPrimaryPosting().getUUID(); LedgerEntryEditSupport.validatePatch(entry, editedEntry -> applyEdit(editedEntry, edit, - accountProjection.getUUID(), portfolioProjection.getUUID(), cashPostingUUID, + LedgerProjectionRole.ACCOUNT, LedgerProjectionRole.PORTFOLIO, cashPostingUUID, securityPostingUUID)); } - private void applyEdit(LedgerEntry editedEntry, LedgerBuySellEdit edit, String accountProjectionUUID, - String portfolioProjectionUUID, String cashPostingUUID, String securityPostingUUID) + private void applyEdit(LedgerEntry editedEntry, LedgerBuySellEdit edit, LedgerProjectionRole accountRole, + LedgerProjectionRole portfolioRole, String cashPostingUUID, String securityPostingUUID) { LedgerEntryMetadataPatchHelper.apply(editedEntry, edit.getMetadata()); edit.getCashPosting().applyTo(LedgerEntryEditSupport.postingByUUID(editedEntry, cashPostingUUID)); edit.getSecurityPosting().applyTo(LedgerEntryEditSupport.postingByUUID(editedEntry, securityPostingUUID)); - unitPostingUpdater.apply(editedEntry, edit.getUnits()); - ensureProjectionExists(editedEntry, accountProjectionUUID); - ensureProjectionExists(editedEntry, portfolioProjectionUUID); - } - - private LedgerProjectionRef projection(LedgerEntry entry, LedgerProjectionRole role) - { - return entry.getProjectionRefs().stream() // - .filter(projection -> projection.getRole() == role) // - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("Projection not found: " + role)); //$NON-NLS-1$ - } - - private void ensureProjectionExists(LedgerEntry entry, String projectionUUID) - { - if (entry.getProjectionRefs().stream().noneMatch(projection -> projection.getUUID().equals(projectionUUID))) - throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_027 - .message("Projection was removed by edit: " + projectionUUID)); //$NON-NLS-1$ + unitPostingUpdater.applyDirect(editedEntry, edit.getUnits()); + LedgerProjectionSupport.descriptor(editedEntry, accountRole); + LedgerProjectionSupport.descriptor(editedEntry, portfolioRole); } } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellReversalConverter.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellReversalConverter.java index 57c5af2240..2bd4bfb0b8 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellReversalConverter.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellReversalConverter.java @@ -12,10 +12,11 @@ import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerMutationContext; import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerPostingDirection; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedAccountTransaction; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedPortfolioTransaction; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; @@ -46,10 +47,10 @@ public BuySellEntry reverse(BuySellEntry entry) throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_CONVERT_027.message("Only ledger-backed buy/sell transactions can be reversed")); //$NON-NLS-1$ var ledgerEntry = accountTransaction.getLedgerEntry(); - var accountProjectionUUID = accountTransaction.getLedgerProjectionRef().getUUID(); - var portfolioProjectionUUID = portfolioTransaction.getLedgerProjectionRef().getUUID(); - var account = accountTransaction.getLedgerProjectionRef().getAccount(); - var portfolio = portfolioTransaction.getLedgerProjectionRef().getPortfolio(); + var accountProjectionUUID = accountTransaction.getRuntimeProjectionId(); + var portfolioProjectionUUID = portfolioTransaction.getRuntimeProjectionId(); + var account = accountTransaction.getLedgerProjectionDescriptor().getAccount(); + var portfolio = portfolioTransaction.getLedgerProjectionDescriptor().getPortfolio(); preflight(ledgerEntry, accountTransaction, portfolioTransaction); LedgerInvestmentPlanRefSupport.requireCurrentRefsResolveUniquely(client, ledgerEntry); @@ -91,8 +92,8 @@ private void preflight(LedgerEntry entry, LedgerBackedAccountTransaction account if (accountTransaction.getLedgerEntry() != portfolioTransaction.getLedgerEntry()) throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_028.message("Buy/sell projections do not belong to the same ledger entry")); //$NON-NLS-1$ - if (accountTransaction.getLedgerProjectionRef().getRole() != LedgerProjectionRole.ACCOUNT - || portfolioTransaction.getLedgerProjectionRef().getRole() != LedgerProjectionRole.PORTFOLIO) + if (accountTransaction.getLedgerProjectionRole() != LedgerProjectionRole.ACCOUNT + || portfolioTransaction.getLedgerProjectionRole() != LedgerProjectionRole.PORTFOLIO) throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_029.message("Buy/sell projections are not account/portfolio projections")); //$NON-NLS-1$ var accountProjection = uniqueProjection(entry, LedgerProjectionRole.ACCOUNT); @@ -100,8 +101,8 @@ private void preflight(LedgerEntry entry, LedgerBackedAccountTransaction account var cashPosting = requireOnePosting(entry, LedgerPostingType.CASH); var securityPosting = requireOnePosting(entry, LedgerPostingType.SECURITY); - if (LedgerProjectionSupport.primaryPosting(entry, accountProjection) != cashPosting - || LedgerProjectionSupport.primaryPosting(entry, portfolioProjection) != securityPosting) + if (accountProjection.getPrimaryPosting() != cashPosting + || portfolioProjection.getPrimaryPosting() != securityPosting) throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_030.message("Buy/sell projection primary postings are ambiguous")); //$NON-NLS-1$ if (cashPosting.getAccount() != accountProjection.getAccount() @@ -124,6 +125,8 @@ private void reverse(LedgerEntry entry) cashPosting.setCurrency(amount.getCurrencyCode()); securityPosting.setAmount(amount.getAmount()); securityPosting.setCurrency(amount.getCurrencyCode()); + cashPosting.setDirection(cashDirection(targetType)); + securityPosting.setDirection(securityDirection(targetType)); entry.setType(targetType); } @@ -176,9 +179,10 @@ private LedgerPosting requireOnePosting(LedgerEntry entry, LedgerPostingType typ return postings.get(0); } - private LedgerProjectionRef uniqueProjection(LedgerEntry entry, LedgerProjectionRole role) + private DerivedProjectionDescriptor uniqueProjection(LedgerEntry entry, LedgerProjectionRole role) { - var projections = entry.getProjectionRefs().stream().filter(projection -> projection.getRole() == role).toList(); + var projections = LedgerProjectionSupport.descriptors(entry).stream() + .filter(projection -> projection.getRole() == role).toList(); if (projections.size() != 1) throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_032 @@ -188,6 +192,16 @@ private LedgerProjectionRef uniqueProjection(LedgerEntry entry, LedgerProjection return projections.get(0); } + private LedgerPostingDirection cashDirection(LedgerEntryType entryType) + { + return entryType == LedgerEntryType.BUY ? LedgerPostingDirection.OUTBOUND : LedgerPostingDirection.INBOUND; + } + + private LedgerPostingDirection securityDirection(LedgerEntryType entryType) + { + return entryType == LedgerEntryType.BUY ? LedgerPostingDirection.INBOUND : LedgerPostingDirection.OUTBOUND; + } + private AccountTransaction find(Account account, String projectionUUID) { return account.getTransactions().stream() // diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellTransactionCreator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellTransactionCreator.java index 57a60df9b1..9426307583 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellTransactionCreator.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellTransactionCreator.java @@ -127,8 +127,8 @@ public BuySellEntry update(BuySellEntry entry, Portfolio portfolio, Account acco throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_CONVERT_032.message("Changing buy/sell type is not supported")); //$NON-NLS-1$ var ledgerEntry = accountTransaction.getLedgerEntry(); - var accountProjectionUUID = accountTransaction.getLedgerProjectionRef().getUUID(); - var portfolioProjectionUUID = portfolioTransaction.getLedgerProjectionRef().getUUID(); + var accountProjectionUUID = accountTransaction.getUUID(); + var portfolioProjectionUUID = portfolioTransaction.getUUID(); var ownerPatchHelper = new LedgerOwnerPatchHelper(client); var editBuilder = LedgerBuySellEdit.builder() @@ -148,14 +148,14 @@ public BuySellEntry update(BuySellEntry entry, Portfolio portfolio, Account acco var edit = editBuilder.build(); var editor = new LedgerBuySellEditor(); - if (accountTransaction.getLedgerProjectionRef().getAccount() != account - || portfolioTransaction.getLedgerProjectionRef().getPortfolio() != portfolio) + if (accountTransaction.getLedgerProjectionDescriptor().getAccount() != account + || portfolioTransaction.getLedgerProjectionDescriptor().getPortfolio() != portfolio) editor.validate(ledgerEntry, edit); - if (accountTransaction.getLedgerProjectionRef().getAccount() != account) + if (accountTransaction.getLedgerProjectionDescriptor().getAccount() != account) ownerPatchHelper.moveBuySellAccountSide(ledgerEntry, account); - if (portfolioTransaction.getLedgerProjectionRef().getPortfolio() != portfolio) + if (portfolioTransaction.getLedgerProjectionDescriptor().getPortfolio() != portfolio) ownerPatchHelper.moveBuySellPortfolioSide(ledgerEntry, portfolio); accountTransaction = (LedgerBackedAccountTransaction) find(account, accountProjectionUUID); @@ -163,8 +163,10 @@ public BuySellEntry update(BuySellEntry entry, Portfolio portfolio, Account acco entry = (BuySellEntry) accountTransaction.getCrossEntry(); editor.apply(portfolioTransaction, edit); + LedgerProjectionService.restoreIfValid(client); + accountTransaction = (LedgerBackedAccountTransaction) find(account, accountProjectionUUID); - return entry; + return (BuySellEntry) accountTransaction.getCrossEntry(); } private void applyCashForex(LedgerBuySellEdit.Builder builder, LedgerForexAmount forex) @@ -277,16 +279,8 @@ private boolean isUnitPosting(LedgerPostingType type) private BuySellEntry materializeAndWrap(Portfolio portfolio, Account account, LedgerTransactionCreator.CreatedTransaction created) { - var accountProjectionUUID = created.getProjectionRefs().stream() - .filter(projection -> projection.getRole() == LedgerProjectionRole.ACCOUNT) - .findFirst() - .orElseThrow() - .getUUID(); - var portfolioProjectionUUID = created.getProjectionRefs().stream() - .filter(projection -> projection.getRole() == LedgerProjectionRole.PORTFOLIO) - .findFirst() - .orElseThrow() - .getUUID(); + var accountProjectionUUID = created.getRuntimeProjectionId(LedgerProjectionRole.ACCOUNT); + var portfolioProjectionUUID = created.getRuntimeProjectionId(LedgerProjectionRole.PORTFOLIO); LedgerProjectionService.materialize(client); diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryDirectionConverter.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryDirectionConverter.java index aeaab80975..9771426351 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryDirectionConverter.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryDirectionConverter.java @@ -10,10 +10,11 @@ import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerMutationContext; import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerPostingDirection; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedPortfolioTransaction; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionSupport; @@ -42,25 +43,26 @@ public PortfolioTransaction reverse(TransactionPair transa throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_CONVERT_034.message("Only ledger-backed delivery transactions can be reversed")); //$NON-NLS-1$ var entry = ledgerTransaction.getLedgerEntry(); - var projectionRef = ledgerTransaction.getLedgerProjectionRef(); - var portfolio = projectionRef.getPortfolio(); - var projectionUUID = projectionRef.getUUID(); + var descriptor = ledgerTransaction.getLedgerProjectionDescriptor(); + var portfolio = descriptor.getPortfolio(); + var oldRuntimeProjectionId = descriptor.getRuntimeProjectionId(); - preflight(entry, projectionRef, transaction, portfolio); + preflight(entry, descriptor, transaction, portfolio); var targetRole = role(entry.getType() == LedgerEntryType.DELIVERY_INBOUND ? LedgerEntryType.DELIVERY_OUTBOUND : LedgerEntryType.DELIVERY_INBOUND); - var roleChange = LedgerInvestmentPlanRefSupport.roleChange(projectionUUID, projectionRef.getRole(), + var targetRuntimeProjectionId = runtimeProjectionId(entry, targetRole); + var roleChange = LedgerInvestmentPlanRefSupport.roleChange(oldRuntimeProjectionId, descriptor.getRole(), targetRole); LedgerInvestmentPlanRefSupport.requireRefsFollowRoleChanges(client, entry, roleChange); - new LedgerMutationContext(client).mutateEntry(entry, editedEntry -> reverse(editedEntry, projectionUUID)); + new LedgerMutationContext(client).mutateEntry(entry, this::reverse); LedgerInvestmentPlanRefSupport.updateProjectionRoles(client, entry, roleChange); - return find(portfolio, projectionUUID); + return find(portfolio, targetRuntimeProjectionId); } - private void preflight(LedgerEntry entry, LedgerProjectionRef projectionRef, + private void preflight(LedgerEntry entry, DerivedProjectionDescriptor descriptor, TransactionPair transaction, Portfolio portfolio) { if (entry.getType() != LedgerEntryType.DELIVERY_INBOUND && entry.getType() != LedgerEntryType.DELIVERY_OUTBOUND) @@ -68,7 +70,7 @@ private void preflight(LedgerEntry entry, LedgerProjectionRef projectionRef, var expectedRole = role(entry.getType()); - if (projectionRef.getRole() != expectedRole) + if (descriptor.getRole() != expectedRole) throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PROJ_034.message("Only the delivery projection can be reversed")); //$NON-NLS-1$ if (transaction.getOwner() != portfolio) @@ -77,10 +79,10 @@ private void preflight(LedgerEntry entry, LedgerProjectionRef projectionRef, var projection = requireOneProjection(entry, expectedRole); var securityPosting = requireOnePosting(entry, LedgerPostingType.SECURITY); - if (projection != projectionRef) + if (!projection.getRuntimeProjectionId().equals(descriptor.getRuntimeProjectionId())) throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_036.message("Selected projection is not the unique delivery projection")); //$NON-NLS-1$ - if (LedgerProjectionSupport.primaryPosting(entry, projection) != securityPosting) + if (projection.getPrimaryPosting() != securityPosting) throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_037.message("Delivery projection primary posting is ambiguous")); //$NON-NLS-1$ if (securityPosting.getPortfolio() != projection.getPortfolio()) @@ -94,22 +96,19 @@ private void preflight(LedgerEntry entry, LedgerProjectionRef projectionRef, reversedAmount(entry, entry.getType()); } - private void reverse(LedgerEntry entry, String projectionUUID) + private void reverse(LedgerEntry entry) { var currentType = entry.getType(); var targetType = currentType == LedgerEntryType.DELIVERY_INBOUND ? LedgerEntryType.DELIVERY_OUTBOUND : LedgerEntryType.DELIVERY_INBOUND; var amount = reversedAmount(entry, currentType); var securityPosting = requireOnePosting(entry, LedgerPostingType.SECURITY); - var projection = entry.getProjectionRefs().stream() // - .filter(item -> projectionUUID.equals(item.getUUID())) // - .findFirst() - .orElseThrow(() -> new IllegalArgumentException( - "Ledger delivery projection not found: " + projectionUUID)); //$NON-NLS-1$ + var targetRole = role(targetType); securityPosting.setAmount(amount.getAmount()); securityPosting.setCurrency(amount.getCurrencyCode()); - projection.setRole(role(targetType)); + securityPosting.setDirection(direction(targetRole)); + securityPosting.setLocalKey(targetRole.name()); entry.setType(targetType); } @@ -155,9 +154,10 @@ private LedgerPosting requireOnePosting(LedgerEntry entry, LedgerPostingType typ return postings.get(0); } - private LedgerProjectionRef requireOneProjection(LedgerEntry entry, LedgerProjectionRole role) + private DerivedProjectionDescriptor requireOneProjection(LedgerEntry entry, LedgerProjectionRole role) { - var projections = entry.getProjectionRefs().stream().filter(projection -> projection.getRole() == role).toList(); + var projections = LedgerProjectionSupport.descriptors(entry).stream() + .filter(projection -> projection.getRole() == role).toList(); if (projections.size() != 1) throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_039 @@ -172,6 +172,17 @@ private LedgerProjectionRole role(LedgerEntryType entryType) : LedgerProjectionRole.DELIVERY_OUTBOUND; } + private LedgerPostingDirection direction(LedgerProjectionRole role) + { + return role == LedgerProjectionRole.DELIVERY_OUTBOUND ? LedgerPostingDirection.OUTBOUND + : LedgerPostingDirection.INBOUND; + } + + private String runtimeProjectionId(LedgerEntry entry, LedgerProjectionRole role) + { + return entry.getUUID() + ":" + role; //$NON-NLS-1$ + } + private PortfolioTransaction find(Portfolio portfolio, String projectionUUID) { return portfolio.getTransactions().stream() // diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryTransactionCreator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryTransactionCreator.java index 9779a9146e..3559975452 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryTransactionCreator.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryTransactionCreator.java @@ -110,9 +110,9 @@ public PortfolioTransaction update(PortfolioTransaction transaction, Portfolio p var edit = editBuilder.build(); var editor = new LedgerDeliveryTransactionEditor(); - if (ledgerTransaction.getLedgerProjectionRef().getPortfolio() != portfolio) + if (ledgerTransaction.getLedgerProjectionDescriptor().getPortfolio() != portfolio) { - var projectionUUID = ledgerTransaction.getLedgerProjectionRef().getUUID(); + var projectionUUID = ledgerTransaction.getUUID(); editor.validate(ledgerTransaction, edit); new LedgerOwnerPatchHelper(client).moveDelivery(ledgerTransaction, portfolio); @@ -120,8 +120,9 @@ public PortfolioTransaction update(PortfolioTransaction transaction, Portfolio p } editor.apply(ledgerTransaction, edit); + LedgerProjectionService.restoreIfValid(client); - return ledgerTransaction; + return (PortfolioTransaction) find(portfolio, ledgerTransaction.getUUID()); } private void applyForex(LedgerDeliveryTransactionEdit.Builder builder, LedgerForexAmount forex) @@ -230,7 +231,10 @@ private boolean isUnitPosting(LedgerPostingType type) private PortfolioTransaction materializeAndFind(Portfolio portfolio, LedgerTransactionCreator.CreatedTransaction created) { - var projectionUUID = created.getProjectionRefs().get(0).getUUID(); + var projectionUUID = created.getRuntimeProjectionId( + created.getEntry().getType() == name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType.DELIVERY_INBOUND + ? name.abuchen.portfolio.model.ledger.LedgerProjectionRole.DELIVERY_INBOUND + : name.abuchen.portfolio.model.ledger.LedgerProjectionRole.DELIVERY_OUTBOUND); LedgerProjectionService.materialize(client); diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryTransactionEditor.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryTransactionEditor.java index 2ab199eeaf..bd1a33b4b2 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryTransactionEditor.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryTransactionEditor.java @@ -30,11 +30,10 @@ public void apply(LedgerBackedPortfolioTransaction transaction, LedgerDeliveryTr throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_040 .message("Unsupported delivery edit for " + entry.getType())); //$NON-NLS-1$ - var projectionUUID = transaction.getLedgerProjectionRef().getUUID(); - var postingUUID = LedgerProjectionSupport.primaryPosting(entry, transaction.getLedgerProjectionRef()).getUUID(); + var role = transaction.getLedgerProjectionRole(); + var postingUUID = transaction.getLedgerProjectionDescriptor().getPrimaryPosting().getUUID(); - LedgerEntryEditSupport.applyValidated(entry, editedEntry -> applyEdit(editedEntry, edit, projectionUUID, - postingUUID)); + LedgerEntryEditSupport.applyValidated(entry, editedEntry -> applyEdit(editedEntry, edit, role, postingUUID)); } public void validate(LedgerBackedPortfolioTransaction transaction, LedgerDeliveryTransactionEdit edit) @@ -48,26 +47,19 @@ public void validate(LedgerBackedPortfolioTransaction transaction, LedgerDeliver throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_041 .message("Unsupported delivery edit for " + entry.getType())); //$NON-NLS-1$ - var projectionUUID = transaction.getLedgerProjectionRef().getUUID(); - var postingUUID = LedgerProjectionSupport.primaryPosting(entry, transaction.getLedgerProjectionRef()).getUUID(); + var role = transaction.getLedgerProjectionRole(); + var postingUUID = transaction.getLedgerProjectionDescriptor().getPrimaryPosting().getUUID(); - LedgerEntryEditSupport.validatePatch(entry, editedEntry -> applyEdit(editedEntry, edit, projectionUUID, - postingUUID)); + LedgerEntryEditSupport.validatePatch(entry, editedEntry -> applyEdit(editedEntry, edit, role, postingUUID)); } - private void applyEdit(LedgerEntry editedEntry, LedgerDeliveryTransactionEdit edit, String projectionUUID, + private void applyEdit(LedgerEntry editedEntry, LedgerDeliveryTransactionEdit edit, + name.abuchen.portfolio.model.ledger.LedgerProjectionRole role, String postingUUID) { LedgerEntryMetadataPatchHelper.apply(editedEntry, edit.getMetadata()); edit.getPosting().applyTo(LedgerEntryEditSupport.postingByUUID(editedEntry, postingUUID)); - unitPostingUpdater.apply(editedEntry, edit.getUnits()); - ensureProjectionExists(editedEntry, projectionUUID); - } - - private void ensureProjectionExists(LedgerEntry entry, String projectionUUID) - { - if (entry.getProjectionRefs().stream().noneMatch(projection -> projection.getUUID().equals(projectionUUID))) - throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_040 - .message("Projection was removed by edit: " + projectionUUID)); //$NON-NLS-1$ + unitPostingUpdater.applyDirect(editedEntry, edit.getUnits()); + LedgerProjectionSupport.descriptor(editedEntry, role); } } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDividendTransactionCreator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDividendTransactionCreator.java index e2239f9688..c4d375c79c 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDividendTransactionCreator.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDividendTransactionCreator.java @@ -115,9 +115,9 @@ public AccountTransaction update(AccountTransaction transaction, Account account var edit = editBuilder.build(); var editor = new LedgerAccountTransactionEditor(); - if (ledgerTransaction.getLedgerProjectionRef().getAccount() != account) + if (ledgerTransaction.getLedgerProjectionDescriptor().getAccount() != account) { - var projectionUUID = ledgerTransaction.getLedgerProjectionRef().getUUID(); + var projectionUUID = ledgerTransaction.getRuntimeProjectionId(); editor.validate(ledgerTransaction, edit); new LedgerOwnerPatchHelper(client).moveAccountOnly(ledgerTransaction, account); @@ -219,7 +219,7 @@ private boolean isUnitPosting(LedgerPostingType type) private AccountTransaction materializeAndFind(Account account, LedgerTransactionCreator.CreatedTransaction created) { - var projectionUUID = created.getProjectionRefs().get(0).getUUID(); + var projectionUUID = created.getRuntimeProjectionId(name.abuchen.portfolio.model.ledger.LedgerProjectionRole.ACCOUNT); LedgerProjectionService.materialize(client); diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerInlineEditingPolicy.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerInlineEditingPolicy.java index 01ff6b4346..b0d1604114 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerInlineEditingPolicy.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerInlineEditingPolicy.java @@ -31,7 +31,7 @@ public static boolean isEditable(Object element, LedgerInlineEditingField field) return true; return isEditable(ledgerBackedTransaction.getLedgerEntry().getType(), - ledgerBackedTransaction.getLedgerProjectionRef().getRole(), field); + ledgerBackedTransaction.getLedgerProjectionRole(), field); } public static boolean isEditable(LedgerEntryType type, LedgerProjectionRole role, LedgerInlineEditingField field) diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerInvestmentPlanRefSupport.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerInvestmentPlanRefSupport.java index 0e4135d5bc..4a47bda6c9 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerInvestmentPlanRefSupport.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerInvestmentPlanRefSupport.java @@ -5,7 +5,6 @@ import name.abuchen.portfolio.model.Client; import name.abuchen.portfolio.model.ledger.LedgerEntry; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; /** @@ -19,10 +18,10 @@ private LedgerInvestmentPlanRefSupport() { } - static RoleChange roleChange(String projectionUUID, LedgerProjectionRole sourceRole, + static RoleChange roleChange(String runtimeProjectionId, LedgerProjectionRole sourceRole, LedgerProjectionRole targetRole) { - return new RoleChange(Objects.requireNonNull(projectionUUID), Objects.requireNonNull(sourceRole), + return new RoleChange(Objects.requireNonNull(runtimeProjectionId), Objects.requireNonNull(sourceRole), Objects.requireNonNull(targetRole)); } @@ -32,7 +31,7 @@ static void requireCurrentRefsResolveUniquely(Client client, LedgerEntry entry) } static SplitExecutionRefUpdates prepareAccountTransferSplitExecutionRefUpdates(Client client, LedgerEntry entry, - LedgerProjectionRef sourceProjection, LedgerProjectionRef targetProjection, + LedgerProjectionRole sourceRole, LedgerProjectionRole targetRole, LedgerEntry removalEntry, LedgerEntry depositEntry) { return new SplitExecutionRefUpdates(List.of()); @@ -48,7 +47,7 @@ static void updateProjectionRoles(Client client, LedgerEntry entry, RoleChange.. // Projection role rewrites are no longer part of InvestmentPlan linkage. } - record RoleChange(String projectionUUID, LedgerProjectionRole sourceRole, LedgerProjectionRole targetRole) + record RoleChange(String runtimeProjectionId, LedgerProjectionRole sourceRole, LedgerProjectionRole targetRole) { } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerNativeComponentInspectorModel.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerNativeComponentInspectorModel.java index e03d5d3644..d24777fda6 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerNativeComponentInspectorModel.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerNativeComponentInspectorModel.java @@ -15,13 +15,14 @@ import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerParameter; import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinition; import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinitionRegistry; import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; import name.abuchen.portfolio.model.ledger.configuration.LedgerLegDefinition; import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; +import name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionSupport; import name.abuchen.portfolio.money.Money; import name.abuchen.portfolio.money.Values; @@ -43,9 +44,8 @@ public enum HeaderField SHAPE, NATIVE_TARGETED, SELECTED_PROJECTION_ROLE, - SELECTED_PROJECTION_UUID, - SELECTED_PRIMARY_POSTING_UUID, - SELECTED_POSTING_GROUP_UUID + SELECTED_RUNTIME_PROJECTION_ID, + SELECTED_PRIMARY_POSTING_UUID } public record HeaderRow(HeaderField field, String value) @@ -71,8 +71,8 @@ public record PostingParameterRow(String postingUUID, String postingType, String { } - public record ProjectionRefRow(String projectionRole, String owner, String projectionUUID, - String primaryPostingUUID, String postingGroupUUID) + public record DescriptorRow(String projectionRole, String owner, String runtimeProjectionId, + String primaryPostingId, String unitPostings) { } @@ -81,19 +81,19 @@ public record ProjectionRefRow(String projectionRole, String owner, String proje private final List legs; private final List postings; private final List postingParameters; - private final List projectionRefs; + private final List descriptors; private final boolean nativeEntryDefinitionAvailable; private LedgerNativeComponentInspectorModel(List headerRows, List entryParameters, List legs, List postings, List postingParameters, - List projectionRefs, boolean nativeEntryDefinitionAvailable) + List descriptors, boolean nativeEntryDefinitionAvailable) { this.headerRows = List.copyOf(headerRows); this.entryParameters = List.copyOf(entryParameters); this.legs = List.copyOf(legs); this.postings = List.copyOf(postings); this.postingParameters = List.copyOf(postingParameters); - this.projectionRefs = List.copyOf(projectionRefs); + this.descriptors = List.copyOf(descriptors); this.nativeEntryDefinitionAvailable = nativeEntryDefinitionAvailable; } @@ -120,10 +120,10 @@ public static boolean isLedgerNativeTargetedProjection(Object transaction) static Optional from(LedgerBackedTransaction transaction) { Objects.requireNonNull(transaction); - return from(transaction.getLedgerEntry(), transaction.getLedgerProjectionRef(), LedgerEntryDefinitionRegistry::lookup); + return from(transaction.getLedgerEntry(), transaction.getLedgerProjectionDescriptor(), LedgerEntryDefinitionRegistry::lookup); } - static Optional from(LedgerEntry entry, LedgerProjectionRef selectedProjectionRef, + static Optional from(LedgerEntry entry, DerivedProjectionDescriptor selectedDescriptor, Function> definitionLookup) { Objects.requireNonNull(entry); @@ -136,11 +136,11 @@ static Optional from(LedgerEntry entry, Led : Optional.empty(); var nativeEntryDefinitionAvailable = definition.isPresent(); - return Optional.of(new LedgerNativeComponentInspectorModel(headerRows(entry, selectedProjectionRef), + return Optional.of(new LedgerNativeComponentInspectorModel(headerRows(entry, selectedDescriptor), parameterRows(entry.getParameters()), definition.map(d -> legRows(d.getLegDefinitions())).orElseGet(List::of), postingRows(entry.getPostings()), postingParameterRows(entry.getPostings()), - projectionRefRows(entry.getProjectionRefs()), nativeEntryDefinitionAvailable)); + descriptorRows(entry), nativeEntryDefinitionAvailable)); } public List getHeaderRows() @@ -168,9 +168,9 @@ public List getPostingParameters() return postingParameters; } - public List getProjectionRefs() + public List getDescriptors() { - return projectionRefs; + return descriptors; } public boolean isNativeEntryDefinitionAvailable() @@ -178,7 +178,7 @@ public boolean isNativeEntryDefinitionAvailable() return nativeEntryDefinitionAvailable; } - private static List headerRows(LedgerEntry entry, LedgerProjectionRef selectedProjectionRef) + private static List headerRows(LedgerEntry entry, DerivedProjectionDescriptor selectedDescriptor) { return List.of(new HeaderRow(HeaderField.ENTRY_TYPE, format(entry.getType())), new HeaderRow(HeaderField.ENTRY_UUID, format(entry.getUUID())), @@ -191,16 +191,12 @@ private static List headerRows(LedgerEntry entry, LedgerProjectionRef new HeaderRow(HeaderField.NATIVE_TARGETED, Boolean.toString(entry.getType().isLedgerNativeTargeted())), new HeaderRow(HeaderField.SELECTED_PROJECTION_ROLE, - selectedProjectionRef != null ? format(selectedProjectionRef.getRole()) : ""), //$NON-NLS-1$ - new HeaderRow(HeaderField.SELECTED_PROJECTION_UUID, - selectedProjectionRef != null ? format(selectedProjectionRef.getUUID()) : ""), //$NON-NLS-1$ + selectedDescriptor != null ? format(selectedDescriptor.getRole()) : ""), //$NON-NLS-1$ + new HeaderRow(HeaderField.SELECTED_RUNTIME_PROJECTION_ID, + selectedDescriptor != null ? format(selectedDescriptor.getRuntimeProjectionId()) : ""), //$NON-NLS-1$ new HeaderRow(HeaderField.SELECTED_PRIMARY_POSTING_UUID, - selectedProjectionRef != null - ? format(selectedProjectionRef.getPrimaryPostingUUID()) - : ""), //$NON-NLS-1$ - new HeaderRow(HeaderField.SELECTED_POSTING_GROUP_UUID, - selectedProjectionRef != null - ? format(selectedProjectionRef.getPostingGroupUUID()) + selectedDescriptor != null + ? format(selectedDescriptor.getPrimaryPosting().getUUID()) : "")); //$NON-NLS-1$ } @@ -245,22 +241,23 @@ private static List postingParameterRows(List projectionRefRows(List projectionRefs) + private static List descriptorRows(LedgerEntry entry) { - return projectionRefs.stream() - .map(projectionRef -> new ProjectionRefRow(format(projectionRef.getRole()), - owner(projectionRef), format(projectionRef.getUUID()), - format(projectionRef.getPrimaryPostingUUID()), - format(projectionRef.getPostingGroupUUID()))) + return LedgerProjectionSupport.descriptors(entry).stream() + .map(descriptor -> new DescriptorRow(format(descriptor.getRole()), owner(descriptor), + format(descriptor.getRuntimeProjectionId()), + format(descriptor.getPrimaryPosting().getUUID()), + descriptor.getUnitPostings().stream().map(LedgerPosting::getUUID) + .sorted().toList().toString())) .toList(); } - private static String owner(LedgerProjectionRef projectionRef) + private static String owner(DerivedProjectionDescriptor descriptor) { - if (projectionRef.getAccount() != null) - return format(projectionRef.getAccount()); + if (descriptor.getAccount() != null) + return format(descriptor.getAccount()); - return format(projectionRef.getPortfolio()); + return format(descriptor.getPortfolio()); } private static String code(LedgerParameterType type) diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerOwnerPatchHelper.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerOwnerPatchHelper.java index 955c65c105..ad75c0f3dc 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerOwnerPatchHelper.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerOwnerPatchHelper.java @@ -12,7 +12,6 @@ import name.abuchen.portfolio.model.Transaction; import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerMutationContext; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedAccountTransaction; @@ -62,7 +61,7 @@ public void moveDelivery(LedgerBackedPortfolioTransaction transaction, Portfolio if (entry.getType() != LedgerEntryType.DELIVERY_INBOUND && entry.getType() != LedgerEntryType.DELIVERY_OUTBOUND) throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_047.message("Unsupported delivery owner patch for " + entry.getType())); //$NON-NLS-1$ - movePortfolioProjection(entry, transaction.getLedgerProjectionRef().getRole(), newPortfolio); + movePortfolioProjection(entry, transaction.getLedgerProjectionRole(), newPortfolio); } public void moveBuySellAccountSide(LedgerEntry entry, Account newAccount) @@ -164,11 +163,7 @@ public void movePortfolioTransferTarget(PortfolioTransferEntry entry, Portfolio private void moveAccountProjection(LedgerEntry entry, LedgerProjectionRole role, Account newAccount) { mutationContext.mutateEntry(entry, editedEntry -> { - var projection = uniqueProjection(editedEntry, role); - var posting = LedgerProjectionSupport.primaryPosting(editedEntry, projection); - - projection.setAccount(newAccount); - projection.setPortfolio(null); + var posting = LedgerProjectionSupport.descriptor(editedEntry, role).getPrimaryPosting(); posting.setAccount(newAccount); }); } @@ -176,27 +171,11 @@ private void moveAccountProjection(LedgerEntry entry, LedgerProjectionRole role, private void movePortfolioProjection(LedgerEntry entry, LedgerProjectionRole role, Portfolio newPortfolio) { mutationContext.mutateEntry(entry, editedEntry -> { - var projection = uniqueProjection(editedEntry, role); - var posting = LedgerProjectionSupport.primaryPosting(editedEntry, projection); - - projection.setPortfolio(newPortfolio); - projection.setAccount(null); + var posting = LedgerProjectionSupport.descriptor(editedEntry, role).getPrimaryPosting(); posting.setPortfolio(newPortfolio); }); } - private LedgerProjectionRef uniqueProjection(LedgerEntry entry, LedgerProjectionRole role) - { - var projections = entry.getProjectionRefs().stream().filter(projection -> projection.getRole() == role).toList(); - - if (projections.size() != 1) - throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_042 - .message("Expected one projection for role " + role + " but found " //$NON-NLS-1$ //$NON-NLS-2$ - + projections.size())); - - return projections.get(0); - } - private boolean isAccountOnly(LedgerEntryType type) { return switch (type) diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioCompositeTypeConverter.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioCompositeTypeConverter.java index 8b57e011a6..466b244943 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioCompositeTypeConverter.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioCompositeTypeConverter.java @@ -3,7 +3,6 @@ import java.math.BigDecimal; import java.util.List; import java.util.Objects; -import java.util.UUID; import name.abuchen.portfolio.model.Account; import name.abuchen.portfolio.model.Client; @@ -15,10 +14,13 @@ import name.abuchen.portfolio.model.ledger.LedgerEntryEditSupport; import name.abuchen.portfolio.model.ledger.LedgerMutationContext; import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerPostingDirection; +import name.abuchen.portfolio.model.ledger.LedgerPostingSemanticRole; +import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedPortfolioTransaction; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionSupport; @@ -79,71 +81,68 @@ private Operation prepare(LedgerBackedPortfolioTransaction ledgerTransaction, TransactionPair transaction) { var entry = ledgerTransaction.getLedgerEntry(); - var projectionRef = ledgerTransaction.getLedgerProjectionRef(); + var descriptor = ledgerTransaction.getLedgerProjectionDescriptor(); var type = entry.getType(); if (type == LedgerEntryType.BUY || type == LedgerEntryType.SELL) - return prepareBuySellToOppositeDelivery(entry, projectionRef, transaction); + return prepareBuySellToOppositeDelivery(entry, descriptor, transaction); if (type == LedgerEntryType.DELIVERY_INBOUND || type == LedgerEntryType.DELIVERY_OUTBOUND) - return prepareDeliveryToOppositeBuySell(entry, projectionRef, transaction); + return prepareDeliveryToOppositeBuySell(entry, descriptor, transaction); throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_CONVERT_052.message("Unsupported composite portfolio conversion: " + type)); //$NON-NLS-1$ } - private Operation prepareBuySellToOppositeDelivery(LedgerEntry entry, LedgerProjectionRef projectionRef, + private Operation prepareBuySellToOppositeDelivery(LedgerEntry entry, DerivedProjectionDescriptor descriptor, TransactionPair transaction) { - var portfolio = projectionRef.getPortfolio(); - var projectionUUID = projectionRef.getUUID(); + var portfolio = descriptor.getPortfolio(); + var oldRuntimeProjectionId = descriptor.getRuntimeProjectionId(); - preflightBuySell(entry, projectionRef, transaction, portfolio); + preflightBuySell(entry, descriptor, transaction, portfolio); var targetType = entry.getType() == LedgerEntryType.BUY ? LedgerEntryType.DELIVERY_OUTBOUND : LedgerEntryType.DELIVERY_INBOUND; var targetRole = role(targetType); - var roleChange = LedgerInvestmentPlanRefSupport.roleChange(projectionUUID, LedgerProjectionRole.PORTFOLIO, - targetRole); + var targetRuntimeProjectionId = runtimeProjectionId(entry, targetRole); + var roleChange = LedgerInvestmentPlanRefSupport.roleChange(oldRuntimeProjectionId, + LedgerProjectionRole.PORTFOLIO, targetRole); LedgerInvestmentPlanRefSupport.requireRefsFollowRoleChanges(client, entry, roleChange); - LedgerEntryEditSupport.validatePatch(entry, editedEntry -> applyBuySellToOppositeDelivery(editedEntry, - projectionUUID)); + LedgerEntryEditSupport.validatePatch(entry, this::applyBuySellToOppositeDelivery); - return new Operation(entry, portfolio, projectionUUID, roleChange, - editedEntry -> applyBuySellToOppositeDelivery(editedEntry, projectionUUID)); + return new Operation(entry, portfolio, targetRuntimeProjectionId, roleChange, + this::applyBuySellToOppositeDelivery); } - private Operation prepareDeliveryToOppositeBuySell(LedgerEntry entry, LedgerProjectionRef projectionRef, + private Operation prepareDeliveryToOppositeBuySell(LedgerEntry entry, DerivedProjectionDescriptor descriptor, TransactionPair transaction) { - var portfolio = projectionRef.getPortfolio(); + var portfolio = descriptor.getPortfolio(); var account = requireReferenceAccount(portfolio); - var projectionUUID = projectionRef.getUUID(); - var accountProjectionUUID = UUID.randomUUID().toString(); - var cashPostingUUID = UUID.randomUUID().toString(); + var oldRuntimeProjectionId = descriptor.getRuntimeProjectionId(); + var targetRuntimeProjectionId = runtimeProjectionId(entry, LedgerProjectionRole.PORTFOLIO); - preflightDelivery(entry, projectionRef, transaction, portfolio); + preflightDelivery(entry, descriptor, transaction, portfolio); - var roleChange = LedgerInvestmentPlanRefSupport.roleChange(projectionUUID, projectionRef.getRole(), + var roleChange = LedgerInvestmentPlanRefSupport.roleChange(oldRuntimeProjectionId, descriptor.getRole(), LedgerProjectionRole.PORTFOLIO); LedgerInvestmentPlanRefSupport.requireRefsFollowRoleChanges(client, entry, roleChange); LedgerEntryEditSupport.validatePatch(entry, - editedEntry -> applyDeliveryToOppositeBuySell(editedEntry, projectionUUID, account, - accountProjectionUUID, cashPostingUUID)); + editedEntry -> applyDeliveryToOppositeBuySell(editedEntry, account)); - return new Operation(entry, portfolio, projectionUUID, roleChange, - editedEntry -> applyDeliveryToOppositeBuySell(editedEntry, projectionUUID, account, - accountProjectionUUID, cashPostingUUID)); + return new Operation(entry, portfolio, targetRuntimeProjectionId, roleChange, + editedEntry -> applyDeliveryToOppositeBuySell(editedEntry, account)); } - private void preflightBuySell(LedgerEntry entry, LedgerProjectionRef projectionRef, + private void preflightBuySell(LedgerEntry entry, DerivedProjectionDescriptor descriptor, TransactionPair transaction, Portfolio portfolio) { if (entry.getType() != LedgerEntryType.BUY && entry.getType() != LedgerEntryType.SELL) throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PROJ_043.message("Only ledger-backed buy/sell entries can be converted")); //$NON-NLS-1$ - if (projectionRef.getRole() != LedgerProjectionRole.PORTFOLIO) + if (descriptor.getRole() != LedgerProjectionRole.PORTFOLIO) throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PROJ_044.message("Only the portfolio side of a buy/sell entry can be converted")); //$NON-NLS-1$ if (transaction.getOwner() != portfolio) @@ -154,11 +153,11 @@ private void preflightBuySell(LedgerEntry entry, LedgerProjectionRef projectionR var cashPosting = requireOnePosting(entry, LedgerPostingType.CASH); var securityPosting = requireOnePosting(entry, LedgerPostingType.SECURITY); - if (portfolioProjection != projectionRef) + if (!portfolioProjection.getRuntimeProjectionId().equals(descriptor.getRuntimeProjectionId())) throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_046.message("Selected projection is not the unique portfolio projection")); //$NON-NLS-1$ - if (LedgerProjectionSupport.primaryPosting(entry, accountProjection) != cashPosting - || LedgerProjectionSupport.primaryPosting(entry, portfolioProjection) != securityPosting) + if (accountProjection.getPrimaryPosting() != cashPosting + || portfolioProjection.getPrimaryPosting() != securityPosting) throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_047.message("Buy/sell projection primary postings are ambiguous")); //$NON-NLS-1$ if (cashPosting.getAccount() != accountProjection.getAccount() @@ -170,7 +169,7 @@ private void preflightBuySell(LedgerEntry entry, LedgerProjectionRef projectionR reversedBuySellAmount(entry, entry.getType()); } - private void preflightDelivery(LedgerEntry entry, LedgerProjectionRef projectionRef, + private void preflightDelivery(LedgerEntry entry, DerivedProjectionDescriptor descriptor, TransactionPair transaction, Portfolio portfolio) { if (entry.getType() != LedgerEntryType.DELIVERY_INBOUND && entry.getType() != LedgerEntryType.DELIVERY_OUTBOUND) @@ -178,7 +177,7 @@ private void preflightDelivery(LedgerEntry entry, LedgerProjectionRef projection var expectedRole = role(entry.getType()); - if (projectionRef.getRole() != expectedRole) + if (descriptor.getRole() != expectedRole) throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PROJ_050.message("Only the delivery projection can be converted")); //$NON-NLS-1$ if (transaction.getOwner() != portfolio) @@ -187,16 +186,16 @@ private void preflightDelivery(LedgerEntry entry, LedgerProjectionRef projection if (entry.getPostings().stream().anyMatch(posting -> posting.getType() == LedgerPostingType.CASH)) throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_053.message("Ledger delivery entry must not already have a cash posting")); //$NON-NLS-1$ - if (entry.getProjectionRefs().stream().anyMatch(projection -> projection.getRole() == LedgerProjectionRole.ACCOUNT)) + if (hasProjection(entry, LedgerProjectionRole.ACCOUNT)) throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_052.message("Ledger delivery entry must not already have an account projection")); //$NON-NLS-1$ var projection = uniqueProjection(entry, expectedRole); var securityPosting = requireOnePosting(entry, LedgerPostingType.SECURITY); - if (projection != projectionRef) + if (!projection.getRuntimeProjectionId().equals(descriptor.getRuntimeProjectionId())) throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_053.message("Selected projection is not the unique delivery projection")); //$NON-NLS-1$ - if (LedgerProjectionSupport.primaryPosting(entry, projection) != securityPosting) + if (projection.getPrimaryPosting() != securityPosting) throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_054.message("Delivery projection primary posting is ambiguous")); //$NON-NLS-1$ if (securityPosting.getPortfolio() != projection.getPortfolio()) @@ -206,14 +205,13 @@ private void preflightDelivery(LedgerEntry entry, LedgerProjectionRef projection reversedDeliveryAmount(entry, entry.getType()); } - private void applyBuySellToOppositeDelivery(LedgerEntry entry, String projectionUUID) + private void applyBuySellToOppositeDelivery(LedgerEntry entry) { var targetType = entry.getType() == LedgerEntryType.BUY ? LedgerEntryType.DELIVERY_OUTBOUND : LedgerEntryType.DELIVERY_INBOUND; var targetRole = role(targetType); var amount = reversedBuySellAmount(entry, entry.getType()); var securityPosting = requireOnePosting(entry, LedgerPostingType.SECURITY); - var portfolioProjection = projection(entry, projectionUUID); securityPosting.setAmount(amount.getAmount()); securityPosting.setCurrency(amount.getCurrencyCode()); @@ -222,24 +220,17 @@ private void applyBuySellToOppositeDelivery(LedgerEntry entry, String projection .filter(posting -> posting.getType() == LedgerPostingType.CASH) // .forEach(entry::removePosting); - List.copyOf(entry.getProjectionRefs()).stream() // - .filter(projection -> projection.getRole() == LedgerProjectionRole.ACCOUNT) // - .forEach(entry::removeProjectionRef); - - portfolioProjection.setRole(targetRole); + markPrimary(securityPosting, LedgerPostingSemanticRole.SECURITY, direction(targetRole), targetRole); entry.setType(targetType); } - private void applyDeliveryToOppositeBuySell(LedgerEntry entry, String portfolioProjectionUUID, Account account, - String accountProjectionUUID, String cashPostingUUID) + private void applyDeliveryToOppositeBuySell(LedgerEntry entry, Account account) { var targetType = entry.getType() == LedgerEntryType.DELIVERY_INBOUND ? LedgerEntryType.SELL : LedgerEntryType.BUY; var amount = reversedDeliveryAmount(entry, entry.getType()); var securityPosting = requireOnePosting(entry, LedgerPostingType.SECURITY); - var cashPosting = new LedgerPosting(cashPostingUUID); - var accountProjection = new LedgerProjectionRef(accountProjectionUUID); - var portfolioProjection = projection(entry, portfolioProjectionUUID); + var cashPosting = new LedgerPosting(); var unitPostings = entry.getPostings().stream() // .filter(posting -> posting != securityPosting) // .toList(); @@ -251,21 +242,16 @@ private void applyDeliveryToOppositeBuySell(LedgerEntry entry, String portfolioP cashPosting.setAccount(account); applyDeliveryCashPosting(securityPosting, cashPosting, account); - accountProjection.setRole(LedgerProjectionRole.ACCOUNT); - accountProjection.setAccount(account); - accountProjection.setPrimaryPosting(cashPosting); - - portfolioProjection.setRole(LedgerProjectionRole.PORTFOLIO); + markPrimary(cashPosting, LedgerPostingSemanticRole.CASH, cashDirection(targetType), + LedgerProjectionRole.ACCOUNT); + markPrimary(securityPosting, LedgerPostingSemanticRole.SECURITY, securityDirection(targetType), + LedgerProjectionRole.PORTFOLIO); List.copyOf(entry.getPostings()).forEach(entry::removePosting); entry.addPosting(cashPosting); entry.addPosting(securityPosting); unitPostings.forEach(entry::addPosting); - List.copyOf(entry.getProjectionRefs()).forEach(entry::removeProjectionRef); - entry.addProjectionRef(accountProjection); - entry.addProjectionRef(portfolioProjection); - entry.setType(targetType); } @@ -386,9 +372,10 @@ private LedgerPosting requireOnePosting(LedgerEntry entry, LedgerPostingType typ return postings.get(0); } - private LedgerProjectionRef uniqueProjection(LedgerEntry entry, LedgerProjectionRole role) + private DerivedProjectionDescriptor uniqueProjection(LedgerEntry entry, LedgerProjectionRole role) { - var projections = entry.getProjectionRefs().stream().filter(projection -> projection.getRole() == role).toList(); + var projections = LedgerProjectionSupport.descriptors(entry).stream() + .filter(projection -> projection.getRole() == role).toList(); if (projections.size() != 1) throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_056 @@ -398,13 +385,9 @@ private LedgerProjectionRef uniqueProjection(LedgerEntry entry, LedgerProjection return projections.get(0); } - private LedgerProjectionRef projection(LedgerEntry entry, String projectionUUID) + private boolean hasProjection(LedgerEntry entry, LedgerProjectionRole role) { - return entry.getProjectionRefs().stream() // - .filter(projection -> projectionUUID.equals(projection.getUUID())) // - .findFirst() - .orElseThrow(() -> new IllegalArgumentException( - "Ledger portfolio projection not found: " + projectionUUID)); //$NON-NLS-1$ + return LedgerProjectionSupport.descriptors(entry).stream().anyMatch(projection -> projection.getRole() == role); } private LedgerProjectionRole role(LedgerEntryType entryType) @@ -413,6 +396,50 @@ private LedgerProjectionRole role(LedgerEntryType entryType) : LedgerProjectionRole.DELIVERY_OUTBOUND; } + private void markPrimary(LedgerPosting posting, LedgerPostingSemanticRole semanticRole, + LedgerPostingDirection direction, LedgerProjectionRole role) + { + posting.setSemanticRole(semanticRole); + posting.setDirection(direction); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + posting.setLocalKey(role.name()); + } + + private LedgerPostingDirection cashDirection(LedgerEntryType entryType) + { + return switch (entryType) + { + case BUY -> LedgerPostingDirection.OUTBOUND; + case SELL -> LedgerPostingDirection.INBOUND; + default -> LedgerPostingDirection.NEUTRAL; + }; + } + + private LedgerPostingDirection securityDirection(LedgerEntryType entryType) + { + return switch (entryType) + { + case BUY -> LedgerPostingDirection.INBOUND; + case SELL -> LedgerPostingDirection.OUTBOUND; + default -> LedgerPostingDirection.NEUTRAL; + }; + } + + private LedgerPostingDirection direction(LedgerProjectionRole role) + { + return switch (role) + { + case DELIVERY_OUTBOUND -> LedgerPostingDirection.OUTBOUND; + case DELIVERY_INBOUND -> LedgerPostingDirection.INBOUND; + default -> LedgerPostingDirection.NEUTRAL; + }; + } + + private String runtimeProjectionId(LedgerEntry entry, LedgerProjectionRole role) + { + return entry.getUUID() + ":" + role; //$NON-NLS-1$ + } + private PortfolioTransaction find(Portfolio portfolio, String projectionUUID) { return portfolio.getTransactions().stream() // diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferEditor.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferEditor.java index 61dff64d37..09d207a15d 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferEditor.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferEditor.java @@ -6,7 +6,6 @@ import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerEntryEditSupport; import name.abuchen.portfolio.model.ledger.LedgerEntryMetadataPatchHelper; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedPortfolioTransaction; @@ -35,16 +34,15 @@ public void apply(LedgerEntry entry, LedgerPortfolioTransferEdit edit) throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_057 .message("Unsupported portfolio transfer edit for " + entry.getType())); //$NON-NLS-1$ - var sourceProjection = projection(entry, LedgerProjectionRole.SOURCE_PORTFOLIO); - var targetProjection = projection(entry, LedgerProjectionRole.TARGET_PORTFOLIO); - var sourcePostingUUID = LedgerProjectionSupport.primaryPosting(entry, sourceProjection).getUUID(); - var targetPostingUUID = LedgerProjectionSupport.primaryPosting(entry, targetProjection).getUUID(); + var sourceProjection = LedgerProjectionSupport.descriptor(entry, LedgerProjectionRole.SOURCE_PORTFOLIO); + var targetProjection = LedgerProjectionSupport.descriptor(entry, LedgerProjectionRole.TARGET_PORTFOLIO); + var sourcePostingUUID = sourceProjection.getPrimaryPosting().getUUID(); + var targetPostingUUID = targetProjection.getPrimaryPosting().getUUID(); var sourcePortfolio = sourceProjection.getPortfolio(); var targetPortfolio = targetProjection.getPortfolio(); LedgerEntryEditSupport.applyValidated(entry, editedEntry -> applyEdit(editedEntry, edit, - sourceProjection.getUUID(), sourcePortfolio, targetProjection.getUUID(), targetPortfolio, - sourcePostingUUID, targetPostingUUID)); + sourcePortfolio, targetPortfolio, sourcePostingUUID, targetPostingUUID)); } public void validate(LedgerEntry entry, LedgerPortfolioTransferEdit edit) @@ -56,48 +54,34 @@ public void validate(LedgerEntry entry, LedgerPortfolioTransferEdit edit) throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_058 .message("Unsupported portfolio transfer edit for " + entry.getType())); //$NON-NLS-1$ - var sourceProjection = projection(entry, LedgerProjectionRole.SOURCE_PORTFOLIO); - var targetProjection = projection(entry, LedgerProjectionRole.TARGET_PORTFOLIO); - var sourcePostingUUID = LedgerProjectionSupport.primaryPosting(entry, sourceProjection).getUUID(); - var targetPostingUUID = LedgerProjectionSupport.primaryPosting(entry, targetProjection).getUUID(); + var sourceProjection = LedgerProjectionSupport.descriptor(entry, LedgerProjectionRole.SOURCE_PORTFOLIO); + var targetProjection = LedgerProjectionSupport.descriptor(entry, LedgerProjectionRole.TARGET_PORTFOLIO); + var sourcePostingUUID = sourceProjection.getPrimaryPosting().getUUID(); + var targetPostingUUID = targetProjection.getPrimaryPosting().getUUID(); var sourcePortfolio = sourceProjection.getPortfolio(); var targetPortfolio = targetProjection.getPortfolio(); LedgerEntryEditSupport.validatePatch(entry, editedEntry -> applyEdit(editedEntry, edit, - sourceProjection.getUUID(), sourcePortfolio, targetProjection.getUUID(), targetPortfolio, - sourcePostingUUID, targetPostingUUID)); + sourcePortfolio, targetPortfolio, sourcePostingUUID, targetPostingUUID)); } - private void applyEdit(LedgerEntry editedEntry, LedgerPortfolioTransferEdit edit, String sourceProjectionUUID, - name.abuchen.portfolio.model.Portfolio sourcePortfolio, String targetProjectionUUID, - name.abuchen.portfolio.model.Portfolio targetPortfolio, String sourcePostingUUID, - String targetPostingUUID) + private void applyEdit(LedgerEntry editedEntry, LedgerPortfolioTransferEdit edit, + name.abuchen.portfolio.model.Portfolio sourcePortfolio, + name.abuchen.portfolio.model.Portfolio targetPortfolio, + String sourcePostingUUID, String targetPostingUUID) { LedgerEntryMetadataPatchHelper.apply(editedEntry, edit.getMetadata()); edit.getSourcePosting().applyTo(LedgerEntryEditSupport.postingByUUID(editedEntry, sourcePostingUUID)); edit.getTargetPosting().applyTo(LedgerEntryEditSupport.postingByUUID(editedEntry, targetPostingUUID)); unitPostingUpdater.apply(editedEntry, edit.getUnits()); - ensureOwners(editedEntry, sourceProjectionUUID, sourcePortfolio, targetProjectionUUID, targetPortfolio); + ensureOwners(editedEntry, sourcePortfolio, targetPortfolio); } - private LedgerProjectionRef projection(LedgerEntry entry, LedgerProjectionRole role) - { - return entry.getProjectionRefs().stream() // - .filter(projection -> projection.getRole() == role) // - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("Projection not found: " + role)); //$NON-NLS-1$ - } - - private void ensureOwners(LedgerEntry entry, String sourceProjectionUUID, - name.abuchen.portfolio.model.Portfolio source, String targetProjectionUUID, + private void ensureOwners(LedgerEntry entry, name.abuchen.portfolio.model.Portfolio source, name.abuchen.portfolio.model.Portfolio target) { - var sourceProjection = entry.getProjectionRefs().stream() - .filter(projection -> projection.getUUID().equals(sourceProjectionUUID)).findFirst() - .orElseThrow(); - var targetProjection = entry.getProjectionRefs().stream() - .filter(projection -> projection.getUUID().equals(targetProjectionUUID)).findFirst() - .orElseThrow(); + var sourceProjection = LedgerProjectionSupport.descriptor(entry, LedgerProjectionRole.SOURCE_PORTFOLIO); + var targetProjection = LedgerProjectionSupport.descriptor(entry, LedgerProjectionRole.TARGET_PORTFOLIO); if (sourceProjection.getPortfolio() != source || targetProjection.getPortfolio() != target) throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_057 diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferTransactionCreator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferTransactionCreator.java index ec161546c0..af5e4cf262 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferTransactionCreator.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferTransactionCreator.java @@ -108,8 +108,8 @@ public PortfolioTransferEntry update(PortfolioTransferEntry entry, Portfolio sou var targetTransaction = (LedgerBackedPortfolioTransaction) entry.getTargetTransaction(); var ledgerEntry = sourceTransaction.getLedgerEntry(); - var sourceProjectionUUID = sourceTransaction.getLedgerProjectionRef().getUUID(); - var targetProjectionUUID = targetTransaction.getLedgerProjectionRef().getUUID(); + var sourceProjectionUUID = sourceTransaction.getUUID(); + var targetProjectionUUID = targetTransaction.getUUID(); var ownerPatchHelper = new LedgerOwnerPatchHelper(client); var editBuilder = LedgerPortfolioTransferEdit.builder() @@ -131,14 +131,14 @@ public PortfolioTransferEntry update(PortfolioTransferEntry entry, Portfolio sou var edit = editBuilder.build(); var editor = new LedgerPortfolioTransferEditor(); - if (sourceTransaction.getLedgerProjectionRef().getPortfolio() != sourcePortfolio - || targetTransaction.getLedgerProjectionRef().getPortfolio() != targetPortfolio) + if (sourceTransaction.getLedgerProjectionDescriptor().getPortfolio() != sourcePortfolio + || targetTransaction.getLedgerProjectionDescriptor().getPortfolio() != targetPortfolio) editor.validate(ledgerEntry, edit); - if (sourceTransaction.getLedgerProjectionRef().getPortfolio() != sourcePortfolio) + if (sourceTransaction.getLedgerProjectionDescriptor().getPortfolio() != sourcePortfolio) ownerPatchHelper.movePortfolioTransferSource(ledgerEntry, sourcePortfolio); - if (targetTransaction.getLedgerProjectionRef().getPortfolio() != targetPortfolio) + if (targetTransaction.getLedgerProjectionDescriptor().getPortfolio() != targetPortfolio) ownerPatchHelper.movePortfolioTransferTarget(ledgerEntry, targetPortfolio); sourceTransaction = (LedgerBackedPortfolioTransaction) find(sourcePortfolio, sourceProjectionUUID); @@ -179,16 +179,8 @@ private void applyTargetForex(LedgerPortfolioTransferEdit.Builder builder, Ledge private PortfolioTransferEntry materializeAndWrap(Portfolio sourcePortfolio, Portfolio targetPortfolio, LedgerTransactionCreator.CreatedTransaction created) { - var sourceProjectionUUID = created.getProjectionRefs().stream() - .filter(projection -> projection.getRole() == LedgerProjectionRole.SOURCE_PORTFOLIO) - .findFirst() - .orElseThrow() - .getUUID(); - var targetProjectionUUID = created.getProjectionRefs().stream() - .filter(projection -> projection.getRole() == LedgerProjectionRole.TARGET_PORTFOLIO) - .findFirst() - .orElseThrow() - .getUUID(); + var sourceProjectionUUID = created.getRuntimeProjectionId(LedgerProjectionRole.SOURCE_PORTFOLIO); + var targetProjectionUUID = created.getRuntimeProjectionId(LedgerProjectionRole.TARGET_PORTFOLIO); LedgerProjectionService.materialize(client); diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerShareAdjustmentHelper.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerShareAdjustmentHelper.java index 36164a6394..59ae50d127 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerShareAdjustmentHelper.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerShareAdjustmentHelper.java @@ -4,9 +4,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Objects; -import java.util.Optional; import java.util.function.LongUnaryOperator; -import java.util.stream.Stream; import name.abuchen.portfolio.model.LedgerDiagnosticCode; import name.abuchen.portfolio.model.Client; @@ -17,7 +15,6 @@ import name.abuchen.portfolio.model.ledger.LedgerModelCopy; import name.abuchen.portfolio.model.ledger.LedgerMutationContext; import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; @@ -68,45 +65,7 @@ public static Plan emptyPlan() private static LedgerPosting primaryPosting(LedgerBackedTransaction transaction) { - var entry = transaction.getLedgerEntry(); - var projectionRef = transaction.getLedgerProjectionRef(); - - if (projectionRef.getPrimaryPostingUUID() != null) - return requirePostingInEntry(entry, projectionRef.getPrimaryPostingUUID()); - - if (entry.getType().requiresTargetedProjectionRefs()) - throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_058 - .message("Targeted Ledger projection has no primary posting: " + projectionRef.getUUID())); //$NON-NLS-1$ - - Optional posting = switch (projectionRef.getRole()) - { - case SOURCE_ACCOUNT -> accountPostings(entry, projectionRef).findFirst(); - case TARGET_ACCOUNT -> last(accountPostings(entry, projectionRef)); - case SOURCE_PORTFOLIO -> portfolioPostings(entry, projectionRef).findFirst(); - case TARGET_PORTFOLIO -> last(portfolioPostings(entry, projectionRef)); - case ACCOUNT, CASH_COMPENSATION -> accountPostings(entry, projectionRef).findFirst(); - case PORTFOLIO, DELIVERY, DELIVERY_INBOUND, DELIVERY_OUTBOUND, OLD_SECURITY_LEG, NEW_SECURITY_LEG -> - portfolioPostings(entry, projectionRef).findFirst(); - }; - - return posting.orElseThrow(() -> new IllegalArgumentException( - "No primary Ledger posting for projection " + projectionRef.getUUID())); //$NON-NLS-1$ - } - - private static Stream accountPostings(LedgerEntry entry, LedgerProjectionRef projectionRef) - { - return entry.getPostings().stream().filter(posting -> posting.getAccount() == projectionRef.getAccount()); - } - - private static Stream portfolioPostings(LedgerEntry entry, LedgerProjectionRef projectionRef) - { - return entry.getPostings().stream().filter(posting -> posting.getPortfolio() == projectionRef.getPortfolio()); - } - - private static Optional last(Stream stream) - { - var postings = stream.toList(); - return postings.isEmpty() ? Optional.empty() : Optional.of(postings.get(postings.size() - 1)); + return transaction.getLedgerProjectionDescriptor().getPrimaryPosting(); } private static LedgerEntry requireEntryInLedger(Ledger ledger, String uuid) diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerTransactionCreator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerTransactionCreator.java index 559ff2dd83..fd1da62249 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerTransactionCreator.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerTransactionCreator.java @@ -5,9 +5,7 @@ import java.util.Objects; import name.abuchen.portfolio.model.LedgerDiagnosticCode; -import name.abuchen.portfolio.model.Account; import name.abuchen.portfolio.model.Client; -import name.abuchen.portfolio.model.Portfolio; import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerMutationContext; import name.abuchen.portfolio.model.ledger.LedgerParameter; @@ -15,10 +13,8 @@ import name.abuchen.portfolio.model.ledger.LedgerPostingDirection; import name.abuchen.portfolio.model.ledger.LedgerPostingSemanticRole; import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.LedgerTransactionMetadata; -import name.abuchen.portfolio.model.ledger.ProjectionMembershipRole; import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; @@ -120,10 +116,7 @@ public CreatedTransaction createDividend(LedgerTransactionMetadata metadata, Led dividend.getExDate())); entry.addPosting(cashPosting); - var unitPostings = addUnitPostings(entry, dividend.getUnits()); - var projection = accountProjection(dividend.getCashLeg().getAccount(), cashPosting); - addUnitMemberships(projection, unitPostings); - entry.addProjectionRef(projection); + addUnitPostings(entry, dividend.getUnits()); return add(entry); } @@ -175,10 +168,6 @@ LedgerEntry createAccountTransferEntry(LedgerTransactionMetadata metadata, Ledge entry.addPosting(sourcePosting); entry.addPosting(targetPosting); - entry.addProjectionRef(accountProjection(LedgerProjectionRole.SOURCE_ACCOUNT, source.getAccount(), - sourcePosting)); - entry.addProjectionRef(accountProjection(LedgerProjectionRole.TARGET_ACCOUNT, target.getAccount(), - targetPosting)); return entry; } @@ -209,10 +198,6 @@ LedgerEntry createPortfolioTransferEntry(LedgerTransactionMetadata metadata, entry.addPosting(sourcePosting); entry.addPosting(targetPosting); - entry.addProjectionRef(portfolioProjection(LedgerProjectionRole.SOURCE_PORTFOLIO, source.getPortfolio(), - sourcePosting)); - entry.addProjectionRef(portfolioProjection(LedgerProjectionRole.TARGET_PORTFOLIO, target.getPortfolio(), - targetPosting)); return entry; } @@ -232,10 +217,7 @@ private LedgerEntry createAccountEntry(LedgerTransactionMetadata metadata, Ledge markPrimary(posting, semanticRole(postingType), LedgerPostingDirection.NEUTRAL, LedgerProjectionRole.ACCOUNT); entry.addPosting(posting); - var unitPostings = addUnitPostings(entry, units); - var projection = accountProjection(cashLeg.getAccount(), posting); - addUnitMemberships(projection, unitPostings); - entry.addProjectionRef(projection); + addUnitPostings(entry, units); return entry; } @@ -258,10 +240,7 @@ private LedgerEntry createDeliveryEntry(LedgerTransactionMetadata metadata, Ledg markPrimary(posting, LedgerPostingSemanticRole.SECURITY, direction(role), role); entry.addPosting(posting); - var unitPostings = addUnitPostings(entry, deliveryLeg.getUnits()); - var projection = portfolioProjection(role, deliveryLeg.getPortfolio(), posting); - addUnitMemberships(projection, unitPostings); - entry.addProjectionRef(projection); + addUnitPostings(entry, deliveryLeg.getUnits()); return entry; } @@ -284,14 +263,7 @@ private LedgerEntry createBuySellEntry(LedgerTransactionMetadata metadata, Ledge entry.addPosting(cashPosting); entry.addPosting(securityPosting); - var unitPostings = addUnitPostings(entry, units); - var accountProjection = accountProjection(cashLeg.getAccount(), cashPosting); - var portfolioProjection = portfolioProjection(LedgerProjectionRole.PORTFOLIO, securityLeg.getPortfolio(), - securityPosting); - addUnitMemberships(accountProjection, unitPostings); - addUnitMemberships(portfolioProjection, unitPostings); - entry.addProjectionRef(accountProjection); - entry.addProjectionRef(portfolioProjection); + addUnitPostings(entry, units); return entry; } @@ -378,20 +350,6 @@ private List addUnitPostings(LedgerEntry entry, LedgerCreationUni return List.copyOf(postings); } - private void addUnitMemberships(LedgerProjectionRef projection, List unitPostings) - { - for (var posting : unitPostings) - { - switch (posting.getType()) - { - case FEE -> projection.addMembership(posting.getUUID(), ProjectionMembershipRole.FEE_UNIT); - case TAX -> projection.addMembership(posting.getUUID(), ProjectionMembershipRole.TAX_UNIT); - case GROSS_VALUE -> projection.addMembership(posting.getUUID(), ProjectionMembershipRole.GROSS_VALUE_UNIT); - default -> throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_062.message("Unsupported unit posting type: " + posting.getType())); //$NON-NLS-1$ - } - } - } - private void applyMoney(LedgerPosting posting, Money amount, LedgerForexAmount forex) { posting.setAmount(amount.getAmount()); @@ -488,34 +446,6 @@ private LedgerPostingDirection direction(LedgerProjectionRole role) }; } - private LedgerProjectionRef accountProjection(Account account, LedgerPosting posting) - { - return accountProjection(LedgerProjectionRole.ACCOUNT, account, posting); - } - - private LedgerProjectionRef accountProjection(LedgerProjectionRole role, Account account, LedgerPosting posting) - { - var projection = new LedgerProjectionRef(); - - projection.setRole(role); - projection.setAccount(account); - projection.setPrimaryPosting(posting); - - return projection; - } - - private LedgerProjectionRef portfolioProjection(LedgerProjectionRole role, Portfolio portfolio, - LedgerPosting posting) - { - var projection = new LedgerProjectionRef(); - - projection.setRole(role); - projection.setPortfolio(portfolio); - projection.setPrimaryPosting(posting); - - return projection; - } - CreatedTransaction add(LedgerEntry entry) { var liveEntry = new LedgerMutationContext(client).attachEntry(entry); @@ -537,9 +467,9 @@ public LedgerEntry getEntry() return entry; } - public List getProjectionRefs() + public String getRuntimeProjectionId(LedgerProjectionRole role) { - return entry.getProjectionRefs(); + return entry.getUUID() + ":" + role; //$NON-NLS-1$ } } } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerTransferDirectionConverter.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerTransferDirectionConverter.java index 660bcbb849..06cab64293 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerTransferDirectionConverter.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerTransferDirectionConverter.java @@ -14,9 +14,10 @@ import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerMutationContext; import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerPostingDirection; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedAccountTransaction; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedPortfolioTransaction; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; @@ -46,10 +47,10 @@ public AccountTransferEntry reverse(AccountTransferEntry transfer) throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_CONVERT_063.message("Only ledger-backed account transfers can be reversed")); //$NON-NLS-1$ var entry = sourceTransaction.getLedgerEntry(); - var sourceProjectionUUID = sourceTransaction.getLedgerProjectionRef().getUUID(); - var targetProjectionUUID = targetTransaction.getLedgerProjectionRef().getUUID(); - var sourceAccount = sourceTransaction.getLedgerProjectionRef().getAccount(); - var targetAccount = targetTransaction.getLedgerProjectionRef().getAccount(); + var sourceProjectionUUID = sourceTransaction.getRuntimeProjectionId(); + var targetProjectionUUID = targetTransaction.getRuntimeProjectionId(); + var sourceAccount = sourceTransaction.getLedgerProjectionDescriptor().getAccount(); + var targetAccount = targetTransaction.getLedgerProjectionDescriptor().getAccount(); preflightAccountTransfer(entry, sourceTransaction, targetTransaction); var sourceRoleChange = LedgerInvestmentPlanRefSupport.roleChange(sourceProjectionUUID, @@ -61,8 +62,9 @@ public AccountTransferEntry reverse(AccountTransferEntry transfer) new LedgerMutationContext(client).mutateEntry(entry, this::reverseAccountTransfer); LedgerInvestmentPlanRefSupport.updateProjectionRoles(client, entry, sourceRoleChange, targetRoleChange); - return AccountTransferEntry.readOnly(targetAccount, find(targetAccount, targetProjectionUUID), sourceAccount, - find(sourceAccount, sourceProjectionUUID)); + return AccountTransferEntry.readOnly(targetAccount, + find(targetAccount, entry, LedgerProjectionRole.SOURCE_ACCOUNT), sourceAccount, + find(sourceAccount, entry, LedgerProjectionRole.TARGET_ACCOUNT)); } public PortfolioTransferEntry reverse(PortfolioTransferEntry transfer) @@ -74,10 +76,10 @@ public PortfolioTransferEntry reverse(PortfolioTransferEntry transfer) throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_CONVERT_064.message("Only ledger-backed portfolio transfers can be reversed")); //$NON-NLS-1$ var entry = sourceTransaction.getLedgerEntry(); - var sourceProjectionUUID = sourceTransaction.getLedgerProjectionRef().getUUID(); - var targetProjectionUUID = targetTransaction.getLedgerProjectionRef().getUUID(); - var sourcePortfolio = sourceTransaction.getLedgerProjectionRef().getPortfolio(); - var targetPortfolio = targetTransaction.getLedgerProjectionRef().getPortfolio(); + var sourceProjectionUUID = sourceTransaction.getRuntimeProjectionId(); + var targetProjectionUUID = targetTransaction.getRuntimeProjectionId(); + var sourcePortfolio = sourceTransaction.getLedgerProjectionDescriptor().getPortfolio(); + var targetPortfolio = targetTransaction.getLedgerProjectionDescriptor().getPortfolio(); preflightPortfolioTransfer(entry, sourceTransaction, targetTransaction); var sourceRoleChange = LedgerInvestmentPlanRefSupport.roleChange(sourceProjectionUUID, @@ -89,8 +91,9 @@ public PortfolioTransferEntry reverse(PortfolioTransferEntry transfer) new LedgerMutationContext(client).mutateEntry(entry, this::reversePortfolioTransfer); LedgerInvestmentPlanRefSupport.updateProjectionRoles(client, entry, sourceRoleChange, targetRoleChange); - return PortfolioTransferEntry.readOnly(targetPortfolio, find(targetPortfolio, targetProjectionUUID), - sourcePortfolio, find(sourcePortfolio, sourceProjectionUUID)); + return PortfolioTransferEntry.readOnly(targetPortfolio, + find(targetPortfolio, entry, LedgerProjectionRole.SOURCE_PORTFOLIO), + sourcePortfolio, find(sourcePortfolio, entry, LedgerProjectionRole.TARGET_PORTFOLIO)); } private void preflightAccountTransfer(LedgerEntry entry, LedgerBackedAccountTransaction sourceTransaction, @@ -102,14 +105,14 @@ private void preflightAccountTransfer(LedgerEntry entry, LedgerBackedAccountTran if (sourceTransaction.getLedgerEntry() != targetTransaction.getLedgerEntry()) throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_059.message("Transfer projections do not belong to the same ledger entry")); //$NON-NLS-1$ - if (sourceTransaction.getLedgerProjectionRef().getRole() != LedgerProjectionRole.SOURCE_ACCOUNT - || targetTransaction.getLedgerProjectionRef().getRole() != LedgerProjectionRole.TARGET_ACCOUNT) + if (sourceTransaction.getLedgerProjectionRole() != LedgerProjectionRole.SOURCE_ACCOUNT + || targetTransaction.getLedgerProjectionRole() != LedgerProjectionRole.TARGET_ACCOUNT) throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_060.message("Account transfer projections are not in source/target order")); //$NON-NLS-1$ var sourceProjection = uniqueProjection(entry, LedgerProjectionRole.SOURCE_ACCOUNT); var targetProjection = uniqueProjection(entry, LedgerProjectionRole.TARGET_ACCOUNT); - var sourcePosting = LedgerProjectionSupport.primaryPosting(entry, sourceProjection); - var targetPosting = LedgerProjectionSupport.primaryPosting(entry, targetProjection); + var sourcePosting = sourceProjection.getPrimaryPosting(); + var targetPosting = targetProjection.getPrimaryPosting(); if (sourcePosting == targetPosting) throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_066.message("Account transfer source and target postings are ambiguous")); //$NON-NLS-1$ @@ -128,14 +131,14 @@ private void preflightPortfolioTransfer(LedgerEntry entry, LedgerBackedPortfolio if (sourceTransaction.getLedgerEntry() != targetTransaction.getLedgerEntry()) throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_062.message("Transfer projections do not belong to the same ledger entry")); //$NON-NLS-1$ - if (sourceTransaction.getLedgerProjectionRef().getRole() != LedgerProjectionRole.SOURCE_PORTFOLIO - || targetTransaction.getLedgerProjectionRef().getRole() != LedgerProjectionRole.TARGET_PORTFOLIO) + if (sourceTransaction.getLedgerProjectionRole() != LedgerProjectionRole.SOURCE_PORTFOLIO + || targetTransaction.getLedgerProjectionRole() != LedgerProjectionRole.TARGET_PORTFOLIO) throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_063.message("Portfolio transfer projections are not in source/target order")); //$NON-NLS-1$ var sourceProjection = uniqueProjection(entry, LedgerProjectionRole.SOURCE_PORTFOLIO); var targetProjection = uniqueProjection(entry, LedgerProjectionRole.TARGET_PORTFOLIO); - var sourcePosting = LedgerProjectionSupport.primaryPosting(entry, sourceProjection); - var targetPosting = LedgerProjectionSupport.primaryPosting(entry, targetProjection); + var sourcePosting = sourceProjection.getPrimaryPosting(); + var targetPosting = targetProjection.getPrimaryPosting(); if (sourcePosting == targetPosting) throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_068.message("Portfolio transfer source and target postings are ambiguous")); //$NON-NLS-1$ @@ -149,13 +152,13 @@ private void reverseAccountTransfer(LedgerEntry entry) { var sourceProjection = uniqueProjection(entry, LedgerProjectionRole.SOURCE_ACCOUNT); var targetProjection = uniqueProjection(entry, LedgerProjectionRole.TARGET_ACCOUNT); - var sourcePosting = LedgerProjectionSupport.primaryPosting(entry, sourceProjection); - var targetPosting = LedgerProjectionSupport.primaryPosting(entry, targetProjection); + var sourcePosting = sourceProjection.getPrimaryPosting(); + var targetPosting = targetProjection.getPrimaryPosting(); reverseAccountTransferForex(sourcePosting, targetPosting); - sourceProjection.setRole(LedgerProjectionRole.TARGET_ACCOUNT); - targetProjection.setRole(LedgerProjectionRole.SOURCE_ACCOUNT); + markPrimary(sourcePosting, LedgerProjectionRole.TARGET_ACCOUNT); + markPrimary(targetPosting, LedgerProjectionRole.SOURCE_ACCOUNT); } private void reverseAccountTransferForex(LedgerPosting oldSourcePosting, LedgerPosting oldTargetPosting) @@ -209,13 +212,30 @@ private void reversePortfolioTransfer(LedgerEntry entry) var sourceProjection = uniqueProjection(entry, LedgerProjectionRole.SOURCE_PORTFOLIO); var targetProjection = uniqueProjection(entry, LedgerProjectionRole.TARGET_PORTFOLIO); - sourceProjection.setRole(LedgerProjectionRole.TARGET_PORTFOLIO); - targetProjection.setRole(LedgerProjectionRole.SOURCE_PORTFOLIO); + markPrimary(sourceProjection.getPrimaryPosting(), LedgerProjectionRole.TARGET_PORTFOLIO); + markPrimary(targetProjection.getPrimaryPosting(), LedgerProjectionRole.SOURCE_PORTFOLIO); } - private LedgerProjectionRef uniqueProjection(LedgerEntry entry, LedgerProjectionRole role) + private void markPrimary(LedgerPosting posting, LedgerProjectionRole role) { - var projections = entry.getProjectionRefs().stream().filter(projection -> projection.getRole() == role).toList(); + posting.setDirection(direction(role)); + posting.setLocalKey(role.name()); + } + + private LedgerPostingDirection direction(LedgerProjectionRole role) + { + return switch (role) + { + case SOURCE_ACCOUNT, SOURCE_PORTFOLIO -> LedgerPostingDirection.OUTBOUND; + case TARGET_ACCOUNT, TARGET_PORTFOLIO -> LedgerPostingDirection.INBOUND; + default -> LedgerPostingDirection.NEUTRAL; + }; + } + + private DerivedProjectionDescriptor uniqueProjection(LedgerEntry entry, LedgerProjectionRole role) + { + var projections = LedgerProjectionSupport.descriptors(entry).stream() + .filter(projection -> projection.getRole() == role).toList(); if (projections.size() != 1) throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_065 @@ -236,6 +256,19 @@ private AccountTransaction find(Account account, String projectionUUID) + projectionUUID)); } + private AccountTransaction find(Account account, LedgerEntry entry, LedgerProjectionRole role) + { + return account.getTransactions().stream() // + .filter(LedgerBackedAccountTransaction.class::isInstance) // + .map(LedgerBackedAccountTransaction.class::cast) // + .filter(transaction -> transaction.getLedgerEntry() == entry) // + .filter(transaction -> transaction.getLedgerProjectionRole() == role) // + .findFirst() + .orElseThrow(() -> new IllegalStateException( + "Ledger account transfer projection was not materialized: " //$NON-NLS-1$ + + entry.getUUID() + ":" + role)); //$NON-NLS-1$ + } + private PortfolioTransaction find(Portfolio portfolio, String projectionUUID) { return portfolio.getTransactions().stream() // @@ -246,4 +279,17 @@ private PortfolioTransaction find(Portfolio portfolio, String projectionUUID) "Ledger portfolio transfer projection was not materialized: " //$NON-NLS-1$ + projectionUUID)); } + + private PortfolioTransaction find(Portfolio portfolio, LedgerEntry entry, LedgerProjectionRole role) + { + return portfolio.getTransactions().stream() // + .filter(LedgerBackedPortfolioTransaction.class::isInstance) // + .map(LedgerBackedPortfolioTransaction.class::cast) // + .filter(transaction -> transaction.getLedgerEntry() == entry) // + .filter(transaction -> transaction.getLedgerProjectionRole() == role) // + .findFirst() + .orElseThrow(() -> new IllegalStateException( + "Ledger portfolio transfer projection was not materialized: " //$NON-NLS-1$ + + entry.getUUID() + ":" + role)); //$NON-NLS-1$ + } } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerUnitPostingUpdater.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerUnitPostingUpdater.java index 6242a5a4dc..319417e5ad 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerUnitPostingUpdater.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerUnitPostingUpdater.java @@ -8,7 +8,6 @@ import name.abuchen.portfolio.model.ledger.LedgerPosting; import name.abuchen.portfolio.model.ledger.LedgerPostingSemanticRole; import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; -import name.abuchen.portfolio.model.ledger.ProjectionMembershipRole; /** * Updates fee, tax, and forex unit postings on ledger-backed transactions. @@ -25,7 +24,7 @@ public void apply(LedgerEntry entry, LedgerUnitPostingPatch patch) LedgerEntryEditSupport.applyValidated(entry, editedEntry -> applyDirect(editedEntry, patch)); } - private void applyDirect(LedgerEntry entry, LedgerUnitPostingPatch patch) + void applyDirect(LedgerEntry entry, LedgerUnitPostingPatch patch) { for (var edit : patch.getEdits()) { @@ -49,7 +48,6 @@ private void add(LedgerEntry entry, LedgerUnitPostingEdit edit) edit.getPostingPatch().applyTo(posting); markUnit(posting); entry.addPosting(posting); - addUnitMemberships(entry, posting); } private void update(LedgerEntry entry, LedgerUnitPostingEdit edit) @@ -66,30 +64,6 @@ private void remove(LedgerEntry entry, LedgerUnitPostingEdit edit) LedgerUnitPostingEdit.requireUnitType(posting.getType()); entry.removePosting(posting); - removeUnitMemberships(entry, posting); - } - - private void addUnitMemberships(LedgerEntry entry, LedgerPosting posting) - { - var role = unitMembershipRole(posting); - - entry.getProjectionRefs().forEach(projection -> projection.addMembership(posting.getUUID(), role)); - } - - private void removeUnitMemberships(LedgerEntry entry, LedgerPosting posting) - { - entry.getProjectionRefs().forEach(projection -> projection.removeMembershipsForPostingUUID(posting.getUUID())); - } - - private ProjectionMembershipRole unitMembershipRole(LedgerPosting posting) - { - return switch (posting.getType()) - { - case FEE -> ProjectionMembershipRole.FEE_UNIT; - case TAX -> ProjectionMembershipRole.TAX_UNIT; - case GROSS_VALUE -> ProjectionMembershipRole.GROSS_VALUE_UNIT; - default -> throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CONVERT_071.message("Unsupported unit posting type: " + posting.getType())); //$NON-NLS-1$ - }; } private void markUnit(LedgerPosting posting) diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java index ec34277ff7..dde7efdddc 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java @@ -15,10 +15,10 @@ import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerParameter; import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.configuration.rule.LedgerRequirement; import name.abuchen.portfolio.model.ledger.configuration.rule.LedgerRequirementGroup; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionSupport; /** * Validates native Ledger entries against Java-owned entry and leg definitions. @@ -215,7 +215,17 @@ private static LegMatch matchProjectedLeg(LedgerEntry entry, LedgerEntryDefiniti LedgerLegDefinition leg, LedgerProjectionRole projectionRole, Map postingsByUUID, List issues) { - var refs = entry.getProjectionRefs().stream().filter(ref -> ref.getRole() == projectionRole).toList(); + var descriptors = Collections.emptyList(); + try + { + descriptors = LedgerProjectionSupport.descriptors(entry); + } + catch (IllegalArgumentException ignore) + { + // Malformed semantic descriptors are reported below as native validation issues. + } + + var refs = descriptors.stream().filter(ref -> ref.getRole() == projectionRole).toList(); var matchingLegPostings = entry.getPostings().stream() .filter(posting -> postingMatchesLeg(entry.getType(), posting, leg)).toList(); var matchingPostings = new ArrayList(); @@ -230,18 +240,7 @@ private static LegMatch matchProjectedLeg(LedgerEntry entry, LedgerEntryDefiniti for (var ref : refs) { - if (leg.isPrimaryPostingExpected() && isBlank(ref.getPrimaryPostingUUID())) - { - issues.add(issue(IssueCode.PROJECTION_PRIMARY_POSTING_REQUIRED, - LedgerDiagnosticCode.LEDGER_STRUCT_043.message( - "Native leg projection requires a primary posting: " + projectionRole), //$NON-NLS-1$ - entry) - .withProjection(ref) - .withDetail("legRole", leg.getRole())); //$NON-NLS-1$ - continue; - } - - var posting = postingsByUUID.get(ref.getPrimaryPostingUUID()); + var posting = ref.getPrimaryPosting(); if (posting != null) { if (postingMatchesLeg(entry.getType(), posting, leg)) @@ -253,24 +252,19 @@ else if (!postingMatchesAnyLegWithProjectionRole(entry.getType(), posting, defin "Projection primary posting does not match native leg " //$NON-NLS-1$ + leg.getRole()), entry) - .withProjection(ref).withPosting(posting) + .withPosting(posting) .withDetail("legRole", leg.getRole())); //$NON-NLS-1$ } if (leg.isPostingGroupExpected()) { - if (isBlank(ref.getPostingGroupUUID())) + if (ref.getPrimaryPosting() == null || ref.getPrimaryPosting().getGroupKey() == null + || ref.getPrimaryPosting().getGroupKey().isBlank()) issues.add(issue(IssueCode.PROJECTION_POSTING_GROUP_REQUIRED, LedgerDiagnosticCode.LEDGER_STRUCT_045.message( "Native leg projection requires a posting group anchor: " //$NON-NLS-1$ + projectionRole), - entry).withProjection(ref).withDetail("legRole", leg.getRole())); //$NON-NLS-1$ - else if (!postingsByUUID.containsKey(ref.getPostingGroupUUID())) - issues.add(issue(IssueCode.PROJECTION_POSTING_GROUP_NOT_FOUND, - LedgerDiagnosticCode.LEDGER_STRUCT_046.message( - "Native leg projection posting group anchor does not exist: " //$NON-NLS-1$ - + ref.getPostingGroupUUID()), - entry).withProjection(ref).withDetail("legRole", leg.getRole())); //$NON-NLS-1$ + entry).withDetail("legRole", leg.getRole())); //$NON-NLS-1$ } } @@ -620,17 +614,6 @@ private ValidationIssue withPosting(LedgerPosting posting) .withDetail("postingType", posting.getType()); //$NON-NLS-1$ } - private ValidationIssue withProjection(LedgerProjectionRef projectionRef) - { - if (projectionRef == null) - return this; - - return withDetail("projectionUUID", projectionRef.getUUID()) //$NON-NLS-1$ - .withDetail("projectionRole", projectionRef.getRole()) //$NON-NLS-1$ - .withDetail("primaryPostingUUID", projectionRef.getPrimaryPostingUUID()) //$NON-NLS-1$ - .withDetail("postingGroupUUID", projectionRef.getPostingGroupUUID()); //$NON-NLS-1$ - } - private ValidationIssue withParameter(LedgerParameter parameter) { if (parameter == null) 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 index d58a757b08..9afd2bb1bd 100644 --- 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 @@ -25,23 +25,23 @@ public final class LedgerPostingGroupRule private final LedgerRequirement requirement; private final Set postingTypes; private final Set projectionRoles; - private final boolean postingGroupUUIDExpected; + private final boolean groupAnchorExpected; private LedgerPostingGroupRule(String name, LedgerRequirement requirement, Set postingTypes, - Set projectionRoles, boolean postingGroupUUIDExpected) + Set projectionRoles, boolean groupAnchorExpected) { this.name = requireName(name); this.requirement = Objects.requireNonNull(requirement); this.postingTypes = copyPostingTypes(postingTypes); this.projectionRoles = copyProjectionRoles(projectionRoles); - this.postingGroupUUIDExpected = postingGroupUUIDExpected; + this.groupAnchorExpected = groupAnchorExpected; } public static LedgerPostingGroupRule of(String name, LedgerRequirement requirement, Set postingTypes, Set projectionRoles, - boolean postingGroupUUIDExpected) + boolean groupAnchorExpected) { - return new LedgerPostingGroupRule(name, requirement, postingTypes, projectionRoles, postingGroupUUIDExpected); + return new LedgerPostingGroupRule(name, requirement, postingTypes, projectionRoles, groupAnchorExpected); } public String getName() @@ -74,9 +74,9 @@ public Set getProjectionRoles() return projectionRoles; } - public boolean isPostingGroupUUIDExpected() + public boolean isGroupAnchorExpected() { - return postingGroupUUIDExpected; + return groupAnchorExpected; } private static String requireName(String name) diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigrator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigrator.java index 26d24cb3be..b6148d7085 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigrator.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigrator.java @@ -4,7 +4,9 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.HashSet; +import java.util.IdentityHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.UUID; @@ -31,15 +33,14 @@ import name.abuchen.portfolio.model.ledger.LedgerPostingDirection; import name.abuchen.portfolio.model.ledger.LedgerPostingSemanticRole; import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; -import name.abuchen.portfolio.model.ledger.ProjectionMembershipRole; 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.model.ledger.projection.LedgerBackedTransaction; import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionService; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionSupport; /** * Migrates legacy transaction structures into persisted Ledger entries. @@ -141,7 +142,7 @@ private void migrateAccountOnly(Account account, AccountTransaction transaction, var projection = MigrationGraphBuilder.accountProjection(transaction.getUUID(), LedgerProjectionRole.ACCOUNT, account, posting.getUUID()); MigrationGraphBuilder.addUnitMemberships(projection, unitPostings); - MigrationGraphBuilder.addProjectionRef(entry, projection); + MigrationGraphBuilder.addProjectionView(entry, projection); if (plan.handleAlreadyMigratedCompleteGroup("ACCOUNT", entry, transaction)) //$NON-NLS-1$ return; @@ -233,8 +234,8 @@ private void migrateBuySell(BuySellEntry buySellEntry, MigrationPlan plan) LedgerProjectionRole.PORTFOLIO, portfolio, securityPosting.getUUID()); MigrationGraphBuilder.addUnitMemberships(accountProjection, unitPostings); MigrationGraphBuilder.addUnitMemberships(portfolioProjection, unitPostings); - MigrationGraphBuilder.addProjectionRef(entry, accountProjection); - MigrationGraphBuilder.addProjectionRef(entry, portfolioProjection); + MigrationGraphBuilder.addProjectionView(entry, accountProjection); + MigrationGraphBuilder.addProjectionView(entry, portfolioProjection); if (plan.handleAlreadyMigratedCompleteGroup("BUY_SELL", entry, accountTransaction, portfolioTransaction)) //$NON-NLS-1$ return; @@ -320,10 +321,10 @@ private void migrateAccountTransfer(AccountTransferEntry transferEntry, Migratio LedgerPostingDirection.INBOUND, LedgerProjectionRole.TARGET_ACCOUNT); MigrationGraphBuilder.addPosting(entry, sourcePosting); MigrationGraphBuilder.addPosting(entry, targetPosting); - MigrationGraphBuilder.addProjectionRef(entry, MigrationGraphBuilder.accountProjection( + MigrationGraphBuilder.addProjectionView(entry, MigrationGraphBuilder.accountProjection( sourceTransaction.getUUID(), LedgerProjectionRole.SOURCE_ACCOUNT, sourceAccount, sourcePosting.getUUID())); - MigrationGraphBuilder.addProjectionRef(entry, MigrationGraphBuilder.accountProjection( + MigrationGraphBuilder.addProjectionView(entry, MigrationGraphBuilder.accountProjection( targetTransaction.getUUID(), LedgerProjectionRole.TARGET_ACCOUNT, targetAccount, targetPosting.getUUID())); @@ -389,10 +390,10 @@ private void migratePortfolioTransfer(PortfolioTransferEntry transferEntry, Migr LedgerPostingDirection.INBOUND, LedgerProjectionRole.TARGET_PORTFOLIO); MigrationGraphBuilder.addPosting(entry, sourcePosting); MigrationGraphBuilder.addPosting(entry, targetPosting); - MigrationGraphBuilder.addProjectionRef(entry, MigrationGraphBuilder.portfolioProjection( + MigrationGraphBuilder.addProjectionView(entry, MigrationGraphBuilder.portfolioProjection( sourceTransaction.getUUID(), LedgerProjectionRole.SOURCE_PORTFOLIO, sourcePortfolio, sourcePosting.getUUID())); - MigrationGraphBuilder.addProjectionRef(entry, MigrationGraphBuilder.portfolioProjection( + MigrationGraphBuilder.addProjectionView(entry, MigrationGraphBuilder.portfolioProjection( targetTransaction.getUUID(), LedgerProjectionRole.TARGET_PORTFOLIO, targetPortfolio, targetPosting.getUUID())); @@ -504,7 +505,7 @@ private void migrateDelivery(Portfolio portfolio, PortfolioTransaction transacti var projection = MigrationGraphBuilder.portfolioProjection(transaction.getUUID(), role, portfolio, posting.getUUID()); MigrationGraphBuilder.addUnitMemberships(projection, unitPostings); - MigrationGraphBuilder.addProjectionRef(entry, projection); + MigrationGraphBuilder.addProjectionView(entry, projection); if (plan.handleAlreadyMigratedCompleteGroup("DELIVERY", entry, transaction)) //$NON-NLS-1$ return; @@ -540,7 +541,7 @@ private void applyPlan(Client client, MigrationPlan plan) { plan.getEntries().forEach(entry -> MigrationGraphBuilder.addEntry(client.getLedger(), entry)); convertInvestmentPlanTransactionsToLedgerRefs(client, plan); - removeMigratedLegacyTransactions(client, plan.getProjectionUUIDsToRemove(), plan.getLegacyTransactionsToRemove()); + removeMigratedLegacyTransactions(client, plan.getprojectionIdsToRemove(), plan.getLegacyTransactionsToRemove()); LedgerProjectionService.materialize(client); } @@ -552,45 +553,25 @@ private void convertInvestmentPlanTransactionsToLedgerRefs(Client client, Migrat { var transaction = investmentPlan.getTransactions().get(index); - if (!shouldRemove(transaction, plan.getProjectionUUIDsToRemove(), plan.getLegacyTransactionsToRemove())) + if (!shouldRemove(transaction, plan.getprojectionIdsToRemove(), plan.getLegacyTransactionsToRemove())) continue; - var ref = ledgerExecutionRef(client, transaction.getUUID()); + var entry = plan.getMigratedEntry(transaction); - if (ref == null) + if (entry == null) continue; - if (investmentPlan.getLedgerExecutionRefs().stream() - .noneMatch(existing -> sameExecutionRef(existing, ref))) - investmentPlan.addLedgerExecutionRef(ref); - + entry.setGeneratedByPlanKey(investmentPlan.getPlanKey()); + entry.setPlanExecutionDate(transaction.getDateTime().toLocalDate()); + entry.setPlanExecutionSequence(null); + entry.setPreferredViewKind(transaction instanceof PortfolioTransaction + ? InvestmentPlan.LedgerExecutionViewKind.PORTFOLIO.name() + : InvestmentPlan.LedgerExecutionViewKind.ACCOUNT.name()); investmentPlan.getTransactions().remove(index); } } } - private InvestmentPlan.LedgerExecutionRef ledgerExecutionRef(Client client, String projectionUUID) - { - for (var entry : client.getLedger().getEntries()) - { - for (var projection : entry.getProjectionRefs()) - { - if (projectionUUID.equals(projection.getUUID())) - return new InvestmentPlan.LedgerExecutionRef(entry.getUUID(), projection.getUUID(), - projection.getRole()); - } - } - - return null; - } - - private boolean sameExecutionRef(InvestmentPlan.LedgerExecutionRef left, InvestmentPlan.LedgerExecutionRef right) - { - return Objects.equals(left.getLedgerEntryUUID(), right.getLedgerEntryUUID()) - && Objects.equals(left.getProjectionUUID(), right.getProjectionUUID()) - && left.getProjectionRole() == right.getProjectionRole(); - } - private void removeMigratedLegacyTransactions(Client client, Set migratedProjectionUUIDs, List migratedTransactions) { @@ -677,43 +658,22 @@ private static List addUnitPostings(LedgerEntry entry, Transactio return List.copyOf(postings); } - private static void addUnitMemberships(LedgerProjectionRef projection, List unitPostings) + private static void addUnitMemberships(MigrationView projection, List unitPostings) { - for (var posting : unitPostings) - { - switch (posting.getType()) - { - case FEE -> projection.addMembership(posting.getUUID(), ProjectionMembershipRole.FEE_UNIT); - case TAX -> projection.addMembership(posting.getUUID(), ProjectionMembershipRole.TAX_UNIT); - case GROSS_VALUE -> projection.addMembership(posting.getUUID(), ProjectionMembershipRole.GROSS_VALUE_UNIT); - default -> throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_IMPORT_021 - .message("Unsupported unit posting type: " + posting.getType())); //$NON-NLS-1$ - } - } + // Unit postings carry semantic unitRole/groupKey data and no longer need + // persisted projection memberships. } - private static LedgerProjectionRef accountProjection(String uuid, LedgerProjectionRole role, Account account, - String primaryPostingUUID) + private static MigrationView accountProjection(String uuid, LedgerProjectionRole role, Account account, + String primaryPostingId) { - var projection = new LedgerProjectionRef(uuid); - - projection.setRole(role); - projection.setAccount(account); - projection.setPrimaryPostingTargetUUID(primaryPostingUUID); - - return projection; + return new MigrationView(uuid, role, account, null, primaryPostingId); } - private static LedgerProjectionRef portfolioProjection(String uuid, LedgerProjectionRole role, - Portfolio portfolio, String primaryPostingUUID) + private static MigrationView portfolioProjection(String uuid, LedgerProjectionRole role, + Portfolio portfolio, String primaryPostingId) { - var projection = new LedgerProjectionRef(uuid); - - projection.setRole(role); - projection.setPortfolio(portfolio); - projection.setPrimaryPostingTargetUUID(primaryPostingUUID); - - return projection; + return new MigrationView(uuid, role, null, portfolio, primaryPostingId); } private static void addEntry(Ledger ledger, LedgerEntry entry) @@ -731,9 +691,9 @@ private static void addPosting(LedgerEntry entry, LedgerPosting posting) entry.addPosting(posting); } - private static void addProjectionRef(LedgerEntry entry, LedgerProjectionRef projection) + private static void addProjectionView(LedgerEntry entry, MigrationView projection) { - entry.addProjectionRef(projection); + // Projection views are derived from semantic postings. } private static void addParameter(LedgerPosting posting, LedgerParameter parameter) @@ -825,13 +785,19 @@ private static String migratedPostingUUID(String projectionUUID, LedgerPostingTy } } + private record MigrationView(String uuid, LedgerProjectionRole role, Account account, Portfolio portfolio, + String primaryPostingId) + { + } + private static final class MigrationPlan { private final Client client; private final List entries = new ArrayList<>(); private final List legacyTransactionsToRemove = new ArrayList<>(); - private final Set projectionUUIDsToRemove = new HashSet<>(); - private final Set plannedProjectionUUIDs = new HashSet<>(); + private final Map migratedEntriesByTransaction = new IdentityHashMap<>(); + private final Set projectionIdsToRemove = new HashSet<>(); + private final Set plannedEntryUUIDs = new HashSet<>(); private final List diagnostics = new ArrayList<>(); private int migratedTransactionCount; private boolean applied; @@ -846,10 +812,12 @@ private void addEntry(LedgerEntry entry, Transaction... transactions) entries.add(entry); for (var transaction : transactions) + { markMigrated(transaction); + migratedEntriesByTransaction.put(transaction, entry); + } - for (var projection : entry.getProjectionRefs()) - plannedProjectionUUIDs.add(projection.getUUID()); + plannedEntryUUIDs.add(entry.getUUID()); MigrationGraphBuilder.setUpdatedAt(entry, transactions[0]); } @@ -857,21 +825,18 @@ private void addEntry(LedgerEntry entry, Transaction... transactions) private boolean handleAlreadyMigratedCompleteGroup(String family, LedgerEntry expectedEntry, Transaction... transactions) { - var projectionUUIDs = projectionUUIDs(expectedEntry); - - if (projectionUUIDs.stream().anyMatch(plannedProjectionUUIDs::contains)) + if (plannedEntryUUIDs.contains(expectedEntry.getUUID())) { - addDiagnostic(family, "DUPLICATE_CONFLICT", "plannedProjectionConflict", transactions); //$NON-NLS-1$ //$NON-NLS-2$ + addDiagnostic(family, "DUPLICATE_CONFLICT", "plannedEntryConflict", transactions); //$NON-NLS-1$ //$NON-NLS-2$ return true; } - var matchingEntries = existingEntriesContainingAny(projectionUUIDs); + var matchingEntries = existingEntriesMatching(expectedEntry); if (matchingEntries.isEmpty()) return false; - if (matchingEntries.size() == 1 && existingEntryContainsAll(matchingEntries.get(0), projectionUUIDs) - && isStructurallyValidExistingEntry(matchingEntries.get(0))) + if (matchingEntries.size() == 1 && isStructurallyValidExistingEntry(matchingEntries.get(0))) { var mismatch = semanticMismatch(matchingEntries.get(0), expectedEntry); @@ -890,8 +855,7 @@ && isStructurallyValidExistingEntry(matchingEntries.get(0))) } addDiagnostic(family, "DUPLICATE_CONFLICT", //$NON-NLS-1$ - "existingProjectionConflict mismatch=" + duplicateMismatch(matchingEntries, //$NON-NLS-1$ - projectionUUIDs), + "existingEntryConflict mismatch=" + duplicateMismatch(matchingEntries), //$NON-NLS-1$ transactions); return true; } @@ -899,37 +863,56 @@ && isStructurallyValidExistingEntry(matchingEntries.get(0))) private void markMigrated(Transaction transaction) { legacyTransactionsToRemove.add(transaction); - projectionUUIDsToRemove.add(transaction.getUUID()); + projectionIdsToRemove.add(transaction.getUUID()); migratedTransactionCount++; } private void markAlreadyMigrated(Transaction transaction) { legacyTransactionsToRemove.add(transaction); - projectionUUIDsToRemove.add(transaction.getUUID()); + projectionIdsToRemove.add(transaction.getUUID()); } - private List existingEntriesContainingAny(List projectionUUIDs) + private List existingEntriesMatching(LedgerEntry expectedEntry) { var result = new ArrayList(); + var expectedDescriptors = LedgerProjectionSupport.descriptors(expectedEntry); for (var entry : client.getLedger().getEntries()) { - if (entry.getProjectionRefs().stream().anyMatch(ref -> projectionUUIDs.contains(ref.getUUID()))) + if (entry.getUUID().equals(expectedEntry.getUUID()) || entry.getType() == expectedEntry.getType() + || hasOwnerOverlap(entry, expectedDescriptors)) result.add(entry); } return result; } - private boolean existingEntryContainsAll(LedgerEntry entry, List projectionUUIDs) + private boolean hasOwnerOverlap(LedgerEntry entry, + List expectedDescriptors) { - var existingProjectionUUIDs = new HashSet(); - - for (var projection : entry.getProjectionRefs()) - existingProjectionUUIDs.add(projection.getUUID()); + try + { + for (var existingDescriptor : LedgerProjectionSupport.descriptors(entry)) + for (var expectedDescriptor : expectedDescriptors) + { + if (existingDescriptor.getAccount() != null + && existingDescriptor.getAccount() == expectedDescriptor.getAccount()) + return true; + + if (existingDescriptor.getPortfolio() != null + && existingDescriptor.getPortfolio() == expectedDescriptor.getPortfolio()) + return true; + } + } + catch (IllegalArgumentException e) + { + // Malformed existing ledger entries must be treated as possible + // duplicate conflicts so migration does not silently remove legacy rows. + return entry.getType() == expectedDescriptors.get(0).getEntry().getType(); + } - return existingProjectionUUIDs.containsAll(projectionUUIDs); + return false; } private boolean isStructurallyValidExistingEntry(LedgerEntry entry) @@ -941,14 +924,11 @@ private boolean isStructurallyValidExistingEntry(LedgerEntry entry) return LedgerStructuralValidator.validate(ledger).isOK(); } - private SemanticMismatch duplicateMismatch(List matchingEntries, List projectionUUIDs) + private SemanticMismatch duplicateMismatch(List matchingEntries) { if (matchingEntries.size() != 1) return SemanticMismatch.PROJECTION_UUID; - if (!existingEntryContainsAll(matchingEntries.get(0), projectionUUIDs)) - return SemanticMismatch.PROJECTION_UUID; - if (!isStructurallyValidExistingEntry(matchingEntries.get(0))) return SemanticMismatch.STRUCTURAL_VALIDATION; @@ -960,18 +940,28 @@ private SemanticMismatch semanticMismatch(LedgerEntry existingEntry, LedgerEntry if (existingEntry.getType() != expectedEntry.getType()) return SemanticMismatch.ENTRY_TYPE; - if (existingEntry.getProjectionRefs().size() != expectedEntry.getProjectionRefs().size()) - return SemanticMismatch.PROJECTION_UUID; - if (!postingTypeCounts(existingEntry).equals(postingTypeCounts(expectedEntry))) return SemanticMismatch.POSTING_TYPE_SHAPE; if (!sameUnitPostingFacts(existingEntry, expectedEntry)) return SemanticMismatch.UNIT_POSTINGS; - for (var expectedProjection : expectedEntry.getProjectionRefs()) + List existingDescriptors; + + try + { + existingDescriptors = LedgerProjectionSupport.descriptors(existingEntry); + } + catch (IllegalArgumentException e) { - var existingProjection = projectionByUUID(existingEntry, expectedProjection.getUUID()); + return SemanticMismatch.PRIMARY_POSTING; + } + + for (var expectedProjection : LedgerProjectionSupport.descriptors(expectedEntry)) + { + var existingProjection = existingDescriptors.stream() + .filter(descriptor -> descriptor.getRole() == expectedProjection.getRole()) + .findFirst().orElse(null); if (existingProjection == null) return SemanticMismatch.PROJECTION_UUID; @@ -983,8 +973,8 @@ private SemanticMismatch semanticMismatch(LedgerEntry existingEntry, LedgerEntry || existingProjection.getPortfolio() != expectedProjection.getPortfolio()) return SemanticMismatch.PROJECTION_OWNER; - var expectedPosting = postingByUUID(expectedEntry, expectedProjection.getPrimaryPostingUUID()); - var existingPosting = postingByUUID(existingEntry, existingProjection.getPrimaryPostingUUID()); + var expectedPosting = expectedProjection.getPrimaryPosting(); + var existingPosting = existingProjection.getPrimaryPosting(); if (expectedPosting == null || existingPosting == null) return SemanticMismatch.PRIMARY_POSTING; @@ -998,16 +988,6 @@ private SemanticMismatch semanticMismatch(LedgerEntry existingEntry, LedgerEntry return SemanticMismatch.NONE; } - private List projectionUUIDs(LedgerEntry entry) - { - var result = new ArrayList(); - - for (var projection : entry.getProjectionRefs()) - result.add(projection.getUUID()); - - return result; - } - private java.util.Map postingTypeCounts(LedgerEntry entry) { var result = new java.util.EnumMap(LedgerPostingType.class); @@ -1049,15 +1029,6 @@ private boolean isUnitPosting(LedgerPostingType type) || type == LedgerPostingType.GROSS_VALUE; } - private LedgerProjectionRef projectionByUUID(LedgerEntry entry, String uuid) - { - for (var projection : entry.getProjectionRefs()) - if (Objects.equals(projection.getUUID(), uuid)) - return projection; - - return null; - } - private LedgerPosting postingByUUID(LedgerEntry entry, String uuid) { for (var posting : entry.getPostings()) @@ -1126,9 +1097,14 @@ private List getLegacyTransactionsToRemove() return legacyTransactionsToRemove; } - private Set getProjectionUUIDsToRemove() + private LedgerEntry getMigratedEntry(Transaction transaction) + { + return migratedEntriesByTransaction.get(transaction); + } + + private Set getprojectionIdsToRemove() { - return projectionUUIDsToRemove; + return projectionIdsToRemove; } private List getDiagnostics() diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssembler.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssembler.java index 2f0f45c1a0..340c64f22a 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssembler.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssembler.java @@ -21,7 +21,6 @@ import name.abuchen.portfolio.model.ledger.LedgerPostingDirection; import name.abuchen.portfolio.model.ledger.LedgerPostingSemanticRole; import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; @@ -431,21 +430,8 @@ private void addProjection(LedgerEntry entry, ProjectionIntent intent) throw issue(LedgerNativeEntryAssemblyIssue.PROJECTION_TARGET_MISSING, "Projection target posting is required for " + intent.role()); //$NON-NLS-1$ - var projection = new LedgerProjectionRef(); - projection.setRole(intent.role()); - - if (intent.account() != null) - projection.setAccount(intent.account()); - - if (intent.portfolio() != null) - projection.setPortfolio(intent.portfolio()); - - projection.setPrimaryPosting(intent.primaryPosting()); - - if (intent.postingGroup() != null) - projection.setPostingGroup(intent.postingGroup()); - - entry.addProjectionRef(projection); + // Runtime projections are derived from the semantic posting data already + // carried by the intent's primary and unit postings. } private void addEntryParameter(LedgerEntry entry, NativeParameterValue parameter) diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryBuildResult.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryBuildResult.java index 56f76d1ab0..e2602aecd4 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryBuildResult.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryBuildResult.java @@ -1,10 +1,8 @@ package name.abuchen.portfolio.model.ledger.nativeentry; -import java.util.List; import java.util.Objects; import name.abuchen.portfolio.model.ledger.LedgerEntry; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; /** @@ -13,8 +11,8 @@ * workflows before treating native entries as user-facing transactions. * *

- * The result object is not persisted. Only the assembled {@link LedgerEntry} and its - * projection refs become part of the Ledger model. + * The result object is not persisted. Only the assembled {@link LedgerEntry} becomes + * part of the Ledger model. *

*/ public final class LedgerNativeEntryBuildResult @@ -33,11 +31,6 @@ public LedgerEntry getEntry() return entry; } - public List getProjectionRefs() - { - return entry.getProjectionRefs(); - } - public LedgerStructuralValidator.ValidationResult getValidationResult() { return validationResult; diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptor.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptor.java index 35bb35c517..a2ceb416f1 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptor.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptor.java @@ -46,6 +46,11 @@ public LedgerEntry getEntry() return entry; } + public String getRuntimeProjectionId() + { + return entry.getUUID() + ":" + role; //$NON-NLS-1$ + } + public LedgerProjectionRole getRole() { return role; diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedAccountTransaction.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedAccountTransaction.java index 2684239b08..d5a3a0da10 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedAccountTransaction.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedAccountTransaction.java @@ -11,7 +11,6 @@ import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerEntryMetadataPatchHelper; import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; import name.abuchen.portfolio.money.CurrencyConverter; import name.abuchen.portfolio.money.Money; import name.abuchen.portfolio.money.MoneyCollectors; @@ -25,15 +24,15 @@ public final class LedgerBackedAccountTransaction extends AccountTransaction implements LedgerBackedTransaction { private final LedgerEntry entry; - private final LedgerProjectionRef projectionRef; + private final DerivedProjectionDescriptor descriptor; private final LedgerPosting primaryPosting; private CrossEntry crossEntry; - LedgerBackedAccountTransaction(LedgerEntry entry, LedgerProjectionRef projectionRef) + LedgerBackedAccountTransaction(DerivedProjectionDescriptor descriptor) { - this.entry = entry; - this.projectionRef = projectionRef; - this.primaryPosting = LedgerProjectionSupport.primaryPosting(entry, projectionRef); + this.descriptor = descriptor; + this.entry = descriptor.getEntry(); + this.primaryPosting = descriptor.getPrimaryPosting(); } @Override @@ -43,9 +42,9 @@ public LedgerEntry getLedgerEntry() } @Override - public LedgerProjectionRef getLedgerProjectionRef() + public DerivedProjectionDescriptor getLedgerProjectionDescriptor() { - return projectionRef; + return descriptor; } void setLedgerCrossEntry(CrossEntry crossEntry) @@ -56,14 +55,14 @@ void setLedgerCrossEntry(CrossEntry crossEntry) @Override public String getUUID() { - return projectionRef.getUUID(); + return descriptor.getRuntimeProjectionId(); } @Override public Type getType() { if (entry.getType().isLedgerNativeTargeted()) - return LedgerProjectionSupport.targetedAccountType(projectionRef); + return LedgerProjectionSupport.targetedAccountType(descriptor.getRole()); return switch (entry.getType()) { @@ -153,7 +152,7 @@ public CrossEntry getCrossEntry() @Override public Stream getUnits() { - return LedgerProjectionSupport.units(entry, projectionRef, primaryPosting); + return LedgerProjectionSupport.units(descriptor); } @Override @@ -297,12 +296,12 @@ protected void setCrossEntry(CrossEntry crossEntry) private Type transferType() { - return switch (projectionRef.getRole()) + return switch (descriptor.getRole()) { case SOURCE_ACCOUNT -> Type.TRANSFER_OUT; case TARGET_ACCOUNT -> Type.TRANSFER_IN; default -> throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PROJ_067 - .message("Unsupported cash transfer role " + projectionRef.getRole())); //$NON-NLS-1$ + .message("Unsupported cash transfer role " + descriptor.getRole())); //$NON-NLS-1$ }; } } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedCrossEntry.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedCrossEntry.java index dfe86bc3de..871915c7bd 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedCrossEntry.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedCrossEntry.java @@ -85,13 +85,12 @@ private Projection projection(Transaction transaction) if (candidate == transaction || candidate.getUUID().equals(transaction.getUUID())) { var ledgerBacked = (LedgerBackedTransaction) candidate; - var projectionRef = ledgerBacked.getLedgerProjectionRef(); if (candidate instanceof LedgerBackedAccountTransaction) - return new Projection(candidate, projectionRef.getAccount()); + return new Projection(candidate, ledgerBacked.getLedgerProjectionDescriptor().getAccount()); if (candidate instanceof LedgerBackedPortfolioTransaction) - return new Projection(candidate, projectionRef.getPortfolio()); + return new Projection(candidate, ledgerBacked.getLedgerProjectionDescriptor().getPortfolio()); } } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedPortfolioTransaction.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedPortfolioTransaction.java index f5a23673bb..a809f5daf1 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedPortfolioTransaction.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedPortfolioTransaction.java @@ -11,7 +11,6 @@ import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerEntryMetadataPatchHelper; import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; import name.abuchen.portfolio.money.CurrencyConverter; import name.abuchen.portfolio.money.Money; import name.abuchen.portfolio.money.MoneyCollectors; @@ -25,15 +24,15 @@ public final class LedgerBackedPortfolioTransaction extends PortfolioTransaction implements LedgerBackedTransaction { private final LedgerEntry entry; - private final LedgerProjectionRef projectionRef; + private final DerivedProjectionDescriptor descriptor; private final LedgerPosting primaryPosting; private CrossEntry crossEntry; - LedgerBackedPortfolioTransaction(LedgerEntry entry, LedgerProjectionRef projectionRef) + LedgerBackedPortfolioTransaction(DerivedProjectionDescriptor descriptor) { - this.entry = entry; - this.projectionRef = projectionRef; - this.primaryPosting = LedgerProjectionSupport.primaryPosting(entry, projectionRef); + this.descriptor = descriptor; + this.entry = descriptor.getEntry(); + this.primaryPosting = descriptor.getPrimaryPosting(); } @Override @@ -43,9 +42,9 @@ public LedgerEntry getLedgerEntry() } @Override - public LedgerProjectionRef getLedgerProjectionRef() + public DerivedProjectionDescriptor getLedgerProjectionDescriptor() { - return projectionRef; + return descriptor; } void setLedgerCrossEntry(CrossEntry crossEntry) @@ -56,14 +55,14 @@ void setLedgerCrossEntry(CrossEntry crossEntry) @Override public String getUUID() { - return projectionRef.getUUID(); + return descriptor.getRuntimeProjectionId(); } @Override public Type getType() { if (entry.getType().isLedgerNativeTargeted()) - return LedgerProjectionSupport.targetedPortfolioType(projectionRef); + return LedgerProjectionSupport.targetedPortfolioType(descriptor.getRole()); return switch (entry.getType()) { @@ -140,7 +139,7 @@ public CrossEntry getCrossEntry() @Override public Stream getUnits() { - return LedgerProjectionSupport.units(entry, projectionRef, primaryPosting); + return LedgerProjectionSupport.units(descriptor); } @Override @@ -274,12 +273,12 @@ protected void setCrossEntry(CrossEntry crossEntry) private Type transferType() { - return switch (projectionRef.getRole()) + return switch (descriptor.getRole()) { case SOURCE_PORTFOLIO -> Type.TRANSFER_OUT; case TARGET_PORTFOLIO -> Type.TRANSFER_IN; default -> throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PROJ_070 - .message("Unsupported security transfer role " + projectionRef.getRole())); //$NON-NLS-1$ + .message("Unsupported security transfer role " + descriptor.getRole())); //$NON-NLS-1$ }; } } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedTransaction.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedTransaction.java index ee5f7e9773..fde467da7d 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedTransaction.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedTransaction.java @@ -1,7 +1,7 @@ package name.abuchen.portfolio.model.ledger.projection; import name.abuchen.portfolio.model.ledger.LedgerEntry; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; /** * Marks a runtime legacy transaction view that is backed by a Ledger entry. @@ -12,5 +12,15 @@ public interface LedgerBackedTransaction { LedgerEntry getLedgerEntry(); - LedgerProjectionRef getLedgerProjectionRef(); + DerivedProjectionDescriptor getLedgerProjectionDescriptor(); + + default LedgerProjectionRole getLedgerProjectionRole() + { + return getLedgerProjectionDescriptor().getRole(); + } + + default String getRuntimeProjectionId() + { + return getLedgerProjectionDescriptor().getRuntimeProjectionId(); + } } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionFactory.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionFactory.java index ee3aff3058..df6dd9b42c 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionFactory.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionFactory.java @@ -12,11 +12,7 @@ import name.abuchen.portfolio.model.PortfolioTransferEntry; import name.abuchen.portfolio.model.Transaction; import name.abuchen.portfolio.model.ledger.LedgerEntry; -import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; -import name.abuchen.portfolio.model.ledger.ProjectionMembershipRole; import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; /** @@ -38,18 +34,17 @@ final class LedgerProjectionFactory this.descriptorService = Objects.requireNonNull(descriptorService); } - Transaction createProjection(LedgerEntry entry, LedgerProjectionRef projectionRef) + Transaction createProjection(LedgerEntry entry, LedgerProjectionRole role) { Objects.requireNonNull(entry); - Objects.requireNonNull(projectionRef); + Objects.requireNonNull(role); return createDescriptors(entry).stream() // - .filter(descriptor -> descriptor.getRole() == projectionRef.getRole()) // - .map(descriptor -> create(entry, descriptor, - compatibilityProjectionRef(entry, descriptor, projectionRef.getUUID()))) // + .filter(descriptor -> descriptor.getRole() == role) // + .map(this::create) // .findFirst().orElseThrow(() -> new IllegalArgumentException( "Projection descriptor does not belong to entry role: " //$NON-NLS-1$ - + projectionRef.getRole())); + + role)); } List createProjections(LedgerEntry entry) @@ -59,7 +54,7 @@ List createProjections(LedgerEntry entry) var transactions = new ArrayList(); for (var descriptor : createDescriptors(entry)) - transactions.add(create(entry, descriptor, compatibilityProjectionRef(entry, descriptor))); + transactions.add(create(descriptor)); attachCrossEntry(entry, transactions); @@ -76,91 +71,16 @@ private List createDescriptors(LedgerEntry entry) return result.getDescriptors(); } - private Transaction create(LedgerEntry entry, DerivedProjectionDescriptor descriptor, - LedgerProjectionRef projectionRef) + private Transaction create(DerivedProjectionDescriptor descriptor) { if (descriptor.getViewKind() == DerivedProjectionViewKind.ACCOUNT) - return new LedgerBackedAccountTransaction(entry, projectionRef); + return new LedgerBackedAccountTransaction(descriptor); if (descriptor.getViewKind() == DerivedProjectionViewKind.PORTFOLIO) - return new LedgerBackedPortfolioTransaction(entry, projectionRef); + return new LedgerBackedPortfolioTransaction(descriptor); throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PROJ_071 - .message("Unsupported ledger projection role " + projectionRef.getRole())); //$NON-NLS-1$ - } - - private LedgerProjectionRef compatibilityProjectionRef(LedgerEntry entry, DerivedProjectionDescriptor descriptor) - { - var uuid = persistedProjectionRef(entry, descriptor.getRole()) // - .map(LedgerProjectionRef::getUUID) // - .orElse(entry.getUUID() + ":" + descriptor.getRole()); //$NON-NLS-1$ - - return compatibilityProjectionRef(entry, descriptor, uuid); - } - - private LedgerProjectionRef compatibilityProjectionRef(LedgerEntry entry, DerivedProjectionDescriptor descriptor, - String uuid) - { - var projectionRef = new LedgerProjectionRef(uuid); - - projectionRef.setRole(descriptor.getRole()); - - if (descriptor.getAccount() != null) - projectionRef.setAccount(descriptor.getAccount()); - - if (descriptor.getPortfolio() != null) - projectionRef.setPortfolio(descriptor.getPortfolio()); - - projectionRef.setPrimaryPosting(descriptor.getPrimaryPosting()); - projectionRef.setPostingGroup(descriptor.getPrimaryPosting()); - addUnitMemberships(projectionRef, descriptor.getUnitPostings()); - - return projectionRef; - } - - private java.util.Optional persistedProjectionRef(LedgerEntry entry, LedgerProjectionRole role) - { - return entry.getProjectionRefs().stream().filter(ref -> ref.getRole() == role).findFirst(); - } - - private void addUnitMemberships(LedgerProjectionRef projectionRef, List unitPostings) - { - for (var posting : unitPostings) - { - var role = membershipRole(posting); - - if (role == null) - continue; - - projectionRef.addMembership(posting.getUUID(), role); - } - } - - private ProjectionMembershipRole membershipRole(LedgerPosting posting) - { - if (posting.getUnitRole() != null) - return membershipRole(posting.getUnitRole()); - - return switch (posting.getType()) - { - case FEE -> ProjectionMembershipRole.FEE_UNIT; - case TAX -> ProjectionMembershipRole.TAX_UNIT; - case GROSS_VALUE -> ProjectionMembershipRole.GROSS_VALUE_UNIT; - case FOREX -> ProjectionMembershipRole.FOREX_CONTEXT; - default -> null; - }; - } - - private ProjectionMembershipRole membershipRole(LedgerPostingUnitRole role) - { - return switch (role) - { - case FEE -> ProjectionMembershipRole.FEE_UNIT; - case TAX -> ProjectionMembershipRole.TAX_UNIT; - case GROSS_VALUE -> ProjectionMembershipRole.GROSS_VALUE_UNIT; - case FOREX_CONTEXT -> ProjectionMembershipRole.FOREX_CONTEXT; - case PRIMARY -> ProjectionMembershipRole.PRIMARY; - }; + .message("Unsupported ledger projection role " + descriptor.getRole())); //$NON-NLS-1$ } private void attachCrossEntry(LedgerEntry entry, List transactions) @@ -203,8 +123,8 @@ private void attachAccountTransferCrossEntry(LedgerEntry entry, List transa LedgerProjectionRole.ACCOUNT); var portfolioTransaction = (LedgerBackedPortfolioTransaction) transaction(entry, transactions, LedgerProjectionRole.PORTFOLIO); - var crossEntry = BuySellEntry.readOnly(portfolioTransaction.getLedgerProjectionRef().getPortfolio(), - (PortfolioTransaction) portfolioTransaction, accountTransaction.getLedgerProjectionRef() + var crossEntry = BuySellEntry.readOnly(portfolioTransaction.getLedgerProjectionDescriptor().getPortfolio(), + (PortfolioTransaction) portfolioTransaction, accountTransaction.getLedgerProjectionDescriptor() .getAccount(), (AccountTransaction) accountTransaction); @@ -246,7 +166,7 @@ private Transaction transaction(LedgerEntry entry, List transaction return transactions.stream() // .filter(LedgerBackedTransaction.class::isInstance) // .map(LedgerBackedTransaction.class::cast) // - .filter(transaction -> transaction.getLedgerProjectionRef().getRole() == role) // + .filter(transaction -> transaction.getLedgerProjectionRole() == role) // .map(Transaction.class::cast) // .findFirst().orElseThrow(); } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionMaterializer.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionMaterializer.java index b7dfe523bf..b5dc8286ba 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionMaterializer.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionMaterializer.java @@ -54,7 +54,7 @@ else if (transaction instanceof LedgerBackedPortfolioTransaction portfolioTransa private void addAccountProjection(LedgerBackedAccountTransaction transaction) { - var account = transaction.getLedgerProjectionRef().getAccount(); + var account = transaction.getLedgerProjectionDescriptor().getAccount(); if (account.getTransactions().stream().noneMatch(existing -> isSameLedgerProjection(existing, transaction))) account.getTransactions().add(transaction); @@ -62,7 +62,7 @@ private void addAccountProjection(LedgerBackedAccountTransaction transaction) private void addPortfolioProjection(LedgerBackedPortfolioTransaction transaction) { - var portfolio = transaction.getLedgerProjectionRef().getPortfolio(); + var portfolio = transaction.getLedgerProjectionDescriptor().getPortfolio(); if (portfolio.getTransactions().stream().noneMatch(existing -> isSameLedgerProjection(existing, transaction))) portfolio.getTransactions().add(transaction); @@ -71,12 +71,12 @@ private void addPortfolioProjection(LedgerBackedPortfolioTransaction transaction private boolean isSameLedgerProjection(AccountTransaction existing, LedgerBackedTransaction transaction) { return existing instanceof LedgerBackedTransaction && existing.getUUID().equals( - transaction.getLedgerProjectionRef().getUUID()); + transaction.getLedgerProjectionDescriptor().getRuntimeProjectionId()); } private boolean isSameLedgerProjection(PortfolioTransaction existing, LedgerBackedTransaction transaction) { return existing instanceof LedgerBackedTransaction && existing.getUUID().equals( - transaction.getLedgerProjectionRef().getUUID()); + transaction.getLedgerProjectionDescriptor().getRuntimeProjectionId()); } } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionService.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionService.java index 3ea3d0cf88..1eaee28409 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionService.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionService.java @@ -8,10 +8,8 @@ import name.abuchen.portfolio.model.Transaction; import name.abuchen.portfolio.model.ledger.Ledger; import name.abuchen.portfolio.model.ledger.LedgerEntry; -import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; -import name.abuchen.portfolio.model.ledger.ProjectionMembershipRole; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; /** * Coordinates materialization and refresh of runtime projections for Ledger entries. @@ -29,9 +27,9 @@ public static void materialize(Client client) new LedgerProjectionMaterializer().materialize(client); } - public static Transaction createProjection(LedgerEntry entry, LedgerProjectionRef projectionRef) + public static Transaction createProjection(LedgerEntry entry, LedgerProjectionRole role) { - return new LedgerProjectionFactory().createProjection(entry, projectionRef); + return new LedgerProjectionFactory().createProjection(entry, role); } public static List createProjections(LedgerEntry entry) @@ -44,28 +42,6 @@ public static LedgerStructuralValidator.ValidationResult restoreIfValid(Client c return new LedgerRuntimeProjectionRestorer().restoreIfValid(client); } - public static void adaptLegacyScalarMemberships(Client client) - { - for (var entry : client.getLedger().getEntries()) - { - var postingUUIDs = entry.getPostings().stream().map(LedgerPosting::getUUID).collect(Collectors.toSet()); - - for (var projectionRef : entry.getProjectionRefs()) - adaptLegacyScalarMemberships(projectionRef, postingUUIDs); - } - } - - private static void adaptLegacyScalarMemberships(LedgerProjectionRef projectionRef, Set postingUUIDs) - { - if (projectionRef.getPrimaryMembership().isEmpty() - && postingUUIDs.contains(projectionRef.getPrimaryPostingUUID())) - projectionRef.addMembership(projectionRef.getPrimaryPostingUUID(), ProjectionMembershipRole.PRIMARY); - - if (projectionRef.getMembershipsByRole(ProjectionMembershipRole.GROUP_ANCHOR).isEmpty() - && postingUUIDs.contains(projectionRef.getPostingGroupUUID())) - projectionRef.addMembership(projectionRef.getPostingGroupUUID(), ProjectionMembershipRole.GROUP_ANCHOR); - } - public static void logSkipped(LedgerStructuralValidator.ValidationResult result) { LedgerRuntimeProjectionRestorer.logSkipped(result); diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionSupport.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionSupport.java index 64f3216217..4570d6fbc9 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionSupport.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionSupport.java @@ -14,8 +14,7 @@ import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerParameter; import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRef; -import name.abuchen.portfolio.model.ledger.ProjectionMembershipRole; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; import name.abuchen.portfolio.money.Money; @@ -39,33 +38,37 @@ private LedgerProjectionSupport() { } - public static LedgerPosting primaryPosting(LedgerEntry entry, LedgerProjectionRef projectionRef) + public static LedgerPosting primaryPosting(LedgerEntry entry, LedgerProjectionRole role) + { + return descriptor(entry, role).getPrimaryPosting(); + } + + public static DerivedProjectionDescriptor descriptor(LedgerEntry entry, LedgerProjectionRole role) { Objects.requireNonNull(entry); - Objects.requireNonNull(projectionRef); + Objects.requireNonNull(role); - var primaryMembership = projectionRef.getPrimaryMembership(); - if (primaryMembership.isPresent()) - return requirePostingInEntry(entry, primaryMembership.get().getPostingUUID()); + return descriptors(entry).stream() // + .filter(descriptor -> descriptor.getRole() == role) // + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Projection descriptor not found: " + role)); //$NON-NLS-1$ + } - if (projectionRef.getPrimaryPostingUUID() != null) - return requirePostingInEntry(entry, projectionRef.getPrimaryPostingUUID()); + public static List descriptors(LedgerEntry entry) + { + Objects.requireNonNull(entry); - return switch (projectionRef.getRole()) - { - case SOURCE_ACCOUNT -> firstAccountPosting(entry, projectionRef); - case TARGET_ACCOUNT -> lastAccountPosting(entry, projectionRef); - case SOURCE_PORTFOLIO -> firstPortfolioPosting(entry, projectionRef); - case TARGET_PORTFOLIO -> lastPortfolioPosting(entry, projectionRef); - case ACCOUNT, CASH_COMPENSATION -> firstAccountPosting(entry, projectionRef); - case PORTFOLIO, DELIVERY, DELIVERY_INBOUND, DELIVERY_OUTBOUND, OLD_SECURITY_LEG, NEW_SECURITY_LEG -> - firstPortfolioPosting(entry, projectionRef); - }; + var result = new DerivedProjectionDescriptorService().derive(entry); + + if (!result.isOK()) + throw new IllegalArgumentException(result.formatDiagnostics()); + + return result.getDescriptors(); } - static Optional primaryPostingForex(LedgerEntry entry, LedgerProjectionRef projectionRef) + static Optional primaryPostingForex(DerivedProjectionDescriptor descriptor) { - return postingForex(primaryPosting(entry, projectionRef)); + return postingForex(descriptor.getPrimaryPosting()); } private static Optional postingForex(LedgerPosting posting) @@ -80,37 +83,31 @@ private static Optional postingForex(LedgerPosting posting) posting.getExchangeRate())); } - static Stream units(LedgerEntry entry, LedgerProjectionRef projectionRef, LedgerPosting primaryPosting) + static Stream units(DerivedProjectionDescriptor descriptor) { - if (entry.getType().isLedgerNativeTargeted()) - return targetedUnits(entry, projectionRef, primaryPosting).map(LedgerProjectionSupport::unit); - - return entry.getPostings().stream() // - .filter(posting -> posting != primaryPosting) // - .filter(LedgerProjectionSupport::isUnitPosting) // - .map(LedgerProjectionSupport::unit); + return descriptor.getUnitPostings().stream().map(LedgerProjectionSupport::unit); } - static AccountTransaction.Type targetedAccountType(LedgerProjectionRef projectionRef) + static AccountTransaction.Type targetedAccountType(LedgerProjectionRole role) { - return switch (projectionRef.getRole()) + return switch (role) { case CASH_COMPENSATION -> AccountTransaction.Type.DEPOSIT; default -> throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PROJ_072 - .message("Unsupported targeted account role " + projectionRef.getRole())); //$NON-NLS-1$ + .message("Unsupported targeted account role " + role)); //$NON-NLS-1$ }; } - static PortfolioTransaction.Type targetedPortfolioType(LedgerProjectionRef projectionRef) + static PortfolioTransaction.Type targetedPortfolioType(LedgerProjectionRole role) { - return switch (projectionRef.getRole()) + return switch (role) { case DELIVERY_OUTBOUND -> PortfolioTransaction.Type.DELIVERY_OUTBOUND; case DELIVERY_INBOUND -> PortfolioTransaction.Type.DELIVERY_INBOUND; case OLD_SECURITY_LEG -> PortfolioTransaction.Type.DELIVERY_OUTBOUND; case NEW_SECURITY_LEG -> PortfolioTransaction.Type.DELIVERY_INBOUND; default -> throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PROJ_073 - .message("Unsupported targeted portfolio role " + projectionRef.getRole())); //$NON-NLS-1$ + .message("Unsupported targeted portfolio role " + role)); //$NON-NLS-1$ }; } @@ -130,18 +127,18 @@ static UnsupportedOperationException unsupportedMutation() return new UnsupportedOperationException("Ledger-backed projections are read-only"); //$NON-NLS-1$ } - static boolean isAccountProjection(LedgerProjectionRef projectionRef) + static boolean isAccountProjection(LedgerProjectionRole role) { - return switch (projectionRef.getRole()) + return switch (role) { case ACCOUNT, SOURCE_ACCOUNT, TARGET_ACCOUNT, CASH_COMPENSATION -> true; default -> false; }; } - static boolean isPortfolioProjection(LedgerProjectionRef projectionRef) + static boolean isPortfolioProjection(LedgerProjectionRole role) { - return switch (projectionRef.getRole()) + return switch (role) { case PORTFOLIO, SOURCE_PORTFOLIO, TARGET_PORTFOLIO, DELIVERY, DELIVERY_INBOUND, DELIVERY_OUTBOUND, OLD_SECURITY_LEG, NEW_SECURITY_LEG -> true; @@ -149,61 +146,6 @@ static boolean isPortfolioProjection(LedgerProjectionRef projectionRef) }; } - private static LedgerPosting firstAccountPosting(LedgerEntry entry, LedgerProjectionRef projectionRef) - { - return accountPostings(entry, projectionRef).findFirst().orElseThrow(() -> new IllegalArgumentException( - "No account posting for projection " + projectionRef.getUUID())); //$NON-NLS-1$ - } - - private static LedgerPosting requirePostingInEntry(LedgerEntry entry, String uuid) - { - return entry.getPostings().stream() // - .filter(posting -> uuid.equals(posting.getUUID())) // - .findFirst() - .orElseThrow(() -> new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_001 - .message("Primary posting does not exist in entry: " + uuid))); //$NON-NLS-1$ - } - - private static LedgerPosting lastAccountPosting(LedgerEntry entry, LedgerProjectionRef projectionRef) - { - var postings = accountPostings(entry, projectionRef).toList(); - - if (postings.isEmpty()) - throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_074 - .message("No account posting for projection " + projectionRef.getUUID())); //$NON-NLS-1$ - - return postings.get(postings.size() - 1); - } - - private static Stream accountPostings(LedgerEntry entry, LedgerProjectionRef projectionRef) - { - return entry.getPostings().stream() // - .filter(posting -> posting.getAccount() == projectionRef.getAccount()); - } - - private static LedgerPosting firstPortfolioPosting(LedgerEntry entry, LedgerProjectionRef projectionRef) - { - return portfolioPostings(entry, projectionRef).findFirst().orElseThrow(() -> new IllegalArgumentException( - "No portfolio posting for projection " + projectionRef.getUUID())); //$NON-NLS-1$ - } - - private static LedgerPosting lastPortfolioPosting(LedgerEntry entry, LedgerProjectionRef projectionRef) - { - List postings = portfolioPostings(entry, projectionRef).toList(); - - if (postings.isEmpty()) - throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_075 - .message("No portfolio posting for projection " + projectionRef.getUUID())); //$NON-NLS-1$ - - return postings.get(postings.size() - 1); - } - - private static Stream portfolioPostings(LedgerEntry entry, LedgerProjectionRef projectionRef) - { - return entry.getPostings().stream() // - .filter(posting -> posting.getPortfolio() == projectionRef.getPortfolio()); - } - private static boolean isUnitPosting(LedgerPosting posting) { return switch (posting.getType()) @@ -213,53 +155,6 @@ private static boolean isUnitPosting(LedgerPosting posting) }; } - private static Stream targetedUnits(LedgerEntry entry, LedgerProjectionRef projectionRef, - LedgerPosting primaryPosting) - { - var unitMemberships = projectionRef.getMemberships().stream() // - .filter(membership -> isUnitMembershipRole(membership.getRole())) // - .toList(); - - if (!unitMemberships.isEmpty()) - return unitMemberships.stream() // - .map(membership -> requirePostingInEntry(entry, membership.getPostingUUID())) // - .filter(posting -> posting != primaryPosting) // - .filter(LedgerProjectionSupport::isUnitPosting); - - var groupAnchorUUID = projectionRef.getMembershipsByRole(ProjectionMembershipRole.GROUP_ANCHOR).stream() - .findFirst() // - .map(membership -> membership.getPostingUUID()) // - .orElse(projectionRef.getPostingGroupUUID()); - - if (groupAnchorUUID == null || !groupAnchorUUID.equals(primaryPosting.getUUID())) - return Stream.empty(); - - return entry.getPostings().stream() // - .filter(posting -> posting != primaryPosting) // - .filter(LedgerProjectionSupport::isUnitPosting) // - .filter(posting -> hasSameProjectionOwner(primaryPosting, posting)); - } - - private static boolean isUnitMembershipRole(ProjectionMembershipRole role) - { - return switch (role) - { - case FEE_UNIT, TAX_UNIT, GROSS_VALUE_UNIT -> true; - default -> false; - }; - } - - private static boolean hasSameProjectionOwner(LedgerPosting primaryPosting, LedgerPosting posting) - { - if (primaryPosting.getAccount() != null) - return primaryPosting.getAccount() == posting.getAccount(); - - if (primaryPosting.getPortfolio() != null) - return primaryPosting.getPortfolio() == posting.getPortfolio(); - - return false; - } - private static Unit unit(LedgerPosting posting) { var type = switch (posting.getType()) diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerRuntimeProjectionRestorer.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerRuntimeProjectionRestorer.java index e74ba2fe99..bef0b362d1 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerRuntimeProjectionRestorer.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerRuntimeProjectionRestorer.java @@ -313,10 +313,11 @@ private Map expectedProjectionCounts(Client client) { if (projection instanceof LedgerBackedAccountTransaction accountTransaction) addProjection(projections, new ProjectionKey("account", //$NON-NLS-1$ - accountTransaction.getLedgerProjectionRef().getAccount(), projection.getUUID())); + accountTransaction.getLedgerProjectionDescriptor().getAccount(), + projection.getUUID())); else if (projection instanceof LedgerBackedPortfolioTransaction portfolioTransaction) addProjection(projections, new ProjectionKey("portfolio", //$NON-NLS-1$ - portfolioTransaction.getLedgerProjectionRef().getPortfolio(), + portfolioTransaction.getLedgerProjectionDescriptor().getPortfolio(), projection.getUUID())); } } @@ -334,7 +335,7 @@ private Map existingProjectionCounts(Client client) { if (transaction instanceof LedgerBackedTransaction ledgerBacked) addProjection(projections, new ProjectionKey("account", account, //$NON-NLS-1$ - ledgerBacked.getLedgerProjectionRef().getUUID())); + ledgerBacked.getLedgerProjectionDescriptor().getRuntimeProjectionId())); } } @@ -344,7 +345,7 @@ private Map existingProjectionCounts(Client client) { if (transaction instanceof LedgerBackedTransaction ledgerBacked) addProjection(projections, new ProjectionKey("portfolio", portfolio, //$NON-NLS-1$ - ledgerBacked.getLedgerProjectionRef().getUUID())); + ledgerBacked.getLedgerProjectionDescriptor().getRuntimeProjectionId())); } } From 87ff076d99d8c8bc2058d138327c7bf37cb5bc79 Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Thu, 2 Jul 2026 07:22:33 +0200 Subject: [PATCH 21/68] Validate Ledger semantic posting shapes Replace structural validation around removed projection membership truth with semantic posting, owner, group, local-key, and corporate-action leg checks. This rejects malformed semantic Ledger entries while keeping descriptor-created entries, migration safety, and native corporate-action projection behavior intact. XML and protobuf schemas, InvestmentPlan linkage semantics, migration diagnostics logging, facade cleanup, and deferred Ledger entry/posting UUID boundary usage are intentionally left unchanged. --- ...onsWithSecurityCanHaveExDateCheckTest.java | 8 +- .../ledger/LedgerEntryDefinitionTest.java | 4 +- .../ledger/LedgerMutationContextTest.java | 6 +- .../LedgerPostingTypeDefinitionTest.java | 4 +- .../ledger/LedgerStructuralValidatorTest.java | 349 ++++++++++++++- ...ger-v6-spin-off-siemens-energy-example.xml | 20 +- ...LegacyTransactionToLedgerMigratorTest.java | 13 +- .../ledger/LedgerStructuralValidator.java | 398 +++++++++++++++--- .../LedgerNativeEntryDefinitionValidator.java | 13 +- .../LedgerNativeEntryAssembler.java | 12 +- 10 files changed, 727 insertions(+), 100 deletions(-) diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/checks/impl/OnlyAccountTransactionsWithSecurityCanHaveExDateCheckTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/checks/impl/OnlyAccountTransactionsWithSecurityCanHaveExDateCheckTest.java index 89a1ad18fe..63e072d582 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/checks/impl/OnlyAccountTransactionsWithSecurityCanHaveExDateCheckTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/checks/impl/OnlyAccountTransactionsWithSecurityCanHaveExDateCheckTest.java @@ -83,11 +83,11 @@ public void testLedgerBackedExDateWithoutSecurityClearsOnlyExDateFact() entry.setType(LedgerEntryType.DIVIDENDS); entry.setDateTime(DATE_TIME); - posting.setType(LedgerPostingType.CASH); + posting.setType(LedgerPostingType.FEE); posting.setAccount(account); posting.setAmount(Values.Amount.factorize(10)); posting.setCurrency(CurrencyUnit.EUR); - posting.setSemanticRole(LedgerPostingSemanticRole.CASH); + posting.setSemanticRole(LedgerPostingSemanticRole.FEE); posting.setDirection(LedgerPostingDirection.NEUTRAL); posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); posting.addParameter(LedgerParameter.ofLocalDateTime(LedgerParameterType.EX_DATE, EX_DATE)); @@ -147,11 +147,11 @@ public void testLedgerBackedExDateClearSurvivesXmlReload() throws Exception entry.setType(LedgerEntryType.FEES); entry.setDateTime(DATE_TIME); - posting.setType(LedgerPostingType.CASH); + posting.setType(LedgerPostingType.FEE); posting.setAccount(account); posting.setAmount(Values.Amount.factorize(10)); posting.setCurrency(CurrencyUnit.EUR); - posting.setSemanticRole(LedgerPostingSemanticRole.CASH); + posting.setSemanticRole(LedgerPostingSemanticRole.FEE); posting.setDirection(LedgerPostingDirection.NEUTRAL); posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); posting.addParameter(LedgerParameter.ofLocalDateTime(LedgerParameterType.EX_DATE, EX_DATE)); 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 index 8d24b0370e..faf5cf338c 100644 --- 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 @@ -645,7 +645,9 @@ public void testDefinitionLayerDoesNotEnforceNativeCompleteness() entry.addPosting(posting); ledger.addEntry(entry); - assertTrue(LedgerStructuralValidator.validate(ledger).isOK()); + assertFalse(LedgerStructuralValidator.validate(ledger).isOK()); + assertTrue(LedgerStructuralValidator.validate(ledger) + .hasIssue(LedgerStructuralValidator.IssueCode.SEMANTIC_SOURCE_REQUIRED)); } private void assertRequiredPosting(name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinition definition, diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerMutationContextTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerMutationContextTest.java index fcc4a7c6a7..f61227ddb2 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerMutationContextTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerMutationContextTest.java @@ -203,8 +203,8 @@ public void testFailedContextMutationDoesNotRefreshOwnerListsOrMutateLiveLedger( () -> new LedgerMutationContext(client).mutateEntry(entry, editedEntry -> name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(editedEntry).get(0).getPrimaryPosting().setAccount(null))); - assertTrue(exception.getMessage(), exception.getMessage().contains("[MISSING_SEMANTIC_PRIMARY] ")); - assertTrue(exception.getMessage(), exception.getMessage().contains("Semantic account owner is missing")); + assertTrue(exception.getMessage(), exception.getMessage().contains("[SEMANTIC_OWNER_REQUIRED] ")); + assertTrue(exception.getMessage(), exception.getMessage().contains("Semantic primary owner is missing")); assertSame(originalProjectionAccount, name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0).getAccount()); assertSame(originalPostingAccount, entry.getPostings().get(0).getAccount()); @@ -374,7 +374,7 @@ private LedgerEntry targetedAccountEntry(Account account) posting.setAmount(Values.Amount.factorize(100)); posting.setCurrency(CurrencyUnit.EUR); posting.setSemanticRole(LedgerPostingSemanticRole.CASH); - posting.setDirection(LedgerPostingDirection.INBOUND); + posting.setDirection(LedgerPostingDirection.NEUTRAL); posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); entry.addPosting(posting); 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 index bfa780576a..7e09c08478 100644 --- 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 @@ -168,7 +168,9 @@ public void testDefinitionLayerDoesNotEnforcePostingFactCompleteness() entry.addPosting(posting); ledger.addEntry(entry); - assertTrue(LedgerStructuralValidator.validate(ledger).isOK()); + assertFalse(LedgerStructuralValidator.validate(ledger).isOK()); + assertTrue(LedgerStructuralValidator.validate(ledger) + .hasIssue(LedgerStructuralValidator.IssueCode.SEMANTIC_SOURCE_REQUIRED)); assertFalse(LedgerPostingTypeDefinitionRegistry.lookup(LedgerPostingType.CASH).orElseThrow() .supportsParameterType(LedgerParameterType.FEE_REASON)); } 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 index f84f3355f9..3105ed43d1 100644 --- 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 @@ -1,11 +1,18 @@ 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.LedgerEntryType; import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; import name.abuchen.portfolio.money.CurrencyUnit; @@ -29,31 +36,355 @@ 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() + { + for (var type : new LedgerEntryType[] { LedgerEntryType.SPIN_OFF, LedgerEntryType.STOCK_DIVIDEND, + LedgerEntryType.BONUS_ISSUE, LedgerEntryType.RIGHTS_DISTRIBUTION, + LedgerEntryType.BOND_CONVERSION }) + assertOK(LedgerStructuralValidator.validate(ledger(nativeEntry(type)))); + } + + @Test + public void testMissingCorporateActionLegIsRejected() + { + var entry = nativeEntry(LedgerEntryType.SPIN_OFF); + entry.removePosting(posting(entry, CorporateActionLeg.SOURCE_SECURITY)); + + assertIssue(entry, IssueCode.SEMANTIC_SOURCE_REQUIRED); + } + + @Test + public void testDuplicateCorporateActionLegWithoutLocalKeyIsRejected() + { + var entry = nativeEntry(LedgerEntryType.SPIN_OFF); + 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); + } + 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(); - var posting = new LedgerPosting(); + entry.setType(type); + entry.setDateTime(LocalDateTime.of(2026, 1, 1, 10, 0)); - entry.setType(LedgerEntryType.DEPOSIT); + 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)); - posting.setType(LedgerPostingType.CASH); + switch (type) + { + case 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)); + } + case STOCK_DIVIDEND, BONUS_ISSUE -> entry.addPosting(securityPosting("target", //$NON-NLS-1$ + LedgerPostingDirection.INBOUND, CorporateActionLeg.TARGET_SECURITY)); + case RIGHTS_DISTRIBUTION -> entry.addPosting(rightPosting("right", CorporateActionLeg.RIGHT_SECURITY)); //$NON-NLS-1$ + case BOND_CONVERSION -> { + entry.addPosting(bondPosting("source", LedgerPostingDirection.OUTBOUND, //$NON-NLS-1$ + CorporateActionLeg.CONVERSION_SOURCE)); + entry.addPosting(bondPosting("target", LedgerPostingDirection.INBOUND, //$NON-NLS-1$ + CorporateActionLeg.CONVERSION_TARGET)); + } + 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.setSemanticRole(LedgerPostingSemanticRole.CASH); - posting.setDirection(LedgerPostingDirection.NEUTRAL); + 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 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.setGroupKey(LedgerProjectionRole.ACCOUNT.name()); + posting.setLocalKey(localKey); + posting.setGroupKey(localKey); + } - entry.addPosting(posting); - ledger.addEntry(entry); + private LedgerPosting posting(LedgerEntry entry, LedgerPostingDirection direction) + { + return entry.getPostings().stream().filter(posting -> posting.getDirection() == direction).findFirst() + .orElseThrow(); + } - return ledger; + 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.tests/src/name/abuchen/portfolio/model/ledger/ledger-v6-spin-off-siemens-energy-example.xml b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/ledger-v6-spin-off-siemens-energy-example.xml index a5be13b876..4491a365ee 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/ledger-v6-spin-off-siemens-energy-example.xml +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/ledger-v6-spin-off-siemens-energy-example.xml @@ -309,7 +309,7 @@ - + 20000000-0000-0000-0000-000000000002 SECURITY 109960 @@ -360,7 +360,7 @@ - + 20000000-0000-0000-0000-000000000007 SECURITY 109960 @@ -401,7 +401,7 @@ - + 20000000-0000-0000-0000-000000000003 SECURITY 10605 @@ -462,7 +462,7 @@ - + 20000000-0000-0000-0000-000000000004 CASH_COMPENSATION 500 @@ -512,7 +512,7 @@ - + 20000000-0000-0000-0000-000000000005 FEE 200 @@ -537,7 +537,7 @@ - + 20000000-0000-0000-0000-000000000006 TAX 100 @@ -586,7 +586,7 @@ 2026-06-15T10:41:34.896212600Z - + 29ef4ac0-ce62-4bfb-8bed-5dbabf5a8ca4 CASH 118640 @@ -595,7 +595,7 @@ - + 7898a9e0-ff1b-484b-81ff-37ad41d3b611 SECURITY 118640 @@ -614,7 +614,7 @@ 2026-06-15T10:41:50.210577100Z - + ed4ed7ce-87dc-4c1c-a3cd-32dc41370bf1 CASH 1000000 @@ -627,4 +627,4 @@ - \ No newline at end of file + diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigratorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigratorTest.java index 726fdb56e0..30156822c1 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigratorTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigratorTest.java @@ -1228,8 +1228,9 @@ private void assertDuplicateConflictWithMismatch(LegacyTransactionToLedgerMigrat private void assertDiagnostic(LegacyTransactionToLedgerMigrator.MigrationResult result, String... fragments) { - assertTrue(result.getDiagnostics().stream().anyMatch(diagnostic -> List.of(fragments).stream() - .allMatch(diagnostic::contains))); + assertTrue("Missing diagnostic fragments " + List.of(fragments) + " in " + result.getDiagnostics(), //$NON-NLS-1$ //$NON-NLS-2$ + result.getDiagnostics().stream().anyMatch(diagnostic -> List.of(fragments).stream() + .allMatch(diagnostic::contains))); } private LedgerEntry existingAccountProjectionEntry(LedgerEntryType entryType, Account account, String projectionUUID) @@ -1288,8 +1289,10 @@ private LedgerEntry existingBuySellEntry(Account account, Portfolio portfolio, S cashPosting.setAmount(Values.Amount.factorize(100)); cashPosting.setCurrency(CurrencyUnit.EUR); cashPosting.setSemanticRole(LedgerPostingSemanticRole.CASH); - cashPosting.setDirection(LedgerPostingDirection.NEUTRAL); + cashPosting.setDirection(LedgerPostingDirection.OUTBOUND); cashPosting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + cashPosting.setGroupKey("ACCOUNT"); //$NON-NLS-1$ + cashPosting.setLocalKey("ACCOUNT"); //$NON-NLS-1$ securityPosting.setType(LedgerPostingType.SECURITY); securityPosting.setPortfolio(portfolio); securityPosting.setAmount(Values.Amount.factorize(100)); @@ -1297,8 +1300,10 @@ private LedgerEntry existingBuySellEntry(Account account, Portfolio portfolio, S securityPosting.setSecurity(security()); securityPosting.setShares(Values.Share.factorize(5)); securityPosting.setSemanticRole(LedgerPostingSemanticRole.SECURITY); - securityPosting.setDirection(LedgerPostingDirection.NEUTRAL); + securityPosting.setDirection(LedgerPostingDirection.INBOUND); securityPosting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + securityPosting.setGroupKey("PORTFOLIO"); //$NON-NLS-1$ + securityPosting.setLocalKey("PORTFOLIO"); //$NON-NLS-1$ entry.addPosting(cashPosting); entry.addPosting(securityPosting); 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 index 9b2f8a44bd..a844bd355e 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerStructuralValidator.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerStructuralValidator.java @@ -14,6 +14,7 @@ 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.LedgerEntryType; import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; @@ -60,7 +61,19 @@ public enum IssueCode PARAMETER_CODE_NOT_ALLOWED, EX_DATE_VALUE_KIND_REQUIRED, EX_DATE_SECURITY_REQUIRED, - SIGNED_FACT_NOT_ALLOWED + 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() @@ -86,30 +99,8 @@ public static ValidationResult validate(Ledger ledger) private static void validateEntries(Ledger ledger, List issues) { - var entryUUIDCounts = new LinkedHashMap(); - var postingUUIDCounts = new LinkedHashMap(); for (var entry : ledger.getEntries()) { - if (isBlank(entry.getUUID())) - issues.add(entryIssue(IssueCode.ENTRY_UUID_REQUIRED, - LedgerDiagnosticCode.LEDGER_STRUCT_005 - .message(Messages.LedgerStructuralValidatorEntryUuidRequired), - entry)); - else - { - var occurrenceCount = entryUUIDCounts.merge(entry.getUUID(), 1, Integer::sum); - if (occurrenceCount > 1) - issues.add(entryIssue(IssueCode.DUPLICATE_ENTRY_UUID, - LedgerDiagnosticCode.LEDGER_STRUCT_006 - .message(MessageFormat.format( - Messages.LedgerStructuralValidatorDuplicateEntryUuid, - entry.getUUID())), - entry) - .withDetail("objectType", "LedgerEntry") //$NON-NLS-1$ //$NON-NLS-2$ - .withDetail("duplicateUUID", entry.getUUID()) //$NON-NLS-1$ - .withDetail("occurrenceCount", occurrenceCount)); //$NON-NLS-1$ - } - if (entry.getType() == null) issues.add(entryIssue(IssueCode.ENTRY_TYPE_REQUIRED, LedgerDiagnosticCode.LEDGER_STRUCT_007 @@ -127,40 +118,14 @@ private static void validateEntries(Ledger ledger, List issues) validateParameters(entry, null, entry.getParameters(), issues); - validatePostings(entry, postingUUIDCounts, issues); + validatePostings(entry, issues); } } - private static Set validatePostings(LedgerEntry entry, Map ledgerPostingUUIDCounts, - List issues) + private static void validatePostings(LedgerEntry entry, List issues) { - var entryPostingUUIDs = new HashSet(); - for (var posting : entry.getPostings()) { - if (isBlank(posting.getUUID())) - issues.add(postingIssue(IssueCode.POSTING_UUID_REQUIRED, - LedgerDiagnosticCode.LEDGER_STRUCT_009.message(MessageFormat.format( - Messages.LedgerStructuralValidatorPostingUuidRequired, - entry.getUUID())), - entry, posting)); - else - { - entryPostingUUIDs.add(posting.getUUID()); - - var occurrenceCount = ledgerPostingUUIDCounts.merge(posting.getUUID(), 1, Integer::sum); - if (occurrenceCount > 1) - issues.add(postingIssue(IssueCode.DUPLICATE_POSTING_UUID, - LedgerDiagnosticCode.LEDGER_STRUCT_010 - .message(MessageFormat.format( - Messages.LedgerStructuralValidatorDuplicatePostingUuid, - posting.getUUID())), - entry, posting) - .withDetail("objectType", "LedgerPosting") //$NON-NLS-1$ //$NON-NLS-2$ - .withDetail("duplicateUUID", posting.getUUID()) //$NON-NLS-1$ - .withDetail("occurrenceCount", occurrenceCount)); //$NON-NLS-1$ - } - if (posting.getType() == null) issues.add(postingIssue(IssueCode.POSTING_TYPE_REQUIRED, LedgerDiagnosticCode.LEDGER_STRUCT_011.message(MessageFormat.format( @@ -182,8 +147,7 @@ private static Set validatePostings(LedgerEntry entry, Map issues) @@ -226,6 +190,327 @@ private static void validatePostingShape(LedgerEntry entry, LedgerPosting postin 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 SPIN_OFF -> { + requireCorporatePrimary(entry, LedgerProjectionRole.OLD_SECURITY_LEG, + LedgerPostingSemanticRole.SECURITY, CorporateActionLeg.SOURCE_SECURITY, + LedgerPostingDirection.OUTBOUND, false, false, issues); + requireCorporatePrimary(entry, LedgerProjectionRole.NEW_SECURITY_LEG, + LedgerPostingSemanticRole.SECURITY, CorporateActionLeg.TARGET_SECURITY, + LedgerPostingDirection.INBOUND, true, false, issues); + requireCorporatePrimary(entry, LedgerProjectionRole.CASH_COMPENSATION, + LedgerPostingSemanticRole.CASH_COMPENSATION, CorporateActionLeg.CASH_COMPENSATION, + LedgerPostingDirection.NEUTRAL, false, true, issues); + } + case STOCK_DIVIDEND, BONUS_ISSUE -> { + requireCorporatePrimary(entry, LedgerProjectionRole.DELIVERY_INBOUND, + LedgerPostingSemanticRole.SECURITY, CorporateActionLeg.TARGET_SECURITY, + LedgerPostingDirection.INBOUND, false, false, issues); + requireCorporatePrimary(entry, LedgerProjectionRole.CASH_COMPENSATION, + LedgerPostingSemanticRole.CASH_COMPENSATION, CorporateActionLeg.CASH_COMPENSATION, + LedgerPostingDirection.NEUTRAL, false, true, issues); + } + case RIGHTS_DISTRIBUTION -> { + requireOneOfCorporatePrimary(entry, LedgerProjectionRole.NEW_SECURITY_LEG, + LedgerPostingDirection.INBOUND, false, issues, CorporateActionLeg.RIGHT_SECURITY, + CorporateActionLeg.DISTRIBUTED_SECURITY); + requireCorporatePrimary(entry, LedgerProjectionRole.OLD_SECURITY_LEG, + LedgerPostingSemanticRole.SECURITY, CorporateActionLeg.SOURCE_SECURITY, + LedgerPostingDirection.OUTBOUND, false, true, issues); + requireCorporatePrimary(entry, LedgerProjectionRole.CASH_COMPENSATION, + LedgerPostingSemanticRole.CASH_COMPENSATION, CorporateActionLeg.CASH_COMPENSATION, + LedgerPostingDirection.NEUTRAL, false, true, issues); + } + case BOND_CONVERSION -> { + requireCorporatePrimary(entry, LedgerProjectionRole.OLD_SECURITY_LEG, + CorporateActionLeg.CONVERSION_SOURCE, LedgerPostingDirection.OUTBOUND, false, false, + issues); + requireCorporatePrimary(entry, LedgerProjectionRole.NEW_SECURITY_LEG, + CorporateActionLeg.CONVERSION_TARGET, LedgerPostingDirection.INBOUND, false, false, + issues); + requireCorporatePrimary(entry, LedgerProjectionRole.CASH_COMPENSATION, + LedgerPostingSemanticRole.CASH_COMPENSATION, CorporateActionLeg.CASH_COMPENSATION, + LedgerPostingDirection.NEUTRAL, false, true, issues); + } + default -> { + // No semantic shape rule. + } + } + + validateUnitGrouping(entry, issues); + } + + private static void requireCorporatePrimary(LedgerEntry entry, LedgerProjectionRole role, + LedgerPostingSemanticRole semanticRole, CorporateActionLeg leg, LedgerPostingDirection direction, + boolean localKeyRequired, boolean optional, List issues) + { + var matches = entry.getPostings().stream() // + .filter(posting -> matchesPrimary(posting, semanticRole, direction, leg)) // + .filter(posting -> !localKeyRequired || role.name().equals(posting.getLocalKey())) // + .toList(); + + validatePrimaryMatches(entry, role, OwnerKind.PORTFOLIO_OR_ACCOUNT, optional, matches, issues); + + if (!localKeyRequired) + return; + + entry.getPostings().stream() // + .filter(posting -> matchesPrimary(posting, semanticRole, direction, leg)) // + .filter(posting -> isBlank(posting.getLocalKey())) // + .findFirst() + .ifPresent(posting -> issues.add(postingIssue(IssueCode.SEMANTIC_LOCAL_KEY_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_027.message( + "Repeated corporate-action leg requires a local key for " + + role), + entry, posting).withDetail("projectionRole", role))); //$NON-NLS-1$ + } + + private static void requireCorporatePrimary(LedgerEntry entry, LedgerProjectionRole role, CorporateActionLeg leg, + LedgerPostingDirection direction, boolean localKeyRequired, boolean optional, + List issues) + { + var matches = entry.getPostings().stream() // + .filter(posting -> posting.getUnitRole() == LedgerPostingUnitRole.PRIMARY) // + .filter(posting -> posting.getDirection() == direction) // + .filter(posting -> posting.getCorporateActionLeg() == leg) // + .filter(posting -> !localKeyRequired || role.name().equals(posting.getLocalKey())) // + .toList(); + + validatePrimaryMatches(entry, role, OwnerKind.PORTFOLIO_OR_ACCOUNT, optional, matches, issues); + + if (!localKeyRequired) + return; + + entry.getPostings().stream() // + .filter(posting -> posting.getUnitRole() == LedgerPostingUnitRole.PRIMARY) // + .filter(posting -> posting.getDirection() == direction) // + .filter(posting -> posting.getCorporateActionLeg() == leg) // + .filter(posting -> isBlank(posting.getLocalKey())) // + .findFirst() + .ifPresent(posting -> issues.add(postingIssue(IssueCode.SEMANTIC_LOCAL_KEY_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_027.message( + "Repeated corporate-action leg requires a local key for " + + role), + entry, posting).withDetail("projectionRole", role))); //$NON-NLS-1$ + } + + private static void requireOneOfCorporatePrimary(LedgerEntry entry, LedgerProjectionRole role, + LedgerPostingSemanticRole semanticRole, LedgerPostingDirection direction, boolean optional, + List issues, CorporateActionLeg... legs) + { + var matches = entry.getPostings().stream() // + .filter(posting -> posting.getUnitRole() == LedgerPostingUnitRole.PRIMARY) // + .filter(posting -> posting.getSemanticRole() == semanticRole) // + .filter(posting -> posting.getDirection() == direction) // + .filter(posting -> contains(legs, posting.getCorporateActionLeg())) // + .toList(); + + validatePrimaryMatches(entry, role, OwnerKind.PORTFOLIO, optional, matches, issues); + } + + private static void requireOneOfCorporatePrimary(LedgerEntry entry, LedgerProjectionRole role, + LedgerPostingDirection direction, boolean optional, List issues, + CorporateActionLeg... legs) + { + var matches = entry.getPostings().stream() // + .filter(posting -> posting.getUnitRole() == LedgerPostingUnitRole.PRIMARY) // + .filter(posting -> posting.getDirection() == direction) // + .filter(posting -> contains(legs, posting.getCorporateActionLeg())) // + .toList(); + + validatePrimaryMatches(entry, role, OwnerKind.PORTFOLIO, optional, matches, issues); + } + + 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), + 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), + 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 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), + 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"), + 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"), + 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"), + 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 boolean contains(CorporateActionLeg[] legs, CorporateActionLeg value) + { + for (var leg : legs) + if (leg == value) + return true; + + return false; + } + private static void validateParameters(LedgerEntry entry, LedgerPosting posting, List> parameters, List issues) { @@ -615,4 +900,11 @@ private String detailValue(Object value) 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/configuration/LedgerNativeEntryDefinitionValidator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java index dde7efdddc..98ce054041 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java @@ -186,13 +186,9 @@ private static boolean isSatisfied(LedgerEntry entry, LedgerRequirementGroup gro private static void validateLegs(LedgerEntry entry, LedgerEntryDefinition definition, List issues) { - var postingsByUUID = entry.getPostings().stream() // - .collect(Collectors.toMap(LedgerPosting::getUUID, posting -> posting, (left, right) -> left, - LinkedHashMap::new)); - for (var leg : definition.getLegDefinitions()) { - var match = matchLeg(entry, definition, leg, postingsByUUID, issues); + var match = matchLeg(entry, definition, leg, issues); validateCardinality(entry, leg, match.postings(), issues); validateLegParameters(entry, leg, match.postings(), issues); @@ -200,20 +196,19 @@ private static void validateLegs(LedgerEntry entry, LedgerEntryDefinition defini } private static LegMatch matchLeg(LedgerEntry entry, LedgerEntryDefinition definition, LedgerLegDefinition leg, - Map postingsByUUID, List issues) + List issues) { var projectionRole = leg.getProjectionRole(); if (projectionRole.isPresent()) - return matchProjectedLeg(entry, definition, leg, projectionRole.get(), postingsByUUID, issues); + return matchProjectedLeg(entry, definition, leg, projectionRole.get(), issues); return new LegMatch(entry.getPostings().stream() // .filter(posting -> postingMatchesLeg(entry.getType(), posting, leg)).toList()); } private static LegMatch matchProjectedLeg(LedgerEntry entry, LedgerEntryDefinition definition, - LedgerLegDefinition leg, LedgerProjectionRole projectionRole, Map postingsByUUID, - List issues) + LedgerLegDefinition leg, LedgerProjectionRole projectionRole, List issues) { var descriptors = Collections.emptyList(); try diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssembler.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssembler.java index 340c64f22a..b08d71364c 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssembler.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssembler.java @@ -178,18 +178,18 @@ public LedgerNativeEntryBuildResult buildDetached() if (compensationPosting != null) addCashCompensationProjection(entry, cashCompensation, compensationPosting); - var validationResult = validateDetached(entry); - - if (!validationResult.isOK()) - throw issue(LedgerNativeEntryAssemblyIssue.STRUCTURAL_VALIDATION_FAILED, - validationResult.format()); - var definitionValidationResult = LedgerNativeEntryDefinitionValidator.validate(entry); if (!definitionValidationResult.isOK()) throw issue(LedgerNativeEntryAssemblyIssue.NATIVE_DEFINITION_VALIDATION_FAILED, definitionValidationResult.format()); + var validationResult = validateDetached(entry); + + if (!validationResult.isOK()) + throw issue(LedgerNativeEntryAssemblyIssue.STRUCTURAL_VALIDATION_FAILED, + validationResult.format()); + return new LedgerNativeEntryBuildResult(entry, validationResult); } From c2b79ba4e1105e5281edabe4d2e7cef831d2ebf6 Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Thu, 2 Jul 2026 07:43:31 +0200 Subject: [PATCH 22/68] Log Ledger migration diagnostics Add runtime migration diagnostics logging with INFO and WARNING summaries for complete, incomplete, failed, and mixed-state Ledger migration paths. This makes migration attempts visible with numeric counts while preserving failed legacy rows and avoiding silent remigration once Ledger truth exists. XML and protobuf schemas, InvestmentPlan linkage, descriptor materialization, validation rules, and facade cleanup are intentionally left unchanged. --- .../model/LedgerProtobufPersistenceTest.java | 67 +++++++++ .../ledger/LedgerXmlPersistenceTest.java | 47 ++++++ ...LegacyTransactionToLedgerMigratorTest.java | 107 ++++++++++++++ .../name/abuchen/portfolio/PortfolioLog.java | 15 ++ .../portfolio/model/ClientFactory.java | 2 + .../portfolio/model/ProtobufWriter.java | 2 + .../legacy/LedgerMigrationDiagnostics.java | 138 ++++++++++++++++++ .../LegacyTransactionToLedgerMigrator.java | 54 ++++++- 8 files changed, 430 insertions(+), 2 deletions(-) create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/legacy/LedgerMigrationDiagnostics.java diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceTest.java index e323208c6a..9789477f66 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceTest.java @@ -16,11 +16,14 @@ import java.time.Instant; import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; +import org.eclipse.core.runtime.IStatus; import org.junit.Test; import name.abuchen.portfolio.Messages; +import name.abuchen.portfolio.PortfolioLog; import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerParameter; import name.abuchen.portfolio.model.ledger.LedgerPosting; @@ -941,6 +944,58 @@ public void testUnsupportedLegacyRowsRemainCompatibleWhenLedgerTruthExists() thr .filter(t -> legacyTransaction.getUUID().equals(t.getUUID())).count(), is(1L)); } + /** + * Verifies that protobuf loads with Ledger truth and real legacy rows report a mixed state. + * Preserved legacy rows remain data, and loading must not start a silent remigration loop. + */ + @Test + public void testMixedStateProtobufLoadLogsWarningWhenLedgerTruthAndLegacyRowsRemain() throws Exception + { + var fixture = fixture(); + var statuses = new ArrayList(); + new LedgerTransactionCreator(fixture.client()).createDeposit(metadata(), cashLeg(fixture.account(), 100)); + + var legacyTransaction = new AccountTransaction(AccountTransaction.Type.FEES); + legacyTransaction.setDateTime(DATE_TIME); + legacyTransaction.setCurrencyCode(CurrencyUnit.EUR); + legacyTransaction.setAmount(Values.Amount.factorize(7)); + legacyTransaction.setUpdatedAt(UPDATED_AT); + fixture.account().addTransaction(legacyTransaction); + + try (var ignored = PortfolioLog.withTestSink(statuses::add)) + { + load(saveBytes(fixture.client())); + } + + var status = onlyStatus(statuses); + + assertThat(status.getSeverity(), is(IStatus.WARNING)); + assertLogContains(status, "source=protobuf-mixed-state", "inspected=0", "migrated=0", //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ + "preserved=1", "failed=0", "mixedState=1", "MIXED_STATE", //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$ + "preserved legacy rows were not deleted", //$NON-NLS-1$ + "No silent remigration loop will run after Ledger truth exists"); //$NON-NLS-1$ + } + + /** + * Verifies generated protobuf compatibility shadows are not counted as stranded legacy source rows. + * The Ledger truth should reload quietly when no real legacy row remains. + */ + @Test + public void testProtobufCompatibilityShadowsAreNotCountedAsMixedState() throws Exception + { + var fixture = fixture(); + var statuses = new ArrayList(); + new LedgerTransactionCreator(fixture.client()).createDeposit(metadata(), cashLeg(fixture.account(), 100)); + + try (var ignored = PortfolioLog.withTestSink(statuses::add)) + { + load(saveBytes(fixture.client())); + } + + assertFalse(statuses.stream().anyMatch(status -> status.getSeverity() == IStatus.WARNING + && status.getMessage().contains("source=protobuf-mixed-state"))); + } + private ClientFixture fixture() { var client = new Client(); @@ -1200,6 +1255,18 @@ private Client load(byte[] bytes) throws IOException return new ProtobufWriter().load(new ByteArrayInputStream(bytes)); } + private IStatus onlyStatus(List statuses) + { + assertThat(statuses.size(), is(1)); + return statuses.get(0); + } + + private void assertLogContains(IStatus status, String... fragments) + { + for (var fragment : fragments) + assertTrue(status.getMessage(), status.getMessage().contains(fragment)); + } + private void assertUnknownTypeIdFailure(PClient client, String typeName, int id) throws IOException { var exception = assertThrows(IllegalArgumentException.class, () -> load(wrap(client))); diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerXmlPersistenceTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerXmlPersistenceTest.java index 6ba6049684..a3502c7b67 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerXmlPersistenceTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerXmlPersistenceTest.java @@ -18,11 +18,14 @@ import java.nio.file.Files; import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; +import org.eclipse.core.runtime.IStatus; import org.junit.Test; import name.abuchen.portfolio.Messages; +import name.abuchen.portfolio.PortfolioLog; import name.abuchen.portfolio.model.Account; import name.abuchen.portfolio.model.AccountTransaction; import name.abuchen.portfolio.model.AccountTransferEntry; @@ -281,6 +284,34 @@ public void testLedgerXmlLoadDoesNotRemigrateCompatibilityRows() throws Exceptio .filter(LedgerBackedTransaction.class::isInstance).count(), is(1L)); } + /** + * Verifies that XML loads with Ledger truth and real legacy rows report a mixed state. + * The preserved rows must stay present and no automatic remigration loop should run. + */ + @Test + public void testMixedStateXmlLoadLogsWarningWhenLedgerTruthAndLegacyRowsRemain() throws Exception + { + var client = new Client(); + var account = register(client, account()); + var statuses = new ArrayList(); + + new LedgerTransactionCreator(client).createDeposit(LedgerTransactionMetadata.of(DATE_TIME), + LedgerAccountCashLeg.of(account, money(100))); + account.addTransaction(accountTransaction(AccountTransaction.Type.FEES, 7)); + + try (var ignored = PortfolioLog.withTestSink(statuses::add)) + { + load(save(client)); + } + + var status = onlyMigrationStatus(statuses); + + assertThat(status.getSeverity(), is(IStatus.WARNING)); + assertLogContains(status, "source=xml-mixed-state", "inspected=0", "migrated=0", "preserved=1", //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$ + "failed=0", "mixedState=1", "MIXED_STATE", "preserved legacy rows were not deleted", //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$ + "No silent remigration loop will run after Ledger truth exists"); //$NON-NLS-1$ + } + /** * Verifies that legacy ledger parameter aliases remain readable. * Saving must write the current XML form after load. @@ -567,6 +598,22 @@ private Client load(String xml) throws Exception return ClientFactory.load(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); } + private IStatus onlyMigrationStatus(List statuses) + { + var matches = statuses.stream() + .filter(status -> status.getMessage().contains("Ledger migration diagnostics")) //$NON-NLS-1$ + .toList(); + + assertThat(matches.size(), is(1)); + return matches.get(0); + } + + private void assertLogContains(IStatus status, String... fragments) + { + for (var fragment : fragments) + assertTrue(status.getMessage(), status.getMessage().contains(fragment)); + } + private void assertLedgerParameterXmlFailure(String xml, LedgerDiagnosticCode code) { var exception = assertThrows(IOException.class, () -> load(xml)); diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigratorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigratorTest.java index 30156822c1..bd8d4f72e4 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigratorTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigratorTest.java @@ -11,12 +11,15 @@ import java.lang.reflect.InvocationTargetException; import java.math.BigDecimal; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; import java.util.function.Consumer; +import org.eclipse.core.runtime.IStatus; import org.junit.Test; import name.abuchen.portfolio.Messages; +import name.abuchen.portfolio.PortfolioLog; import name.abuchen.portfolio.model.Account; import name.abuchen.portfolio.model.AccountTransaction; import name.abuchen.portfolio.model.AccountTransferEntry; @@ -50,6 +53,98 @@ public class LegacyTransactionToLedgerMigratorTest private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 1, 2, 0, 0); private static final LocalDateTime EX_DATE = LocalDateTime.of(2025, 12, 28, 0, 0); + @Test + public void testCompleteMigrationLogsInfoWithCounts() throws Exception + { + var client = new Client(); + var account = register(client, account()); + var transaction = accountTransaction(AccountTransaction.Type.DEPOSIT, 100); + var statuses = new ArrayList(); + + account.addTransaction(transaction); + + try (var ignored = PortfolioLog.withTestSink(statuses::add)) + { + migrate(client); + } + + var status = onlyStatus(statuses); + + assertThat(status.getSeverity(), is(IStatus.INFO)); + assertLogContains(status, "inspected=1", "migrated=1", "preserved=0", "failed=0", "mixedState=0", //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$ //$NON-NLS-5$ + "preserved legacy count is zero"); //$NON-NLS-1$ + } + + @Test + public void testPartialMigrationLogsWarningWithCountsReasonsAndNotes() throws Exception + { + var client = new Client(); + var account = register(client, account()); + var supported = accountTransaction(AccountTransaction.Type.DEPOSIT, 100); + var unsupported = accountTransaction(AccountTransaction.Type.BUY, 200); + var statuses = new ArrayList(); + + account.addTransaction(supported); + account.addTransaction(unsupported); + + try (var ignored = PortfolioLog.withTestSink(statuses::add)) + { + migrate(client); + } + + var status = onlyStatus(statuses); + + assertThat(status.getSeverity(), is(IStatus.WARNING)); + assertLogContains(status, "inspected=2", "migrated=1", "preserved=1", "failed=1", "mixedState=0", //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$ //$NON-NLS-5$ + "UNSUPPORTED_TYPE", "examples=", "preserved legacy rows were not deleted", //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ + "No silent remigration loop will run after Ledger truth exists"); //$NON-NLS-1$ + } + + @Test + public void testFailedMigrationLogsWarningAndPreservesRows() throws Exception + { + var client = new Client(); + var account = register(client, account()); + var invalid = accountTransaction(AccountTransaction.Type.REMOVAL, -10); + var statuses = new ArrayList(); + + account.addTransaction(invalid); + + try (var ignored = PortfolioLog.withTestSink(statuses::add)) + { + migrate(client); + } + + var status = onlyStatus(statuses); + + assertThat(status.getSeverity(), is(IStatus.WARNING)); + assertThat(account.getTransactions().size(), is(1)); + assertLogContains(status, "inspected=1", "migrated=0", "preserved=1", "failed=1", //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$ + "FAILED_VALIDATION", "preserved legacy rows were not deleted"); //$NON-NLS-1$ //$NON-NLS-2$ + } + + @Test + public void testMigrationDiagnosticsCapsExamples() throws Exception + { + var client = new Client(); + var account = register(client, account()); + var statuses = new ArrayList(); + + for (var index = 0; index < 12; index++) + account.addTransaction(accountTransaction(AccountTransaction.Type.BUY, 100 + index)); + + try (var ignored = PortfolioLog.withTestSink(statuses::add)) + { + migrate(client); + } + + var status = onlyStatus(statuses); + + assertThat(status.getSeverity(), is(IStatus.WARNING)); + assertLogContains(status, "inspected=12", "migrated=0", "preserved=12", "failed=12", //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$ + "omittedExamples=2"); //$NON-NLS-1$ + } + @Test public void testAccountOnlyFamiliesMigrateWithProjectionUUIDAndApiParity() { @@ -1233,6 +1328,18 @@ private void assertDiagnostic(LegacyTransactionToLedgerMigrator.MigrationResult .allMatch(diagnostic::contains))); } + private IStatus onlyStatus(List statuses) + { + assertThat(statuses.size(), is(1)); + return statuses.get(0); + } + + private void assertLogContains(IStatus status, String... fragments) + { + for (var fragment : fragments) + assertTrue(status.getMessage(), status.getMessage().contains(fragment)); + } + private LedgerEntry existingAccountProjectionEntry(LedgerEntryType entryType, Account account, String projectionUUID) { var entry = new LedgerEntry(); diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/PortfolioLog.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/PortfolioLog.java index 24fc55a67e..89abb5b57d 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/PortfolioLog.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/PortfolioLog.java @@ -1,6 +1,8 @@ package name.abuchen.portfolio; import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; import org.eclipse.core.runtime.ILog; import org.eclipse.core.runtime.IStatus; @@ -15,6 +17,8 @@ public class PortfolioLog */ private static final String PLUGIN_ID = "name.abuchen.portfolio"; //$NON-NLS-1$ + private static Consumer testSink; + private PortfolioLog() { } @@ -31,6 +35,17 @@ private static void log(IStatus status) // available System.err.println(status); // NOSONAR } + + if (testSink != null) + testSink.accept(status); + } + + public static AutoCloseable withTestSink(Consumer sink) + { + var previous = testSink; + testSink = Objects.requireNonNull(sink); + + return () -> testSink = previous; } /** diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ClientFactory.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ClientFactory.java index afc50547a3..16f5faf2e6 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ClientFactory.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ClientFactory.java @@ -104,6 +104,7 @@ 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.model.ledger.legacy.LedgerMigrationDiagnostics; import name.abuchen.portfolio.model.ledger.legacy.LegacyTransactionToLedgerMigrator; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionService; @@ -660,6 +661,7 @@ private void initializeLedgerXmlState(Client client) throws IOException removeLegacyProjectionShadows(client); LedgerProjectionService.restoreIfValid(client); + LedgerMigrationDiagnostics.logMixedState(client, "xml-mixed-state"); //$NON-NLS-1$ } private void prepareLedgerXmlSave(Client client, LedgerXmlSaveState saveState) throws IOException diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ProtobufWriter.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ProtobufWriter.java index 75c8f3d153..88223148dd 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ProtobufWriter.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ProtobufWriter.java @@ -52,6 +52,7 @@ 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.model.ledger.legacy.LedgerMigrationDiagnostics; import name.abuchen.portfolio.model.ledger.legacy.LegacyTransactionToLedgerMigrator; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionService; @@ -184,6 +185,7 @@ public Client load(InputStream input) throws IOException if (hasLedgerTruth) { LedgerProjectionService.restoreIfValid(client); + LedgerMigrationDiagnostics.logMixedState(client, "protobuf-mixed-state"); //$NON-NLS-1$ } else { diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/legacy/LedgerMigrationDiagnostics.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/legacy/LedgerMigrationDiagnostics.java new file mode 100644 index 0000000000..36b13dd515 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/legacy/LedgerMigrationDiagnostics.java @@ -0,0 +1,138 @@ +package name.abuchen.portfolio.model.ledger.legacy; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.regex.Pattern; + +import name.abuchen.portfolio.PortfolioLog; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.Transaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; + +public final class LedgerMigrationDiagnostics +{ + private static final String SHADOW_PREFIX = "ledger-shadow:"; //$NON-NLS-1$ + private static final int EXAMPLE_LIMIT = 10; + private static final Pattern REASON_PATTERN = Pattern.compile("\\breason=([A-Z0-9_]+)"); //$NON-NLS-1$ + + private LedgerMigrationDiagnostics() + { + } + + public static void logMigrationAttempt(LegacyTransactionToLedgerMigrator.MigrationResult result) + { + var warning = result.getFailedCount() > 0 || result.getPreservedLegacyTransactionCount() > 0 + || result.getMixedStateCount() > 0; + var message = format("migration", result.getInspectedLegacyTransactionCount(), //$NON-NLS-1$ + result.getMigratedTransactionCount(), result.getPreservedLegacyTransactionCount(), + result.getFailedCount(), result.getMixedStateCount(), reasonCategories(result.getDiagnostics()), + result.getDiagnostics(), warning); + + if (warning) + PortfolioLog.warning(message); + else + PortfolioLog.info(message); + } + + public static void logMixedState(Client client, String source) + { + var mixedStateCount = countLegacySourceRows(client); + + if (mixedStateCount == 0) + return; + + PortfolioLog.warning(format(source, 0, 0, mixedStateCount, 0, mixedStateCount, List.of("MIXED_STATE"), //$NON-NLS-1$ + List.of("reason=MIXED_STATE ledgerTruthExists=true"), true)); //$NON-NLS-1$ + } + + static int countLegacySourceRows(Client client) + { + var count = 0; + + for (var account : client.getAccounts()) + count += countLegacySourceRows(account.getTransactions()); + + for (var portfolio : client.getPortfolios()) + count += countLegacySourceRows(portfolio.getTransactions()); + + return count; + } + + static int failureCount(List diagnostics) + { + var count = 0; + + for (var diagnostic : diagnostics) + if (!diagnostic.contains("reason=SKIPPED_ALREADY_MIGRATED")) //$NON-NLS-1$ + count++; + + return count; + } + + private static int countLegacySourceRows(List transactions) + { + var count = 0; + + for (var transaction : transactions) + if (!(transaction instanceof LedgerBackedTransaction) && !isCompatibilityShadow(transaction)) + count++; + + return count; + } + + private static boolean isCompatibilityShadow(Transaction transaction) + { + return transaction.getUUID() != null && transaction.getUUID().startsWith(SHADOW_PREFIX); + } + + private static List reasonCategories(List diagnostics) + { + var reasons = new LinkedHashSet(); + + for (var diagnostic : diagnostics) + { + var matcher = REASON_PATTERN.matcher(diagnostic); + while (matcher.find()) + reasons.add(matcher.group(1)); + } + + if (reasons.isEmpty()) + reasons.add("COMPLETE"); //$NON-NLS-1$ + + return List.copyOf(reasons); + } + + private static String format(String source, int inspected, int migrated, int preserved, int failed, int mixedState, + List reasons, List diagnostics, boolean warning) + { + var message = new StringBuilder("Ledger migration diagnostics") //$NON-NLS-1$ + .append(" source=").append(source) //$NON-NLS-1$ + .append(" inspected=").append(inspected) //$NON-NLS-1$ + .append(" migrated=").append(migrated) //$NON-NLS-1$ + .append(" preserved=").append(preserved) //$NON-NLS-1$ + .append(" failed=").append(failed) //$NON-NLS-1$ + .append(" mixedState=").append(mixedState) //$NON-NLS-1$ + .append(" reasons=").append(reasons); //$NON-NLS-1$ + + if (!diagnostics.isEmpty()) + { + var examples = diagnostics.stream().limit(EXAMPLE_LIMIT).toList(); + message.append(" examples=").append(examples); //$NON-NLS-1$ + + if (diagnostics.size() > EXAMPLE_LIMIT) + message.append(" omittedExamples=").append(diagnostics.size() - EXAMPLE_LIMIT); //$NON-NLS-1$ + } + + if (warning) + { + message.append(" preserved legacy rows were not deleted."); //$NON-NLS-1$ + message.append(" No silent remigration loop will run after Ledger truth exists."); //$NON-NLS-1$ + } + else + { + message.append(" preserved legacy count is zero."); //$NON-NLS-1$ + } + + return message.toString(); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigrator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigrator.java index b6148d7085..25076cc46c 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigrator.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigrator.java @@ -65,7 +65,12 @@ public MigrationResult migrate(Client client) plan.markApplied(); } - return new MigrationResult(plan.getMigratedTransactionCount(), plan.getDiagnostics()); + var result = new MigrationResult(plan.getInspectedTransactionCount(), plan.getMigratedTransactionCount(), + LedgerMigrationDiagnostics.countLegacySourceRows(client), + LedgerMigrationDiagnostics.failureCount(plan.getDiagnostics()), 0, plan.getDiagnostics()); + LedgerMigrationDiagnostics.logMigrationAttempt(result); + + return result; } private void migrateAccounts(Client client, MigrationPlan plan, Set processedCrossEntries) @@ -77,6 +82,8 @@ private void migrateAccounts(Client client, MigrationPlan plan, Set if (transaction instanceof LedgerBackedTransaction) continue; + plan.markInspected(); + if (transaction.getCrossEntry() != null) { migrateAccountCrossEntry(transaction, plan, processedCrossEntries); @@ -98,6 +105,8 @@ private void migratePortfolios(Client client, MigrationPlan plan, Set projectionIdsToRemove = new HashSet<>(); private final Set plannedEntryUUIDs = new HashSet<>(); private final List diagnostics = new ArrayList<>(); + private int inspectedTransactionCount; private int migratedTransactionCount; private boolean applied; @@ -807,6 +817,11 @@ private MigrationPlan(Client client) this.client = client; } + private void markInspected() + { + inspectedTransactionCount++; + } + private void addEntry(LedgerEntry entry, Transaction... transactions) { entries.add(entry); @@ -1117,6 +1132,11 @@ private int getMigratedTransactionCount() return applied ? migratedTransactionCount : 0; } + private int getInspectedTransactionCount() + { + return inspectedTransactionCount; + } + private boolean hasChanges() { return !entries.isEmpty() || !legacyTransactionsToRemove.isEmpty(); @@ -1151,19 +1171,49 @@ private record UnitPostingFact(LedgerPostingType type, long amount, String curre public static final class MigrationResult { private final int migratedTransactionCount; + private final int inspectedLegacyTransactionCount; + private final int preservedLegacyTransactionCount; + private final int failedCount; + private final int mixedStateCount; private final List diagnostics; - private MigrationResult(int migratedTransactionCount, List diagnostics) + private MigrationResult(int inspectedLegacyTransactionCount, int migratedTransactionCount, + int preservedLegacyTransactionCount, int failedCount, int mixedStateCount, + List diagnostics) { + this.inspectedLegacyTransactionCount = inspectedLegacyTransactionCount; this.migratedTransactionCount = migratedTransactionCount; + this.preservedLegacyTransactionCount = preservedLegacyTransactionCount; + this.failedCount = failedCount; + this.mixedStateCount = mixedStateCount; this.diagnostics = List.copyOf(diagnostics); } + public int getInspectedLegacyTransactionCount() + { + return inspectedLegacyTransactionCount; + } + public int getMigratedTransactionCount() { return migratedTransactionCount; } + public int getPreservedLegacyTransactionCount() + { + return preservedLegacyTransactionCount; + } + + public int getFailedCount() + { + return failedCount; + } + + public int getMixedStateCount() + { + return mixedStateCount; + } + public List getDiagnostics() { return diagnostics; From f2afaf4f703504aeb1a287d91eb3b2bb3ad0a3ec Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Thu, 2 Jul 2026 07:55:19 +0200 Subject: [PATCH 23/68] Clean Ledger semantic facade APIs Remove no-op projection-era migration view scaffolding, rename targeted native-entry policy wording to derived descriptors, and delete obsolete projection-ref validator issue codes. This keeps the stabilized semantic Ledger model easier to read after ProjectionRef and Membership truth were removed. XML and protobuf formats, InvestmentPlan linkage, migration behavior, diagnostics, validation boundaries, rollback safety, descriptor materialization, fluent assembler APIs, and creator APIs are intentionally left unchanged. --- .../model/ledger/LedgerModelTest.java | 6 +- .../ledger/LedgerMutationContextTest.java | 9 +-- .../LedgerProjectionMaterializerTest.java | 6 +- .../ledger/LedgerSpinOffScenarioTest.java | 2 +- .../ledger/LedgerTransactionCreatorTest.java | 12 ++-- ...dgerNativeComponentInspectorModelTest.java | 12 ++-- .../LedgerNativeEntryAssemblerTest.java | 12 ++-- ...erivedProjectionDescriptorServiceTest.java | 10 +-- .../LedgerRuntimeProjectionRestorerTest.java | 2 +- ...gerTransactionDuplicateCopyParityTest.java | 22 +++---- .../model/ledger/LedgerPostingDirection.java | 2 +- .../ledger/LedgerStructuralValidator.java | 17 +---- .../LedgerEntryDefinitionRegistry.java | 2 +- .../ledger/configuration/LedgerEntryType.java | 2 +- .../configuration/LedgerLegCardinality.java | 2 +- .../LedgerNativeEntryDefinitionValidator.java | 2 +- .../LegacyTransactionToLedgerMigrator.java | 62 +------------------ .../LedgerNativeEntryAssembler.java | 2 +- .../ledger/nativeentry/NativeSecurityLeg.java | 2 +- 19 files changed, 54 insertions(+), 132 deletions(-) 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 index fcf8bbe37f..0df4b4d1d9 100644 --- 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 @@ -533,7 +533,7 @@ public void testLedgerEntryTypePoliciesSeparateStandardAndLedgerNativeShapes() standardFamilies.forEach(this::assertStandardLegacyShape); corporateActionFamilies.forEach(this::assertLedgerNativeTargetedShape); - assertTrue(LedgerEntryType.SPIN_OFF.requiresTargetedProjectionRefs()); + assertTrue(LedgerEntryType.SPIN_OFF.requiresTargetedDerivedDescriptors()); assertTrue(LedgerEntryType.SPIN_OFF.usesSignedTargetedProjectionFacts()); } @@ -711,7 +711,7 @@ private void assertStandardLegacyShape(LedgerEntryType type) { assertTrue(type.isLegacyFixedShape()); assertFalse(type.isLedgerNativeTargeted()); - assertFalse(type.requiresTargetedProjectionRefs()); + assertFalse(type.requiresTargetedDerivedDescriptors()); assertFalse(type.usesSignedTargetedProjectionFacts()); } @@ -719,7 +719,7 @@ private void assertLedgerNativeTargetedShape(LedgerEntryType type) { assertFalse(type.isLegacyFixedShape()); assertTrue(type.isLedgerNativeTargeted()); - assertTrue(type.requiresTargetedProjectionRefs()); + assertTrue(type.requiresTargetedDerivedDescriptors()); assertTrue(type.usesSignedTargetedProjectionFacts()); } diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerMutationContextTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerMutationContextTest.java index f61227ddb2..eaf676484e 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerMutationContextTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerMutationContextTest.java @@ -324,14 +324,7 @@ public void testMutationThatWouldFailOnSecondApplicationStillSynchronizesFromCan } /** - * Checks the ledger mutation scenario: projection membership and role changes refresh materialized projections. - * Failed or repeated operations must not leave partial changes or duplicate projections. - * This protects atomic ledger mutation behavior. - */ - - - /** - * Checks the ledger mutation scenario: mutation context preserves entry local projection targeting during copy sync. + * Checks the ledger mutation scenario: mutation context preserves entry-local descriptor targeting during copy sync. * Failed or repeated operations must not leave partial changes or duplicate projections. * This protects atomic ledger mutation behavior. */ diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerProjectionMaterializerTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerProjectionMaterializerTest.java index af2bf769dc..08b5a56345 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerProjectionMaterializerTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerProjectionMaterializerTest.java @@ -90,7 +90,7 @@ public void testServiceCreatesAccountBackedDepositProjection() } @Test - public void testMaterializationUsesDerivedDescriptorWhenProjectionRefsAreAbsent() + public void testMaterializationUsesDerivedDescriptorWhenPersistedProjectionLayoutIsAbsent() { var client = new Client(); var account = account(); @@ -105,7 +105,7 @@ public void testMaterializationUsesDerivedDescriptorWhenProjectionRefsAreAbsent( } @Test - public void testFixedShapeMaterializationUsesDescriptorsWithoutProjectionRefs() + public void testFixedShapeMaterializationUsesDescriptorsWithoutPersistedProjectionLayout() { assertDescriptorMaterializesBuySell(PortfolioTransaction.Type.BUY, AccountTransaction.Type.BUY); assertDescriptorMaterializesBuySell(PortfolioTransaction.Type.SELL, AccountTransaction.Type.SELL); @@ -116,7 +116,7 @@ public void testFixedShapeMaterializationUsesDescriptorsWithoutProjectionRefs() } @Test - public void testSiemensSpinOffMaterializesFromDerivedDescriptorsWithoutProjectionRefs() + public void testSiemensSpinOffMaterializesFromDerivedDescriptorsWithoutPersistedProjectionLayout() { var client = new Client(); var account = account(); diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffScenarioTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffScenarioTest.java index fde9f56b70..1a51070f3c 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffScenarioTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffScenarioTest.java @@ -87,7 +87,7 @@ public void testSpinOffUsesLedgerNativeTargetedPolicy() { assertFalse(LedgerEntryType.SPIN_OFF.isLegacyFixedShape()); assertTrue(LedgerEntryType.SPIN_OFF.isLedgerNativeTargeted()); - assertTrue(LedgerEntryType.SPIN_OFF.requiresTargetedProjectionRefs()); + assertTrue(LedgerEntryType.SPIN_OFF.requiresTargetedDerivedDescriptors()); assertTrue(LedgerEntryType.SPIN_OFF.usesSignedTargetedProjectionFacts()); } diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerTransactionCreatorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerTransactionCreatorTest.java index 704a59870c..7ad981cdc9 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerTransactionCreatorTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerTransactionCreatorTest.java @@ -263,12 +263,12 @@ public void testCreateFeeTaxFamiliesCreateValidAccountShapes() } /** - * Checks the ledger-backed editing scenario: create standard families bind projection refs to primary postings. + * Checks the ledger-backed editing scenario: create standard families bind descriptors to primary postings. * The visible transaction must reflect the ledger entry after the operation. * This protects structural facts from being written through legacy setters. */ @Test - public void testCreateStandardFamiliesBindProjectionRefsToPrimaryPostings() + public void testCreateStandardFamiliesBindDescriptorsToPrimaryPostings() { assertAccountProjectionTargetsPrimaryPosting(LedgerEntryType.DEPOSIT, creator -> creator.createDeposit(metadata(), cashLeg(account(), 10)).getEntry()); @@ -466,12 +466,12 @@ public void testCreateDividendPreservesExDateUnitsAndForex() } /** - * Checks the ledger-backed editing scenario: create buy creates two projection refs with positive magnitudes. + * Checks the ledger-backed editing scenario: create buy creates two descriptors with positive magnitudes. * The visible transaction must reflect the ledger entry after the operation. * This protects structural facts from being written through legacy setters. */ @Test - public void testCreateBuyCreatesTwoProjectionRefsWithPositiveMagnitudes() + public void testCreateBuyCreatesTwoDescriptorsWithPositiveMagnitudes() { var client = new Client(); var account = account(); @@ -492,12 +492,12 @@ public void testCreateBuyCreatesTwoProjectionRefsWithPositiveMagnitudes() } /** - * Checks the ledger-backed editing scenario: create sell creates two projection refs with positive magnitudes. + * Checks the ledger-backed editing scenario: create sell creates two descriptors with positive magnitudes. * The visible transaction must reflect the ledger entry after the operation. * This protects structural facts from being written through legacy setters. */ @Test - public void testCreateSellCreatesTwoProjectionRefsWithPositiveMagnitudes() + public void testCreateSellCreatesTwoDescriptorsWithPositiveMagnitudes() { var client = new Client(); var creator = new LedgerTransactionCreator(client); diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerNativeComponentInspectorModelTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerNativeComponentInspectorModelTest.java index be66123c57..bda76f5ce0 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerNativeComponentInspectorModelTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerNativeComponentInspectorModelTest.java @@ -45,24 +45,24 @@ /** * Tests the read-only model behind the Ledger entry inspector. * The tests make sure selected ledger-backed projections can be displayed from Ledger facts - * and optional Java-only native leg definitions without mutating the entry or legacy projection. + * and optional Java-only native leg definitions without mutating the entry or runtime projection. */ public class LedgerNativeComponentInspectorModelTest { /** * Checks that a spin-off entry can be inspected from the selected old-security projection. * The inspector model must expose entry parameters, functional legs, postings, posting - * parameters, and projection refs from the persisted Ledger entry. + * parameters, and derived descriptors from the persisted Ledger entry. */ @Test public void testSpinOffInspectionIncludesEntryLegPostingParameterAndProjectionRows() { var entry = spinOffEntry(); - var selectedProjectionRef = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0); + var selectedDescriptor = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0); var updatedAt = entry.getUpdatedAt(); var model = LedgerNativeComponentInspectorModel - .from(entry, selectedProjectionRef, LedgerEntryDefinitionRegistry::lookup).orElseThrow(); + .from(entry, selectedDescriptor, LedgerEntryDefinitionRegistry::lookup).orElseThrow(); assertThat(model.getHeaderRows().stream() .anyMatch(row -> row.field() == HeaderField.ENTRY_TYPE && "SPIN_OFF".equals(row.value())), @@ -72,7 +72,7 @@ public void testSpinOffInspectionIncludesEntryLegPostingParameterAndProjectionRo is(true)); assertThat(model.getHeaderRows().stream() .anyMatch(row -> row.field() == HeaderField.SELECTED_RUNTIME_PROJECTION_ID - && selectedProjectionRef.getRuntimeProjectionId().equals(row.value())), + && selectedDescriptor.getRuntimeProjectionId().equals(row.value())), is(true)); assertTrue(model.isNativeEntryDefinitionAvailable()); assertThat(model.getEntryParameters().stream() @@ -93,7 +93,7 @@ public void testSpinOffInspectionIncludesEntryLegPostingParameterAndProjectionRo is(true)); assertThat(model.getDescriptors().stream() .anyMatch(row -> "OLD_SECURITY_LEG".equals(row.projectionRole()) - && selectedProjectionRef.getRuntimeProjectionId() + && selectedDescriptor.getRuntimeProjectionId() .equals(row.runtimeProjectionId()) && "posting-source".equals(row.primaryPostingId())), is(true)); diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblerTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblerTest.java index b3146d071b..54055856a4 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblerTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblerTest.java @@ -471,16 +471,16 @@ public void testBuildAndAddCreatesSpinOffAndMaterializesRuntimeProjections() } /** - * Checks the Ledger-V6 scenario: build and add projection ref uuids are runtime projection uuids. + * Checks the Ledger-V6 scenario: build and add descriptor ids are runtime projection ids. * The result must keep ledger truth and visible runtime rows consistent. * This protects against duplicate truth or partial mutation. */ @Test - public void testBuildAndAddProjectionRefUUIDsAreRuntimeProjectionUUIDs() + public void testBuildAndAddDescriptorIdsAreRuntimeProjectionIds() { var fixture = fixture(); var entry = validSpinOff(fixture).buildAndAdd().getEntry(); - var projectionUUIDs = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream() + var descriptorIds = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream() .map(descriptor -> name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport .runtimeProjectionId(entry, descriptor.getRole())) .collect(Collectors.toSet()); @@ -493,7 +493,7 @@ public void testBuildAndAddProjectionRefUUIDsAreRuntimeProjectionUUIDs() .map(AccountTransaction::getUUID)) .collect(Collectors.toSet()); - assertThat(runtimeUUIDs, is(projectionUUIDs)); + assertThat(runtimeUUIDs, is(descriptorIds)); assertThat(runtimeUUIDs.size(), is(3)); } @@ -593,12 +593,12 @@ public void testGeneratedDetachedEntryPassesStructuralValidatorWhenAddedToScratc } /** - * Checks the Ledger-V6 scenario: generated projection refs target assembler owned postings. + * Checks the Ledger-V6 scenario: generated descriptors target assembler owned postings. * The result must keep ledger truth and visible runtime rows consistent. * This protects against duplicate truth or partial mutation. */ @Test - public void testGeneratedProjectionRefsTargetAssemblerOwnedPostings() + public void testGeneratedDescriptorsTargetAssemblerOwnedPostings() { var fixture = fixture(); var entry = validSpinOff(fixture).buildDetached().getEntry(); diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorServiceTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorServiceTest.java index 00ad9ca053..18c0ac3a24 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorServiceTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorServiceTest.java @@ -44,7 +44,7 @@ /** * Tests runtime-only projection descriptors derived from posting semantics. - * Current projection refs remain the active materialization source in this phase. + * Descriptor derivation is the active materialization source. */ @SuppressWarnings("nls") public class DerivedProjectionDescriptorServiceTest @@ -52,7 +52,7 @@ public class DerivedProjectionDescriptorServiceTest private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 1, 2, 0, 0); @Test - public void testAccountOnlyDescriptorMatchesProjectionRef() + public void testAccountOnlyDescriptorDerivesRuntimeView() { var client = new Client(); var account = account("Cash"); @@ -65,7 +65,7 @@ public void testAccountOnlyDescriptorMatchesProjectionRef() } @Test - public void testBuySellDescriptorsMatchAccountAndPortfolioProjectionRefs() + public void testBuySellDescriptorsDeriveAccountAndPortfolioRuntimeViews() { var client = new Client(); var account = account("Cash"); @@ -98,7 +98,7 @@ public void testBuySellDescriptorsMatchAccountAndPortfolioProjectionRefs() } @Test - public void testDeliveryDescriptorMatchesProjectionRef() + public void testDeliveryDescriptorDerivesRuntimeView() { var client = new Client(); var portfolio = portfolio("Portfolio"); @@ -171,7 +171,7 @@ public void testSecurityTransferDerivesSourceAndTargetWithoutPostingOrder() } @Test - public void testSiemensSpinOffDescriptorsMatchTargetedProjectionRefs() + public void testSiemensSpinOffDescriptorsDeriveTargetedRuntimeViews() { var fixture = fixture(); var entry = spinOffEntry(fixture); diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerRuntimeProjectionRestorerTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerRuntimeProjectionRestorerTest.java index 7381e12d68..33e4ab7b2c 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerRuntimeProjectionRestorerTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerRuntimeProjectionRestorerTest.java @@ -110,7 +110,7 @@ public void testSemanticPrimaryMaterializesAccountProjection() /** * Checks the projection rebuild scenario: semantic group keys drive native targeted units. * Unit projections must be derived from posting semantics. - * This protects Ledger-V6 from relying on projection membership targeting. + * This protects Ledger-V6 from relying on persisted projection membership targeting. */ @Test public void testSemanticGroupKeyMaterializesNativeTargetedUnits() diff --git a/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/LedgerTransactionDuplicateCopyParityTest.java b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/LedgerTransactionDuplicateCopyParityTest.java index 77504b2c81..67db02fd4c 100644 --- a/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/LedgerTransactionDuplicateCopyParityTest.java +++ b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/LedgerTransactionDuplicateCopyParityTest.java @@ -73,7 +73,7 @@ public void testDepositDuplicateCreatesFreshLedgerTruth() throws Exception var duplicate = onlyOther(fixture.account().getTransactions(), original); - assertAccountProjectionDuplicate(original, duplicate, originalSnapshot, 1, 1); + assertAccountProjectionDuplicate(original, duplicate, originalSnapshot, 1); assertThat(fixture.account().getTransactions().size(), is(2)); assertThat(duplicate.getDateTime(), is(DATE_TIME)); assertThat(duplicate.getNote(), is("note")); @@ -104,7 +104,7 @@ public void testDividendDuplicateCreatesFreshLedgerTruth() throws Exception var duplicate = onlyOther(fixture.account().getTransactions(), original); - assertAccountProjectionDuplicate(original, duplicate, originalSnapshot, 1, 1); + assertAccountProjectionDuplicate(original, duplicate, originalSnapshot, 1); assertThat(fixture.account().getTransactions().size(), is(2)); assertThat(duplicate.getDateTime(), is(DATE_TIME)); assertThat(duplicate.getNote(), is("note")); @@ -141,7 +141,7 @@ public void testBuyDuplicateCreatesFreshLedgerTruthAndCrossEntry() throws Except var duplicatePortfolioTransaction = onlyOther(fixture.portfolio().getTransactions(), originalPortfolioTransaction); assertPortfolioProjectionDuplicate(originalPortfolioTransaction, duplicatePortfolioTransaction, originalSnapshot, - 2, 2); + 2); assertThat(fixture.account().getTransactions().size(), is(2)); assertThat(fixture.portfolio().getTransactions().size(), is(2)); assertSame(duplicatePortfolioTransaction, @@ -182,7 +182,7 @@ public void testDeliveryInboundDuplicateCreatesFreshLedgerTruth() throws Excepti var duplicate = onlyOther(fixture.portfolio().getTransactions(), original); - assertPortfolioProjectionDuplicate(original, duplicate, originalSnapshot, 1, 1); + assertPortfolioProjectionDuplicate(original, duplicate, originalSnapshot, 1); assertThat(fixture.portfolio().getTransactions().size(), is(2)); assertThat(duplicate.getType(), is(PortfolioTransaction.Type.DELIVERY_INBOUND)); assertThat(duplicate.getDateTime(), is(DATE_TIME)); @@ -218,7 +218,7 @@ public void testAccountTransferDuplicateCreatesFreshLedgerTruthAndCrossEntry() t var duplicateSourceTransaction = onlyOther(fixture.account().getTransactions(), originalSourceTransaction); var duplicateTargetTransaction = onlyOther(fixture.targetAccount().getTransactions(), originalTargetTransaction); - assertAccountProjectionDuplicate(originalSourceTransaction, duplicateSourceTransaction, originalSnapshot, 2, 2); + assertAccountProjectionDuplicate(originalSourceTransaction, duplicateSourceTransaction, originalSnapshot, 2); assertThat(fixture.account().getTransactions().size(), is(2)); assertThat(fixture.targetAccount().getTransactions().size(), is(2)); assertSame(duplicateTargetTransaction, @@ -262,7 +262,7 @@ public void testPortfolioTransferDuplicateCreatesFreshLedgerTruthAndCrossEntry() var duplicateTargetTransaction = onlyOther(fixture.targetPortfolio().getTransactions(), originalTargetTransaction); - assertPortfolioProjectionDuplicate(originalSourceTransaction, duplicateSourceTransaction, originalSnapshot, 2, 2); + assertPortfolioProjectionDuplicate(originalSourceTransaction, duplicateSourceTransaction, originalSnapshot, 2); assertThat(fixture.portfolio().getTransactions().size(), is(2)); assertThat(fixture.targetPortfolio().getTransactions().size(), is(2)); assertSame(duplicateTargetTransaction, @@ -286,23 +286,23 @@ public void testPortfolioTransferDuplicateCreatesFreshLedgerTruthAndCrossEntry() } private void assertAccountProjectionDuplicate(AccountTransaction original, AccountTransaction duplicate, - EntrySnapshot originalSnapshot, int expectedPostings, int expectedProjectionRefs) + EntrySnapshot originalSnapshot, int expectedPostings) { assertThat(duplicate.getClass().getName().contains("LedgerBacked"), is(true)); assertThat(original.getUUID(), not(duplicate.getUUID())); - assertFreshLedgerIdentity(originalSnapshot, entry(duplicate), expectedPostings, expectedProjectionRefs); + assertFreshLedgerIdentity(originalSnapshot, entry(duplicate), expectedPostings); } private void assertPortfolioProjectionDuplicate(PortfolioTransaction original, PortfolioTransaction duplicate, - EntrySnapshot originalSnapshot, int expectedPostings, int expectedProjectionRefs) + EntrySnapshot originalSnapshot, int expectedPostings) { assertThat(duplicate.getClass().getName().contains("LedgerBacked"), is(true)); assertThat(original.getUUID(), not(duplicate.getUUID())); - assertFreshLedgerIdentity(originalSnapshot, entry(duplicate), expectedPostings, expectedProjectionRefs); + assertFreshLedgerIdentity(originalSnapshot, entry(duplicate), expectedPostings); } private void assertFreshLedgerIdentity(EntrySnapshot originalSnapshot, Object duplicateEntry, - int expectedPostings, int expectedProjectionRefs) + int expectedPostings) { assertThat(uuid(duplicateEntry), not(originalSnapshot.entryUUID())); assertThat(postings(duplicateEntry).size(), is(expectedPostings)); 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 index ee61b1e20b..d7959b7f83 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerPostingDirection.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerPostingDirection.java @@ -3,7 +3,7 @@ /** * Describes the semantic movement direction of a Ledger posting. * It is additive metadata for future projection derivation and does not replace - * projection refs in the current materialization path. + * derived descriptors in the current materialization path. */ public enum LedgerPostingDirection { 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 index a844bd355e..ff297f2d7d 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerStructuralValidator.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerStructuralValidator.java @@ -20,7 +20,7 @@ /** * Validates the structural consistency of Ledger entries. - * This class checks Ledger facts and projection references. It does not apply business + * This class checks Ledger facts and semantic projection descriptors. It does not apply business * repairs or guess missing transaction data. */ public final class LedgerStructuralValidator @@ -30,7 +30,6 @@ public enum IssueCode LEDGER_REQUIRED, DUPLICATE_ENTRY_UUID, DUPLICATE_POSTING_UUID, - DUPLICATE_PROJECTION_REF_UUID, ENTRY_UUID_REQUIRED, ENTRY_TYPE_REQUIRED, ENTRY_DATE_TIME_REQUIRED, @@ -40,20 +39,6 @@ public enum IssueCode POSTING_SECURITY_REQUIRED, POSTING_EXCHANGE_RATE_POSITIVE, DIVIDEND_SECURITY_REQUIRED, - PROJECTION_REF_UUID_REQUIRED, - PROJECTION_REF_ROLE_REQUIRED, - FIXED_SHAPE_PROJECTION_ROLE_REQUIRED, - FIXED_SHAPE_PROJECTION_ROLE_NOT_ALLOWED, - PROJECTION_REF_ACCOUNT_REQUIRED, - PROJECTION_REF_PORTFOLIO_REQUIRED, - PROJECTION_REF_ACCOUNT_NOT_ALLOWED, - PROJECTION_REF_PORTFOLIO_NOT_ALLOWED, - TARGETING_REF_REQUIRED, - PRIMARY_POSTING_REF_NOT_FOUND, - POSTING_GROUP_REF_NOT_FOUND, - PROJECTION_MEMBERSHIP_REF_NOT_FOUND, - PROJECTION_PRIMARY_TARGET_CONFLICT, - PROJECTION_GROUP_TARGET_CONFLICT, PARAMETER_TYPE_REQUIRED, PARAMETER_VALUE_KIND_REQUIRED, PARAMETER_VALUE_REQUIRED, 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 index 426ae69a3d..e5c25cee94 100644 --- 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 @@ -25,7 +25,7 @@ * *

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

*/ public final class LedgerEntryDefinitionRegistry 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 index 99696408c1..a2ce0e0f85 100644 --- 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 @@ -80,7 +80,7 @@ public boolean isLedgerNativeTargeted() return shape == Shape.LEDGER_NATIVE_TARGETED; } - public boolean requiresTargetedProjectionRefs() + public boolean requiresTargetedDerivedDescriptors() { return isLedgerNativeTargeted(); } 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 index fe023e7945..d1ab046952 100644 --- 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 @@ -3,7 +3,7 @@ /** * 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 projection refs, not leg cardinality rules. + * postings and semantic projection facts, not leg cardinality rules. */ public enum LedgerLegCardinality { diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java index 98ce054041..c6060d27e1 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java @@ -267,7 +267,7 @@ else if (!postingMatchesAnyLegWithProjectionRole(entry.getType(), posting, defin && leg.getCardinality() != LedgerLegCardinality.AT_LEAST_ONE) issues.add(issue(IssueCode.AMBIGUOUS_LEG_MATCH, LedgerDiagnosticCode.LEDGER_STRUCT_047 - .message("Native leg maps to multiple projection refs: " + leg.getRole()), //$NON-NLS-1$ + .message("Native leg maps to multiple derived descriptors: " + leg.getRole()), //$NON-NLS-1$ entry) .withDetail("legRole", leg.getRole()) //$NON-NLS-1$ .withDetail("projectionRole", projectionRole)); //$NON-NLS-1$ diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigrator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigrator.java index 25076cc46c..286c647beb 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigrator.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigrator.java @@ -147,11 +147,7 @@ private void migrateAccountOnly(Account account, AccountTransaction transaction, MigrationGraphBuilder.markPrimary(posting, MigrationGraphBuilder.semanticRole(postingType), LedgerPostingDirection.NEUTRAL, LedgerProjectionRole.ACCOUNT); MigrationGraphBuilder.addPosting(entry, posting); - var unitPostings = MigrationGraphBuilder.addUnitPostings(entry, transaction); - var projection = MigrationGraphBuilder.accountProjection(transaction.getUUID(), LedgerProjectionRole.ACCOUNT, - account, posting.getUUID()); - MigrationGraphBuilder.addUnitMemberships(projection, unitPostings); - MigrationGraphBuilder.addProjectionView(entry, projection); + MigrationGraphBuilder.addUnitPostings(entry, transaction); if (plan.handleAlreadyMigratedCompleteGroup("ACCOUNT", entry, transaction)) //$NON-NLS-1$ return; @@ -236,15 +232,7 @@ private void migrateBuySell(BuySellEntry buySellEntry, MigrationPlan plan) LedgerProjectionRole.PORTFOLIO); MigrationGraphBuilder.addPosting(entry, cashPosting); MigrationGraphBuilder.addPosting(entry, securityPosting); - var unitPostings = MigrationGraphBuilder.addUnitPostings(entry, portfolioTransaction); - var accountProjection = MigrationGraphBuilder.accountProjection(accountTransaction.getUUID(), - LedgerProjectionRole.ACCOUNT, account, cashPosting.getUUID()); - var portfolioProjection = MigrationGraphBuilder.portfolioProjection(portfolioTransaction.getUUID(), - LedgerProjectionRole.PORTFOLIO, portfolio, securityPosting.getUUID()); - MigrationGraphBuilder.addUnitMemberships(accountProjection, unitPostings); - MigrationGraphBuilder.addUnitMemberships(portfolioProjection, unitPostings); - MigrationGraphBuilder.addProjectionView(entry, accountProjection); - MigrationGraphBuilder.addProjectionView(entry, portfolioProjection); + MigrationGraphBuilder.addUnitPostings(entry, portfolioTransaction); if (plan.handleAlreadyMigratedCompleteGroup("BUY_SELL", entry, accountTransaction, portfolioTransaction)) //$NON-NLS-1$ return; @@ -330,12 +318,6 @@ private void migrateAccountTransfer(AccountTransferEntry transferEntry, Migratio LedgerPostingDirection.INBOUND, LedgerProjectionRole.TARGET_ACCOUNT); MigrationGraphBuilder.addPosting(entry, sourcePosting); MigrationGraphBuilder.addPosting(entry, targetPosting); - MigrationGraphBuilder.addProjectionView(entry, MigrationGraphBuilder.accountProjection( - sourceTransaction.getUUID(), LedgerProjectionRole.SOURCE_ACCOUNT, sourceAccount, - sourcePosting.getUUID())); - MigrationGraphBuilder.addProjectionView(entry, MigrationGraphBuilder.accountProjection( - targetTransaction.getUUID(), LedgerProjectionRole.TARGET_ACCOUNT, targetAccount, - targetPosting.getUUID())); if (plan.handleAlreadyMigratedCompleteGroup("ACCOUNT_TRANSFER", entry, sourceTransaction, targetTransaction)) //$NON-NLS-1$ return; @@ -399,12 +381,6 @@ private void migratePortfolioTransfer(PortfolioTransferEntry transferEntry, Migr LedgerPostingDirection.INBOUND, LedgerProjectionRole.TARGET_PORTFOLIO); MigrationGraphBuilder.addPosting(entry, sourcePosting); MigrationGraphBuilder.addPosting(entry, targetPosting); - MigrationGraphBuilder.addProjectionView(entry, MigrationGraphBuilder.portfolioProjection( - sourceTransaction.getUUID(), LedgerProjectionRole.SOURCE_PORTFOLIO, sourcePortfolio, - sourcePosting.getUUID())); - MigrationGraphBuilder.addProjectionView(entry, MigrationGraphBuilder.portfolioProjection( - targetTransaction.getUUID(), LedgerProjectionRole.TARGET_PORTFOLIO, targetPortfolio, - targetPosting.getUUID())); if (plan.handleAlreadyMigratedCompleteGroup("PORTFOLIO_TRANSFER", entry, sourceTransaction, targetTransaction)) //$NON-NLS-1$ return; @@ -510,11 +486,7 @@ private void migrateDelivery(Portfolio portfolio, PortfolioTransaction transacti : LedgerPostingDirection.OUTBOUND, role); MigrationGraphBuilder.addPosting(entry, posting); - var unitPostings = MigrationGraphBuilder.addUnitPostings(entry, transaction); - var projection = MigrationGraphBuilder.portfolioProjection(transaction.getUUID(), role, portfolio, - posting.getUUID()); - MigrationGraphBuilder.addUnitMemberships(projection, unitPostings); - MigrationGraphBuilder.addProjectionView(entry, projection); + MigrationGraphBuilder.addUnitPostings(entry, transaction); if (plan.handleAlreadyMigratedCompleteGroup("DELIVERY", entry, transaction)) //$NON-NLS-1$ return; @@ -667,24 +639,6 @@ private static List addUnitPostings(LedgerEntry entry, Transactio return List.copyOf(postings); } - private static void addUnitMemberships(MigrationView projection, List unitPostings) - { - // Unit postings carry semantic unitRole/groupKey data and no longer need - // persisted projection memberships. - } - - private static MigrationView accountProjection(String uuid, LedgerProjectionRole role, Account account, - String primaryPostingId) - { - return new MigrationView(uuid, role, account, null, primaryPostingId); - } - - private static MigrationView portfolioProjection(String uuid, LedgerProjectionRole role, - Portfolio portfolio, String primaryPostingId) - { - return new MigrationView(uuid, role, null, portfolio, primaryPostingId); - } - private static void addEntry(Ledger ledger, LedgerEntry entry) { ledger.addEntry(entry); @@ -700,11 +654,6 @@ private static void addPosting(LedgerEntry entry, LedgerPosting posting) entry.addPosting(posting); } - private static void addProjectionView(LedgerEntry entry, MigrationView projection) - { - // Projection views are derived from semantic postings. - } - private static void addParameter(LedgerPosting posting, LedgerParameter parameter) { posting.addParameter(parameter); @@ -794,11 +743,6 @@ private static String migratedPostingUUID(String projectionUUID, LedgerPostingTy } } - private record MigrationView(String uuid, LedgerProjectionRole role, Account account, Portfolio portfolio, - String primaryPostingId) - { - } - private static final class MigrationPlan { private final Client client; diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssembler.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssembler.java index b08d71364c..435e4ecf1a 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssembler.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssembler.java @@ -42,7 +42,7 @@ *

* The assembler input is not persisted as a separate configuration object. It uses the * static Ledger configuration model to create persisted Ledger entries, postings, - * parameters, and projection refs. + * parameters, and semantic projection facts. *

*/ public final class LedgerNativeEntryAssembler diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeSecurityLeg.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeSecurityLeg.java index f4cd018430..50b5371a12 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeSecurityLeg.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeSecurityLeg.java @@ -19,7 +19,7 @@ * *

* This input object is not persisted directly. The assembler converts it into Ledger - * security postings, controlled leg codes, and projection refs. + * security postings, controlled leg codes, and semantic projection facts. *

*/ public final class NativeSecurityLeg From e6eee141e5ef6c4a2cf6fbdf8ca84b4bc426ccd6 Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Thu, 2 Jul 2026 08:41:48 +0200 Subject: [PATCH 24/68] Finalize Ledger no-UUID release gate Updated the Siemens spin-off example and release-gate tests to assert semantic Ledger truth, descriptor-based runtime views, deterministic legacy plan keys, and stable generated Ledger timestamps. This keeps historic XML/protobuf parity and UI release-gate coverage aligned with the no-UUID Ledger model. XML/protobuf schemas, materialization semantics, validation rules, migration acceptance, and diagnostics behavior were intentionally left unchanged. --- ...LedgerSpinOffXmlDefinitionCheckerTest.java | 19 +++++++++++ ...ger-v6-spin-off-siemens-energy-example.xml | 12 ------- .../AccountTransactionModelTest.java | 34 +++++++++++++++++-- .../transactions/BuySellModelTest.java | 14 +++++--- .../SecurityDeliveryModelTest.java | 7 ++-- .../AccountTransferLegacyActionGuardTest.java | 22 ------------ .../portfolio/model/ClientFactory.java | 17 ++++++++++ .../portfolio/model/InvestmentPlan.java | 30 ++++++++++++++-- .../LegacyTransactionToLedgerMigrator.java | 11 ++++-- 9 files changed, 119 insertions(+), 47 deletions(-) diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffXmlDefinitionCheckerTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffXmlDefinitionCheckerTest.java index 0811cfe919..7fa6bf7429 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffXmlDefinitionCheckerTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffXmlDefinitionCheckerTest.java @@ -1,6 +1,7 @@ package name.abuchen.portfolio.model.ledger; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import java.io.IOException; @@ -42,9 +43,16 @@ public final class LedgerSpinOffXmlDefinitionCheckerTest public void testSiemensEnergyXmlExampleMatchesStaticLedgerDefinitions() throws IOException { var report = check(XML_EXAMPLE); + var ledgerXml = ledgerSection(Files.readString(resolve(XML_EXAMPLE))); System.out.println(report.markdown()); + assertFalse(ledgerXml.contains("")); + assertFalse(ledgerXml.contains("")); + assertFalse(ledgerXml.contains(""); + var end = xml.indexOf(""); + + assertTrue(start >= 0); + assertTrue(end > start); + + return xml.substring(start, end); + } + public static Report check(Path xmlFile) throws IOException { xmlFile = resolve(xmlFile); diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/ledger-v6-spin-off-siemens-energy-example.xml b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/ledger-v6-spin-off-siemens-energy-example.xml index 4491a365ee..04ad66f9d8 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/ledger-v6-spin-off-siemens-energy-example.xml +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/ledger-v6-spin-off-siemens-energy-example.xml @@ -260,7 +260,6 @@ - 20000000-0000-0000-0000-000000000001 SPIN_OFF 2020-09-28T00:00 Siemens Energy spin-off proof @@ -310,7 +309,6 @@
- 20000000-0000-0000-0000-000000000002 SECURITY 109960 EUR @@ -361,7 +359,6 @@
- 20000000-0000-0000-0000-000000000007 SECURITY 109960 EUR @@ -402,7 +399,6 @@ - 20000000-0000-0000-0000-000000000003 SECURITY 10605 EUR @@ -463,7 +459,6 @@ - 20000000-0000-0000-0000-000000000004 CASH_COMPENSATION 500 EUR @@ -513,7 +508,6 @@ - 20000000-0000-0000-0000-000000000005 FEE 200 EUR @@ -538,7 +532,6 @@ - 20000000-0000-0000-0000-000000000006 TAX 100 EUR @@ -580,14 +573,12 @@
- 316212bb-23d1-486f-aaa9-fc2108d44e82 BUY 2020-01-02T00:00 2026-06-15T10:41:34.896212600Z - 29ef4ac0-ce62-4bfb-8bed-5dbabf5a8ca4 CASH 118640 EUR @@ -596,7 +587,6 @@ - 7898a9e0-ff1b-484b-81ff-37ad41d3b611 SECURITY 118640 EUR @@ -608,14 +598,12 @@ - 9f3dfe85-9707-40bf-9153-816cf063d290 DEPOSIT 2019-12-30T00:00 2026-06-15T10:41:50.210577100Z - ed4ed7ce-87dc-4c1c-a3cd-32dc41370bf1 CASH 1000000 EUR diff --git a/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/AccountTransactionModelTest.java b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/AccountTransactionModelTest.java index 699e6035e3..6e6a66a4f2 100644 --- a/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/AccountTransactionModelTest.java +++ b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/AccountTransactionModelTest.java @@ -10,6 +10,7 @@ import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.List; import org.junit.Test; @@ -64,6 +65,7 @@ public void testExistingLedgerBackedAccountOnlyTransactionEditsThroughLedger() createModel.applyChanges(); var transaction = account.getTransactions().get(0); + var transactionUUID = transaction.getUUID(); var editModel = new AccountTransactionModel(client, AccountTransaction.Type.DEPOSIT); editModel.setSource(account, transaction); editModel.setTotal(Values.Amount.factorize(456)); @@ -153,6 +155,7 @@ public void testExistingLedgerBackedDividendEditsThroughLedgerAndMovesOwner() createModel.applyChanges(); var transaction = account.getTransactions().get(0); + var transactionUUID = transaction.getUUID(); var editModel = new AccountTransactionModel(client, AccountTransaction.Type.DIVIDENDS); editModel.setExchangeRateProviderFactory(new ExchangeRateProviderFactory(client)); editModel.setSource(account, transaction); @@ -165,12 +168,13 @@ public void testExistingLedgerBackedDividendEditsThroughLedgerAndMovesOwner() assertThat(ledgerEntryCount(client), is(1)); assertThat(account.getTransactions().size(), is(1)); - assertThat(account.getTransactions().get(0), is(transaction)); + transaction = account.getTransactions().get(0); + assertThat(transaction.getUUID(), is(transactionUUID)); assertThat(transaction.getAmount(), is(Values.Amount.factorize(451))); assertThat(transaction.getShares(), is(Values.Share.factorize(20))); assertThat(transaction.getExDate(), is(LocalDateTime.of(2026, 6, 2, 0, 0))); assertThat(transaction.getNote(), is("updated")); - assertThat(transaction.getUnitSum(Transaction.Unit.Type.TAX).getAmount(), is(Values.Amount.factorize(5))); + assertTrue(hasLedgerPosting(transaction, "TAX", Values.Amount.factorize(5))); assertThat(client.getAllTransactions().size(), is(1)); var moveModel = new AccountTransactionModel(client, AccountTransaction.Type.DIVIDENDS); @@ -181,7 +185,7 @@ public void testExistingLedgerBackedDividendEditsThroughLedgerAndMovesOwner() assertTrue(account.getTransactions().isEmpty()); assertThat(otherAccount.getTransactions().size(), is(1)); - assertThat(otherAccount.getTransactions().get(0).getUUID(), is(transaction.getUUID())); + assertThat(otherAccount.getTransactions().get(0).getUUID(), is(transactionUUID)); assertThat(otherAccount.getTransactions().get(0).getAmount(), is(Values.Amount.factorize(451))); assertThat(client.getAllTransactions().size(), is(1)); } @@ -199,4 +203,28 @@ private int ledgerEntryCount(Client client) throws ReflectiveOperationException throw new AssertionError(e.getCause()); } } + + private boolean hasLedgerPosting(Transaction transaction, String type, long amount) throws ReflectiveOperationException + { + try + { + var entry = transaction.getClass().getMethod("getLedgerEntry").invoke(transaction); + var postings = (List) entry.getClass().getMethod("getPostings").invoke(entry); + + for (var posting : postings) + { + var postingType = posting.getClass().getMethod("getType").invoke(posting); + var postingAmount = (Long) posting.getClass().getMethod("getAmount").invoke(posting); + + if (type.equals(String.valueOf(postingType)) && postingAmount.longValue() == amount) + return true; + } + + return false; + } + catch (InvocationTargetException e) + { + throw new AssertionError(e.getCause()); + } + } } diff --git a/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/BuySellModelTest.java b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/BuySellModelTest.java index 7f320fdb37..6a909c8fcd 100644 --- a/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/BuySellModelTest.java +++ b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/BuySellModelTest.java @@ -207,6 +207,8 @@ public void testExistingLedgerBackedBuySellEditsThroughLedgerAndMovesOwner() Values.Share.factorize(5), java.util.List.of(), "note", "source"); var accountTransaction = entry.getAccountTransaction(); var portfolioTransaction = entry.getPortfolioTransaction(); + var accountTransactionUUID = accountTransaction.getUUID(); + var portfolioTransactionUUID = portfolioTransaction.getUUID(); var editModel = editModel(fixture, PortfolioTransaction.Type.BUY); editModel.setSource(entry); @@ -215,8 +217,12 @@ public void testExistingLedgerBackedBuySellEditsThroughLedgerAndMovesOwner() editModel.setNote("updated"); editModel.applyChanges(); - assertThat(fixture.account().getTransactions(), is(java.util.List.of(accountTransaction))); - assertThat(fixture.portfolio().getTransactions(), is(java.util.List.of(portfolioTransaction))); + assertThat(fixture.account().getTransactions().size(), is(1)); + assertThat(fixture.portfolio().getTransactions().size(), is(1)); + accountTransaction = fixture.account().getTransactions().get(0); + portfolioTransaction = fixture.portfolio().getTransactions().get(0); + assertThat(accountTransaction.getUUID(), is(accountTransactionUUID)); + assertThat(portfolioTransaction.getUUID(), is(portfolioTransactionUUID)); assertThat(portfolioTransaction.getShares(), is(Values.Share.factorize(7))); assertThat(portfolioTransaction.getAmount(), is(Values.Amount.factorize(140))); assertThat(portfolioTransaction.getNote(), is("updated")); @@ -230,10 +236,10 @@ public void testExistingLedgerBackedBuySellEditsThroughLedgerAndMovesOwner() moveModel.applyChanges(); assertThat(fixture.account().getTransactions().size(), is(1)); - assertThat(fixture.account().getTransactions().get(0).getUUID(), is(accountTransaction.getUUID())); + assertThat(fixture.account().getTransactions().get(0).getUUID(), is(accountTransactionUUID)); assertTrue(fixture.portfolio().getTransactions().isEmpty()); assertThat(otherPortfolio.getTransactions().size(), is(1)); - assertThat(otherPortfolio.getTransactions().get(0).getUUID(), is(portfolioTransaction.getUUID())); + assertThat(otherPortfolio.getTransactions().get(0).getUUID(), is(portfolioTransactionUUID)); assertSame(otherPortfolio.getTransactions().get(0), fixture.account().getTransactions().get(0).getCrossEntry() .getCrossTransaction(fixture.account().getTransactions().get(0))); diff --git a/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/SecurityDeliveryModelTest.java b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/SecurityDeliveryModelTest.java index 81dc536f26..c1c3fcc4a2 100644 --- a/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/SecurityDeliveryModelTest.java +++ b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/SecurityDeliveryModelTest.java @@ -81,6 +81,7 @@ public void testExistingLedgerBackedDeliveryEditsThroughLedgerAndMovesOwner() applyDeliveryValues(createModel); createModel.applyChanges(); var transaction = fixture.portfolio().getTransactions().get(0); + var transactionUUID = transaction.getUUID(); var editModel = model(fixture, PortfolioTransaction.Type.DELIVERY_INBOUND); editModel.setSource(new TransactionPair<>(fixture.portfolio(), transaction)); @@ -91,7 +92,9 @@ public void testExistingLedgerBackedDeliveryEditsThroughLedgerAndMovesOwner() editModel.applyChanges(); assertThat(ledgerEntryCount(fixture.client()), is(1)); - assertThat(fixture.portfolio().getTransactions(), is(java.util.List.of(transaction))); + assertThat(fixture.portfolio().getTransactions().size(), is(1)); + transaction = fixture.portfolio().getTransactions().get(0); + assertThat(transaction.getUUID(), is(transactionUUID)); assertThat(transaction.getAmount(), is(Values.Amount.factorize(330))); assertThat(transaction.getShares(), is(Values.Share.factorize(20))); assertThat(transaction.getNote(), is("updated")); @@ -109,7 +112,7 @@ public void testExistingLedgerBackedDeliveryEditsThroughLedgerAndMovesOwner() assertTrue(fixture.portfolio().getTransactions().isEmpty()); assertThat(otherPortfolio.getTransactions().size(), is(1)); - assertThat(otherPortfolio.getTransactions().get(0).getUUID(), is(transaction.getUUID())); + assertThat(otherPortfolio.getTransactions().get(0).getUUID(), is(transactionUUID)); assertThat(otherPortfolio.getTransactions().get(0).getShares(), is(Values.Share.factorize(20))); assertThat(fixture.client().getAllTransactions().size(), is(1)); } diff --git a/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/views/actions/AccountTransferLegacyActionGuardTest.java b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/views/actions/AccountTransferLegacyActionGuardTest.java index 92e2bb7132..c964044c29 100644 --- a/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/views/actions/AccountTransferLegacyActionGuardTest.java +++ b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/views/actions/AccountTransferLegacyActionGuardTest.java @@ -56,8 +56,6 @@ public void testConvertTransferToDepositRemovalSplitsLedgerBackedTransferThrough var fixture = ledgerFixture(); var sourceTransaction = fixture.source().getTransactions().get(0); var targetTransaction = fixture.target().getTransactions().get(0); - var sourceUUID = sourceTransaction.getUUID(); - var targetUUID = targetTransaction.getUUID(); new ConvertTransferToDepositRemovalAction(fixture.client(), List.of(sourceTransaction)).run(); @@ -66,8 +64,6 @@ public void testConvertTransferToDepositRemovalSplitsLedgerBackedTransferThrough assertThat(fixture.target().getTransactions().size(), is(1)); assertThat(fixture.source().getTransactions().get(0).getType(), is(AccountTransaction.Type.REMOVAL)); assertThat(fixture.target().getTransactions().get(0).getType(), is(AccountTransaction.Type.DEPOSIT)); - assertThat(fixture.source().getTransactions().get(0).getUUID(), is(sourceUUID)); - assertThat(fixture.target().getTransactions().get(0).getUUID(), is(targetUUID)); assertThat(fixture.source().getTransactions().get(0).getCrossEntry(), is(nullValue())); assertThat(fixture.target().getTransactions().get(0).getCrossEntry(), is(nullValue())); } @@ -149,16 +145,12 @@ public void testRevertTransferActionReversesLedgerBackedTransferThroughLedgerTru var fixture = ledgerFixture(); var sourceTransaction = fixture.source().getTransactions().get(0); var targetTransaction = fixture.target().getTransactions().get(0); - var sourceUUID = sourceTransaction.getUUID(); - var targetUUID = targetTransaction.getUUID(); new RevertTransferAction(fixture.client(), new TransactionPair<>(fixture.source(), sourceTransaction)).run(); assertThat(ledgerEntryCount(fixture.client()), is(1)); assertThat(fixture.source().getTransactions().size(), is(1)); assertThat(fixture.target().getTransactions().size(), is(1)); - assertThat(fixture.source().getTransactions().get(0).getUUID(), is(sourceUUID)); - assertThat(fixture.target().getTransactions().get(0).getUUID(), is(targetUUID)); assertThat(fixture.source().getTransactions().get(0).getType(), is(AccountTransaction.Type.TRANSFER_IN)); assertThat(fixture.target().getTransactions().get(0).getType(), is(AccountTransaction.Type.TRANSFER_OUT)); assertSame(fixture.source().getTransactions().get(0), fixture.target().getTransactions().get(0) @@ -235,16 +227,12 @@ public void testRevertTransferActionReversesLedgerBackedPortfolioTransferThrough var fixture = ledgerPortfolioFixture(); var sourceTransaction = fixture.source().getTransactions().get(0); var targetTransaction = fixture.target().getTransactions().get(0); - var sourceUUID = sourceTransaction.getUUID(); - var targetUUID = targetTransaction.getUUID(); new RevertTransferAction(fixture.client(), new TransactionPair<>(fixture.source(), sourceTransaction)).run(); assertThat(ledgerEntryCount(fixture.client()), is(1)); assertThat(fixture.source().getTransactions().size(), is(1)); assertThat(fixture.target().getTransactions().size(), is(1)); - assertThat(fixture.source().getTransactions().get(0).getUUID(), is(sourceUUID)); - assertThat(fixture.target().getTransactions().get(0).getUUID(), is(targetUUID)); assertThat(fixture.source().getTransactions().get(0).getType(), is(PortfolioTransaction.Type.TRANSFER_IN)); assertThat(fixture.target().getTransactions().get(0).getType(), is(PortfolioTransaction.Type.TRANSFER_OUT)); assertSame(fixture.source().getTransactions().get(0), fixture.target().getTransactions().get(0) @@ -347,7 +335,6 @@ public void testConvertBuySellToDeliveryConvertsLedgerBackedBuySellThroughLedger { var fixture = ledgerBuySellFixture(PortfolioTransaction.Type.SELL); var portfolioTransaction = fixture.portfolio().getTransactions().get(0); - var portfolioProjectionUUID = portfolioTransaction.getUUID(); new ConvertBuySellToDeliveryAction(fixture.client(), new TransactionPair<>(fixture.portfolio(), portfolioTransaction)).run(); @@ -355,7 +342,6 @@ public void testConvertBuySellToDeliveryConvertsLedgerBackedBuySellThroughLedger assertThat(ledgerEntryCount(fixture.client()), is(1)); assertThat(fixture.account().getTransactions().isEmpty(), is(true)); assertThat(fixture.portfolio().getTransactions().size(), is(1)); - assertThat(fixture.portfolio().getTransactions().get(0).getUUID(), is(portfolioProjectionUUID)); assertThat(fixture.portfolio().getTransactions().get(0).getType(), is(PortfolioTransaction.Type.DELIVERY_OUTBOUND)); assertThat(fixture.portfolio().getTransactions().get(0).getCrossEntry(), is(nullValue())); @@ -392,7 +378,6 @@ public void testConvertDeliveryToBuySellConvertsLedgerBackedDeliveryThroughLedge { var fixture = ledgerDeliveryFixture(); var delivery = fixture.portfolio().getTransactions().get(0); - var portfolioProjectionUUID = delivery.getUUID(); new ConvertDeliveryToBuySellAction(fixture.client(), new TransactionPair<>(fixture.portfolio(), delivery)) .run(); @@ -400,7 +385,6 @@ public void testConvertDeliveryToBuySellConvertsLedgerBackedDeliveryThroughLedge assertThat(ledgerEntryCount(fixture.client()), is(1)); assertThat(fixture.account().getTransactions().size(), is(1)); assertThat(fixture.portfolio().getTransactions().size(), is(1)); - assertThat(fixture.portfolio().getTransactions().get(0).getUUID(), is(portfolioProjectionUUID)); assertThat(fixture.portfolio().getTransactions().get(0).getType(), is(PortfolioTransaction.Type.BUY)); assertThat(fixture.account().getTransactions().get(0).getType(), is(AccountTransaction.Type.BUY)); assertSame(fixture.portfolio().getTransactions().get(0), fixture.account().getTransactions().get(0) @@ -418,13 +402,11 @@ public void testRevertDeliveryActionReversesLedgerBackedDeliveryThroughLedgerTru { var fixture = ledgerDeliveryFixture(); var delivery = fixture.portfolio().getTransactions().get(0); - var projectionUUID = delivery.getUUID(); new RevertDeliveryAction(fixture.client(), new TransactionPair<>(fixture.portfolio(), delivery)).run(); assertThat(ledgerEntryCount(fixture.client()), is(1)); assertThat(fixture.portfolio().getTransactions().size(), is(1)); - assertThat(fixture.portfolio().getTransactions().get(0).getUUID(), is(projectionUUID)); assertThat(fixture.portfolio().getTransactions().get(0).getType(), is(PortfolioTransaction.Type.DELIVERY_OUTBOUND)); assertThat(fixture.portfolio().getTransactions().get(0).getCrossEntry(), is(nullValue())); @@ -886,13 +868,11 @@ private void assertInlineBuySellToDelivery(PortfolioTransaction.Type from, Portf { var fixture = ledgerBuySellFixture(from); var portfolioTransaction = fixture.portfolio().getTransactions().get(0); - var portfolioUUID = portfolioTransaction.getUUID(); setTypeValue(new TransactionTypeEditingSupport(fixture.client()), portfolioTransaction, from, to); assertThat(fixture.account().getTransactions().isEmpty(), is(true)); assertThat(fixture.portfolio().getTransactions().size(), is(1)); - assertThat(fixture.portfolio().getTransactions().get(0).getUUID(), is(portfolioUUID)); assertThat(fixture.portfolio().getTransactions().get(0).getType(), is(to)); assertThat(fixture.portfolio().getTransactions().get(0).getCrossEntry(), is(nullValue())); } @@ -902,13 +882,11 @@ private void assertInlineDeliveryToBuySell(PortfolioTransaction.Type from, Portf { var fixture = ledgerDeliveryFixture(from); var delivery = fixture.portfolio().getTransactions().get(0); - var portfolioUUID = delivery.getUUID(); setTypeValue(new TransactionTypeEditingSupport(fixture.client()), delivery, from, to); assertThat(fixture.account().getTransactions().size(), is(1)); assertThat(fixture.portfolio().getTransactions().size(), is(1)); - assertThat(fixture.portfolio().getTransactions().get(0).getUUID(), is(portfolioUUID)); assertThat(fixture.portfolio().getTransactions().get(0).getType(), is(to)); assertThat(fixture.account().getTransactions().get(0).getType(), is(accountType)); assertSame(fixture.portfolio().getTransactions().get(0), fixture.account().getTransactions().get(0) diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ClientFactory.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ClientFactory.java index 16f5faf2e6..5783178e25 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ClientFactory.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ClientFactory.java @@ -1591,6 +1591,23 @@ else if (flags.contains(SaveFlag.COMPRESSED)) default: break; } + + assignMissingInvestmentPlanKeys(client); + } + + private static void assignMissingInvestmentPlanKeys(Client client) + { + var index = 0; + + for (var plan : client.getPlans()) + { + index++; + + if (plan.hasPlanKey()) + continue; + + plan.assignPlanKeyIfMissing("plan-legacy-" + index); //$NON-NLS-1$ + } } private static void fixAssetClassTypes(Client client) diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/InvestmentPlan.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/InvestmentPlan.java index ffac5fd436..87fa64e90f 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/InvestmentPlan.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/InvestmentPlan.java @@ -4,6 +4,7 @@ import java.text.MessageFormat; import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.ZoneOffset; import java.util.ArrayList; import java.util.Comparator; import java.util.List; @@ -88,7 +89,7 @@ public enum Type private String planKey; private List transactions = new ArrayList<>(); - private List ledgerExecutionRefs = new ArrayList<>(); + private List ledgerExecutionRefs; public InvestmentPlan() { @@ -256,6 +257,17 @@ public void setPlanKey(String planKey) this.planKey = planKey; } + boolean hasPlanKey() + { + return planKey != null && !planKey.isBlank(); + } + + void assignPlanKeyIfMissing(String planKey) + { + if (!hasPlanKey()) + this.planKey = planKey; + } + public List getTransactions() { return this.transactions; @@ -633,6 +645,7 @@ public List> generateTransactions(CurrencyConverter converter while (!transactionDate.isAfter(now)) { TransactionPair transaction = createTransaction(converter, transactionDate); + transaction.getTransaction().setUpdatedAt(planExecutionUpdatedAt(transactionDate)); transactions.add(transaction.getTransaction()); newlyCreated.add(transaction); @@ -674,7 +687,9 @@ public List> generateTransactions(Client client, CurrencyConv while (!transactionDate.isAfter(now)) { TransactionPair transaction = createLedgerTransaction(client, converter, transactionDate); - markLedgerExecution((LedgerBackedTransaction) transaction.getTransaction(), transactionDate); + var ledgerBackedTransaction = (LedgerBackedTransaction) transaction.getTransaction(); + ledgerBackedTransaction.getLedgerEntry().setUpdatedAt(planExecutionUpdatedAt(transactionDate)); + markLedgerExecution(ledgerBackedTransaction, transactionDate); newlyCreated.add(transaction); transactionDate = next(transactionDate); @@ -691,18 +706,29 @@ void markLedgerExecution(LedgerBackedTransaction transaction) private void markLedgerExecution(LedgerBackedTransaction transaction, LocalDate executionDate) { var entry = transaction.getLedgerEntry(); + var updatedAt = entry.getUpdatedAt(); + entry.setGeneratedByPlanKey(getPlanKey()); entry.setPlanExecutionDate(executionDate); entry.setPlanExecutionSequence(null); entry.setPreferredViewKind(viewKind(transaction).name()); + entry.setUpdatedAt(updatedAt); } void markLedgerExecution(LedgerEntry entry, LocalDate executionDate, LedgerExecutionViewKind preferredViewKind) { + var updatedAt = entry.getUpdatedAt(); + entry.setGeneratedByPlanKey(getPlanKey()); entry.setPlanExecutionDate(executionDate); entry.setPlanExecutionSequence(null); entry.setPreferredViewKind(preferredViewKind != null ? preferredViewKind.name() : null); + entry.setUpdatedAt(updatedAt); + } + + private java.time.Instant planExecutionUpdatedAt(LocalDate executionDate) + { + return executionDate.atStartOfDay().toInstant(ZoneOffset.UTC); } private LedgerExecutionViewKind viewKind(LedgerBackedTransaction transaction) diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigrator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigrator.java index 286c647beb..05347c658a 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigrator.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigrator.java @@ -2,6 +2,7 @@ import java.math.BigDecimal; import java.nio.charset.StandardCharsets; +import java.time.ZoneOffset; import java.util.ArrayList; import java.util.HashSet; import java.util.IdentityHashMap; @@ -548,6 +549,7 @@ private void convertInvestmentPlanTransactionsToLedgerRefs(Client client, Migrat entry.setPreferredViewKind(transaction instanceof PortfolioTransaction ? InvestmentPlan.LedgerExecutionViewKind.PORTFOLIO.name() : InvestmentPlan.LedgerExecutionViewKind.ACCOUNT.name()); + MigrationGraphBuilder.setUpdatedAt(entry, transaction); investmentPlan.getTransactions().remove(index); } } @@ -586,7 +588,7 @@ private static LedgerEntry entry(Transaction transaction, LedgerEntryType type) entry.setDateTime(transaction.getDateTime()); entry.setNote(transaction.getNote()); entry.setSource(transaction.getSource()); - entry.setUpdatedAt(transaction.getUpdatedAt()); + entry.setUpdatedAt(migratedUpdatedAt(transaction)); return entry; } @@ -646,7 +648,12 @@ private static void addEntry(Ledger ledger, LedgerEntry entry) private static void setUpdatedAt(LedgerEntry entry, Transaction transaction) { - entry.setUpdatedAt(transaction.getUpdatedAt()); + entry.setUpdatedAt(migratedUpdatedAt(transaction)); + } + + private static java.time.Instant migratedUpdatedAt(Transaction transaction) + { + return transaction.getDateTime().toInstant(ZoneOffset.UTC); } private static void addPosting(LedgerEntry entry, LedgerPosting posting) From 7e9dbd95f87c9660f90180de2264a66a3895a380 Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Thu, 2 Jul 2026 08:56:33 +0200 Subject: [PATCH 25/68] Tighten Ledger no-UUID release checks Removed obsolete UUID-era validator issue constants and stale message keys, renamed the native component inspector label to derived descriptors, and scoped XML no-UUID assertions to the Ledger truth subtree. This keeps the completed no-UUID model easier to audit and prevents released legacy UUID fields outside Ledger from being mistaken for Ledger truth. XML/protobuf schemas, Corporate Action persistence, fixed legacy PTransaction types, validation behavior, and migration behavior were intentionally left unchanged. --- .../ledger/LedgerXmlPersistenceTest.java | 34 +++++++++++++------ .../name/abuchen/portfolio/ui/Messages.java | 2 +- .../LedgerNativeComponentInspectorDialog.java | 2 +- .../abuchen/portfolio/ui/messages.properties | 2 +- .../portfolio/ui/messages_ca.properties | 2 +- .../portfolio/ui/messages_cs.properties | 2 +- .../portfolio/ui/messages_da.properties | 2 +- .../portfolio/ui/messages_de.properties | 2 +- .../portfolio/ui/messages_es.properties | 2 +- .../portfolio/ui/messages_fi.properties | 2 +- .../portfolio/ui/messages_fr.properties | 2 +- .../portfolio/ui/messages_it.properties | 2 +- .../portfolio/ui/messages_nl.properties | 2 +- .../portfolio/ui/messages_pl.properties | 2 +- .../portfolio/ui/messages_pt.properties | 2 +- .../portfolio/ui/messages_pt_BR.properties | 2 +- .../portfolio/ui/messages_ru.properties | 2 +- .../portfolio/ui/messages_sk.properties | 2 +- .../portfolio/ui/messages_tr.properties | 2 +- .../portfolio/ui/messages_vi.properties | 2 +- .../portfolio/ui/messages_zh.properties | 2 +- .../portfolio/ui/messages_zh_TW.properties | 2 +- .../src/name/abuchen/portfolio/Messages.java | 3 -- .../abuchen/portfolio/messages.properties | 3 -- .../abuchen/portfolio/messages_ca.properties | 3 -- .../abuchen/portfolio/messages_cs.properties | 3 -- .../abuchen/portfolio/messages_da.properties | 3 -- .../abuchen/portfolio/messages_de.properties | 3 -- .../abuchen/portfolio/messages_es.properties | 3 -- .../abuchen/portfolio/messages_fi.properties | 3 -- .../abuchen/portfolio/messages_fr.properties | 3 -- .../abuchen/portfolio/messages_it.properties | 3 -- .../abuchen/portfolio/messages_nl.properties | 3 -- .../abuchen/portfolio/messages_pl.properties | 3 -- .../abuchen/portfolio/messages_pt.properties | 3 -- .../portfolio/messages_pt_BR.properties | 3 -- .../abuchen/portfolio/messages_ru.properties | 3 -- .../abuchen/portfolio/messages_sk.properties | 3 -- .../abuchen/portfolio/messages_tr.properties | 3 -- .../abuchen/portfolio/messages_vi.properties | 3 -- .../abuchen/portfolio/messages_zh.properties | 3 -- .../portfolio/messages_zh_TW.properties | 3 -- .../ledger/LedgerStructuralValidator.java | 4 --- 43 files changed, 45 insertions(+), 95 deletions(-) diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerXmlPersistenceTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerXmlPersistenceTest.java index a3502c7b67..f7340df64c 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerXmlPersistenceTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerXmlPersistenceTest.java @@ -228,6 +228,7 @@ public void testLedgerXmlRoundtripPreservesTruthAndDoesNotPersistRuntimeProjecti var xml = save(client); assertTrue(xml.contains("")); + assertTrue(xml.contains(account.getUUID())); assertFalse(xml.contains("]*\\buuid=.*")); - assertFalse(xml.matches("(?s).*]*\\buuid=.*")); - assertFalse(xml.contains("")); - assertFalse(xml.contains("]*\\buuid=.*")); + assertFalse(ledgerXml.matches("(?s).*]*\\buuid=.*")); + assertFalse(ledgerXml.contains("")); + assertFalse(ledgerXml.contains(""); + var end = xml.indexOf(""); + + assertTrue(start >= 0); + assertTrue(end > start); + + return xml.substring(start, end); } private Account register(Client client, Account account) diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/Messages.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/Messages.java index dd3ecfd0d7..9c258f7d75 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/Messages.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/Messages.java @@ -1022,7 +1022,7 @@ public class Messages extends NLS public static String LedgerNativeComponentInspectorPostingUUID; public static String LedgerNativeComponentInspectorPrimaryExpected; public static String LedgerNativeComponentInspectorPrimaryPostingUUID; - public static String LedgerNativeComponentInspectorProjectionRefs; + public static String LedgerNativeComponentInspectorDerivedDescriptors; public static String LedgerNativeComponentInspectorProjectionRole; public static String LedgerNativeComponentInspectorProjectionUUID; public static String LedgerNativeComponentInspectorSelectedProjectionUUID; diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/LedgerNativeComponentInspectorDialog.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/LedgerNativeComponentInspectorDialog.java index 6e292cf3a6..166d4e3ea2 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/LedgerNativeComponentInspectorDialog.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/LedgerNativeComponentInspectorDialog.java @@ -114,7 +114,7 @@ protected Control createDialogArea(Composite parent) row -> new String[] { row.postingUUID(), row.postingType(), row.parameter(), row.code(), row.valueKind(), row.value(), row.domain() }); - createTable(container, Messages.LedgerNativeComponentInspectorProjectionRefs, model.getDescriptors(), + createTable(container, Messages.LedgerNativeComponentInspectorDerivedDescriptors, model.getDescriptors(), new String[] { Messages.LedgerNativeComponentInspectorProjectionRole, Messages.LedgerNativeComponentInspectorOwner, Messages.LedgerNativeComponentInspectorProjectionUUID, diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages.properties index 665571f0d5..b0c2926eb7 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages.properties @@ -2036,7 +2036,7 @@ LedgerNativeComponentInspectorPrimaryExpected = Primary expected LedgerNativeComponentInspectorPrimaryPostingUUID = Primary posting UUID -LedgerNativeComponentInspectorProjectionRefs = Projection references +LedgerNativeComponentInspectorDerivedDescriptors = Derived descriptors LedgerNativeComponentInspectorProjectionRole = Projection role diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_ca.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_ca.properties index 45a0d35581..ce44bdd21d 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_ca.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_ca.properties @@ -2025,7 +2025,7 @@ LedgerNativeComponentInspectorPrimaryExpected = Refer\u00E8ncia principal espera LedgerNativeComponentInspectorPrimaryPostingUUID = UUID de l\u2019assentament principal -LedgerNativeComponentInspectorProjectionRefs = Refer\u00E8ncies de projecci\u00F3 +LedgerNativeComponentInspectorDerivedDescriptors = Refer\u00E8ncies de projecci\u00F3 LedgerNativeComponentInspectorProjectionRole = Rol de projecci\u00F3 diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_cs.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_cs.properties index bf6800a113..18e150b13d 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_cs.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_cs.properties @@ -2028,7 +2028,7 @@ LedgerNativeComponentInspectorPrimaryExpected = O\u010Dek\u00E1v\u00E1n prim\u00 LedgerNativeComponentInspectorPrimaryPostingUUID = UUID prim\u00E1rn\u00ED polo\u017Eky -LedgerNativeComponentInspectorProjectionRefs = Odkazy projekc\u00ED +LedgerNativeComponentInspectorDerivedDescriptors = Odkazy projekc\u00ED LedgerNativeComponentInspectorProjectionRole = Role projekce diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_da.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_da.properties index 7223d47e3d..7025c35e29 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_da.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_da.properties @@ -2025,7 +2025,7 @@ LedgerNativeComponentInspectorPrimaryExpected = Prim\u00E6r reference forventet LedgerNativeComponentInspectorPrimaryPostingUUID = Prim\u00E6r posting-UUID -LedgerNativeComponentInspectorProjectionRefs = Projektionsreferencer +LedgerNativeComponentInspectorDerivedDescriptors = Projektionsreferencer LedgerNativeComponentInspectorProjectionRole = Projektionsrolle diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_de.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_de.properties index 7c1e9bd5cb..b0c6bd40ab 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_de.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_de.properties @@ -2029,7 +2029,7 @@ LedgerNativeComponentInspectorPrimaryExpected = Prim\u00E4rer Bezug erwartet LedgerNativeComponentInspectorPrimaryPostingUUID = Prim\u00E4re Buchungs-UUID -LedgerNativeComponentInspectorProjectionRefs = Projektionsreferenzen +LedgerNativeComponentInspectorDerivedDescriptors = Projektionsreferenzen LedgerNativeComponentInspectorProjectionRole = Projektionsrolle diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_es.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_es.properties index 5fddfa55c6..d4d69816d8 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_es.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_es.properties @@ -2025,7 +2025,7 @@ LedgerNativeComponentInspectorPrimaryExpected = Referencia principal esperada LedgerNativeComponentInspectorPrimaryPostingUUID = UUID del asiento principal -LedgerNativeComponentInspectorProjectionRefs = Referencias de proyecci\u00F3n +LedgerNativeComponentInspectorDerivedDescriptors = Referencias de proyecci\u00F3n LedgerNativeComponentInspectorProjectionRole = Rol de proyecci\u00F3n diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_fi.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_fi.properties index 3cc732f359..a97b9fb426 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_fi.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_fi.properties @@ -2025,7 +2025,7 @@ LedgerNativeComponentInspectorPrimaryExpected = Ensisijainen viite odotetaan LedgerNativeComponentInspectorPrimaryPostingUUID = Ensisijaisen kirjausrivin UUID -LedgerNativeComponentInspectorProjectionRefs = Projektioviitteet +LedgerNativeComponentInspectorDerivedDescriptors = Projektioviitteet LedgerNativeComponentInspectorProjectionRole = Projektion rooli diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_fr.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_fr.properties index 1b793b140a..1299ea96fb 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_fr.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_fr.properties @@ -2026,7 +2026,7 @@ LedgerNativeComponentInspectorPrimaryExpected = R\u00E9f\u00E9rence principale a LedgerNativeComponentInspectorPrimaryPostingUUID = UUID du posting principal -LedgerNativeComponentInspectorProjectionRefs = R\u00E9f\u00E9rences de projection +LedgerNativeComponentInspectorDerivedDescriptors = R\u00E9f\u00E9rences de projection LedgerNativeComponentInspectorProjectionRole = R\u00F4le de projection diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_it.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_it.properties index a907ec50aa..0c309de3da 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_it.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_it.properties @@ -2025,7 +2025,7 @@ LedgerNativeComponentInspectorPrimaryExpected = Riferimento principale previsto LedgerNativeComponentInspectorPrimaryPostingUUID = UUID della registrazione principale -LedgerNativeComponentInspectorProjectionRefs = Riferimenti di proiezione +LedgerNativeComponentInspectorDerivedDescriptors = Riferimenti di proiezione LedgerNativeComponentInspectorProjectionRole = Ruolo di proiezione diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_nl.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_nl.properties index 138d0c76ee..d7a656a8a5 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_nl.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_nl.properties @@ -2025,7 +2025,7 @@ LedgerNativeComponentInspectorPrimaryExpected = Primaire verwijzing verwacht LedgerNativeComponentInspectorPrimaryPostingUUID = Primaire posting-UUID -LedgerNativeComponentInspectorProjectionRefs = Projectieverwijzingen +LedgerNativeComponentInspectorDerivedDescriptors = Projectieverwijzingen LedgerNativeComponentInspectorProjectionRole = Projectierol diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_pl.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_pl.properties index f5da8cb7c9..7a77c5747a 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_pl.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_pl.properties @@ -2025,7 +2025,7 @@ LedgerNativeComponentInspectorPrimaryExpected = Oczekiwane odniesienie g\u0142\u LedgerNativeComponentInspectorPrimaryPostingUUID = UUID g\u0142\u00F3wnego ksi\u0119gowania -LedgerNativeComponentInspectorProjectionRefs = Referencje projekcji +LedgerNativeComponentInspectorDerivedDescriptors = Referencje projekcji LedgerNativeComponentInspectorProjectionRole = Rola projekcji diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_pt.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_pt.properties index 3f29ebd6f7..12ce52c2b6 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_pt.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_pt.properties @@ -2023,7 +2023,7 @@ LedgerNativeComponentInspectorPrimaryExpected = Refer\u00EAncia principal espera LedgerNativeComponentInspectorPrimaryPostingUUID = UUID do lan\u00E7amento principal -LedgerNativeComponentInspectorProjectionRefs = Refer\u00EAncias de proje\u00E7\u00E3o +LedgerNativeComponentInspectorDerivedDescriptors = Refer\u00EAncias de proje\u00E7\u00E3o LedgerNativeComponentInspectorProjectionRole = Fun\u00E7\u00E3o da proje\u00E7\u00E3o diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_pt_BR.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_pt_BR.properties index 0bc8db261f..f6eb24f555 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_pt_BR.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_pt_BR.properties @@ -2023,7 +2023,7 @@ LedgerNativeComponentInspectorPrimaryExpected = Refer\u00EAncia principal espera LedgerNativeComponentInspectorPrimaryPostingUUID = UUID do lan\u00E7amento principal -LedgerNativeComponentInspectorProjectionRefs = Refer\u00EAncias de proje\u00E7\u00E3o +LedgerNativeComponentInspectorDerivedDescriptors = Refer\u00EAncias de proje\u00E7\u00E3o LedgerNativeComponentInspectorProjectionRole = Fun\u00E7\u00E3o da proje\u00E7\u00E3o diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_ru.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_ru.properties index 08aa8dbf0a..924d09532b 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_ru.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_ru.properties @@ -2021,7 +2021,7 @@ LedgerNativeComponentInspectorPrimaryExpected = \u041E\u0436\u0438\u0434\u0430\u LedgerNativeComponentInspectorPrimaryPostingUUID = UUID \u043E\u0441\u043D\u043E\u0432\u043D\u043E\u0439 \u043F\u0440\u043E\u0432\u043E\u0434\u043A\u0438 -LedgerNativeComponentInspectorProjectionRefs = \u0421\u0441\u044B\u043B\u043A\u0438 \u043F\u0440\u043E\u0435\u043A\u0446\u0438\u0439 +LedgerNativeComponentInspectorDerivedDescriptors = \u0421\u0441\u044B\u043B\u043A\u0438 \u043F\u0440\u043E\u0435\u043A\u0446\u0438\u0439 LedgerNativeComponentInspectorProjectionRole = \u0420\u043E\u043B\u044C \u043F\u0440\u043E\u0435\u043A\u0446\u0438\u0438 diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_sk.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_sk.properties index ff53a2fef7..b6aaf4a63f 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_sk.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_sk.properties @@ -2025,7 +2025,7 @@ LedgerNativeComponentInspectorPrimaryExpected = O\u010Dak\u00E1va sa prim\u00E1r LedgerNativeComponentInspectorPrimaryPostingUUID = UUID prim\u00E1rnej polo\u017Eky -LedgerNativeComponentInspectorProjectionRefs = Odkazy projekci\u00ED +LedgerNativeComponentInspectorDerivedDescriptors = Odkazy projekci\u00ED LedgerNativeComponentInspectorProjectionRole = Rola projekcie diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_tr.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_tr.properties index 73e1f4c30e..0baed84513 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_tr.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_tr.properties @@ -2025,7 +2025,7 @@ LedgerNativeComponentInspectorPrimaryExpected = Birincil referans bekleniyor LedgerNativeComponentInspectorPrimaryPostingUUID = Birincil kay\u0131t UUID -LedgerNativeComponentInspectorProjectionRefs = Projeksiyon referanslar\u0131 +LedgerNativeComponentInspectorDerivedDescriptors = Projeksiyon referanslar\u0131 LedgerNativeComponentInspectorProjectionRole = Projeksiyon rol\u00FC diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_vi.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_vi.properties index 60a11daa2e..6f20b35366 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_vi.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_vi.properties @@ -2025,7 +2025,7 @@ LedgerNativeComponentInspectorPrimaryExpected = C\u1EA7n tham chi\u1EBFu ch\u00E LedgerNativeComponentInspectorPrimaryPostingUUID = UUID b\u00FAt to\u00E1n ch\u00EDnh -LedgerNativeComponentInspectorProjectionRefs = Tham chi\u1EBFu ph\u00E9p chi\u1EBFu +LedgerNativeComponentInspectorDerivedDescriptors = Tham chi\u1EBFu ph\u00E9p chi\u1EBFu LedgerNativeComponentInspectorProjectionRole = Vai tr\u00F2 ph\u00E9p chi\u1EBFu diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_zh.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_zh.properties index 5a65981948..b7604ce8e1 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_zh.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_zh.properties @@ -2025,7 +2025,7 @@ LedgerNativeComponentInspectorPrimaryExpected = \u9884\u671F\u4E3B\u5F15\u7528 LedgerNativeComponentInspectorPrimaryPostingUUID = \u4E3B\u8FC7\u8D26 UUID -LedgerNativeComponentInspectorProjectionRefs = \u6295\u5F71\u5F15\u7528 +LedgerNativeComponentInspectorDerivedDescriptors = \u6295\u5F71\u5F15\u7528 LedgerNativeComponentInspectorProjectionRole = \u6295\u5F71\u89D2\u8272 diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_zh_TW.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_zh_TW.properties index 186b9a7787..452df8be75 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_zh_TW.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_zh_TW.properties @@ -2025,7 +2025,7 @@ LedgerNativeComponentInspectorPrimaryExpected = \u9884\u671F\u4E3B\u53C3\u7167 LedgerNativeComponentInspectorPrimaryPostingUUID = \u4E3B\u904E\u5E33 UUID -LedgerNativeComponentInspectorProjectionRefs = \u6295\u5F71\u53C3\u7167 +LedgerNativeComponentInspectorDerivedDescriptors = \u6295\u5F71\u53C3\u7167 LedgerNativeComponentInspectorProjectionRole = \u6295\u5F71\u89D2\u8272 diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/Messages.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/Messages.java index 0a3c2d1edb..fd328bf79a 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/Messages.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/Messages.java @@ -284,7 +284,6 @@ public class Messages extends NLS public static String LedgerStructuralValidatorDuplicateProjectionUuid; public static String LedgerStructuralValidatorEntryDateTimeRequired; public static String LedgerStructuralValidatorEntryTypeRequired; - public static String LedgerStructuralValidatorEntryUuidRequired; public static String LedgerStructuralValidatorExDateSecurityRequired; public static String LedgerStructuralValidatorLedgerRequired; public static String LedgerStructuralValidatorParameterCodeNotAllowed; @@ -299,11 +298,9 @@ public class Messages extends NLS public static String LedgerStructuralValidatorPostingGroupRefNotFound; public static String LedgerStructuralValidatorPostingSecurityRequired; public static String LedgerStructuralValidatorPostingTypeRequired; - public static String LedgerStructuralValidatorPostingUuidRequired; public static String LedgerStructuralValidatorPrimaryPostingRefNotFound; public static String LedgerStructuralValidatorProjectionAccountRequired; public static String LedgerStructuralValidatorProjectionGroupTargetConflict; - public static String LedgerStructuralValidatorProjectionMembershipRefNotFound; public static String LedgerStructuralValidatorProjectionPortfolioRequired; public static String LedgerStructuralValidatorProjectionPrimaryTargetConflict; public static String LedgerStructuralValidatorProjectionRoleNotAllowed; diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages.properties index b548b45d6b..8598ac4ea4 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages.properties @@ -556,7 +556,6 @@ LedgerStructuralValidatorEntryDateTimeRequired = Ledger entry {0} must have a da LedgerStructuralValidatorEntryTypeRequired = Ledger entry {0} must have an entry type. -LedgerStructuralValidatorEntryUuidRequired = Every Ledger entry must have a UUID. LedgerStructuralValidatorExDateSecurityRequired = EX_DATE can only be used on postings that reference a security: {0} @@ -586,7 +585,6 @@ LedgerStructuralValidatorPostingSecurityRequired = Security posting {0} must ref LedgerStructuralValidatorPostingTypeRequired = Ledger posting {0} must have a posting type. -LedgerStructuralValidatorPostingUuidRequired = Ledger entry {0} contains a posting without a UUID. LedgerStructuralValidatorPrimaryPostingRefNotFound = Primary posting reference {0} must point to a posting in the same entry. @@ -594,7 +592,6 @@ LedgerStructuralValidatorProjectionAccountRequired = Ledger projection {0} must LedgerStructuralValidatorProjectionGroupTargetConflict = A projection must use either membership references or a single posting-group reference, not both. -LedgerStructuralValidatorProjectionMembershipRefNotFound = Projection membership reference {0} must point to a posting in the same entry. LedgerStructuralValidatorProjectionPortfolioRequired = Ledger projection {0} must reference a portfolio. diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_ca.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_ca.properties index 8605da2ecc..f276aac973 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_ca.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_ca.properties @@ -555,7 +555,6 @@ LedgerStructuralValidatorEntryDateTimeRequired = L\u2019entrada Ledger {0} ha de LedgerStructuralValidatorEntryTypeRequired = L\u2019entrada Ledger {0} ha de tenir un tipus d\u2019entrada. -LedgerStructuralValidatorEntryUuidRequired = Cada entrada Ledger ha de tenir un UUID. LedgerStructuralValidatorExDateSecurityRequired = EX_DATE nom\u00E9s es pot usar en assentaments que referencien un actiu: {0} @@ -585,7 +584,6 @@ LedgerStructuralValidatorPostingSecurityRequired = L\u2019assentament d\u2019act LedgerStructuralValidatorPostingTypeRequired = L\u2019assentament Ledger {0} ha de tenir un tipus d\u2019assentament. -LedgerStructuralValidatorPostingUuidRequired = L\u2019entrada Ledger {0} cont\u00E9 un assentament sense UUID. LedgerStructuralValidatorPrimaryPostingRefNotFound = La refer\u00E8ncia d\u2019assentament principal {0} ha d\u2019apuntar a un assentament de la mateixa entrada. @@ -593,7 +591,6 @@ LedgerStructuralValidatorProjectionAccountRequired = La projecci\u00F3 Ledger {0 LedgerStructuralValidatorProjectionGroupTargetConflict = Una projecci\u00F3 ha d\u2019usar refer\u00E8ncies de pertinen\u00E7a o una sola refer\u00E8ncia de grup d\u2019assentaments, per\u00F2 no totes dues. -LedgerStructuralValidatorProjectionMembershipRefNotFound = La refer\u00E8ncia de pertinen\u00E7a de projecci\u00F3 {0} ha d\u2019apuntar a un assentament de la mateixa entrada. LedgerStructuralValidatorProjectionPortfolioRequired = La projecci\u00F3 Ledger {0} ha de referenciar un compte de valors. diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_cs.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_cs.properties index f0457ea896..29be4f44b5 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_cs.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_cs.properties @@ -554,7 +554,6 @@ LedgerStructuralValidatorEntryDateTimeRequired = Z\u00E1znam Ledger {0} mus\u00E LedgerStructuralValidatorEntryTypeRequired = Z\u00E1znam Ledger {0} mus\u00ED m\u00EDt typ z\u00E1znamu. -LedgerStructuralValidatorEntryUuidRequired = Ka\u017Ed\u00FD z\u00E1znam Ledger mus\u00ED m\u00EDt UUID. LedgerStructuralValidatorExDateSecurityRequired = EX_DATE lze pou\u017E\u00EDt pouze u polo\u017Eek, kter\u00E9 odkazuj\u00ED na cenn\u00FD pap\u00EDr: {0} @@ -584,7 +583,6 @@ LedgerStructuralValidatorPostingSecurityRequired = Polo\u017Eka cenn\u00E9ho pap LedgerStructuralValidatorPostingTypeRequired = Polo\u017Eka Ledger {0} mus\u00ED m\u00EDt typ polo\u017Eky. -LedgerStructuralValidatorPostingUuidRequired = Z\u00E1znam Ledger {0} obsahuje polo\u017Eku bez UUID. LedgerStructuralValidatorPrimaryPostingRefNotFound = Odkaz na prim\u00E1rn\u00ED polo\u017Eku {0} mus\u00ED ukazovat na polo\u017Eku ve stejn\u00E9m z\u00E1znamu. @@ -592,7 +590,6 @@ LedgerStructuralValidatorProjectionAccountRequired = Projekce Ledger {0} mus\u00 LedgerStructuralValidatorProjectionGroupTargetConflict = Projekce mus\u00ED pou\u017E\u00EDt bu\u010F \u010Dlensk\u00E9 odkazy, nebo jeden odkaz na skupinu polo\u017Eek, ne oboj\u00ED. -LedgerStructuralValidatorProjectionMembershipRefNotFound = \u010Clensk\u00FD odkaz projekce {0} mus\u00ED ukazovat na polo\u017Eku ve stejn\u00E9m z\u00E1znamu. LedgerStructuralValidatorProjectionPortfolioRequired = Projekce Ledger {0} mus\u00ED odkazovat na \u00FA\u010Det cenn\u00FDch pap\u00EDr\u016F. diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_da.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_da.properties index c7758f4ea5..b18d9cb043 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_da.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_da.properties @@ -556,7 +556,6 @@ LedgerStructuralValidatorEntryDateTimeRequired = Ledger-posten {0} skal have dat LedgerStructuralValidatorEntryTypeRequired = Ledger-posten {0} skal have en posttype. -LedgerStructuralValidatorEntryUuidRequired = Hver Ledger-post skal have en UUID. LedgerStructuralValidatorExDateSecurityRequired = EX_DATE kan kun bruges p\u00E5 postinger, der refererer til et v\u00E6rdipapir: {0} @@ -586,7 +585,6 @@ LedgerStructuralValidatorPostingSecurityRequired = V\u00E6rdipapirpostingen {0} LedgerStructuralValidatorPostingTypeRequired = Ledger-postingen {0} skal have en postingtype. -LedgerStructuralValidatorPostingUuidRequired = Ledger-posten {0} indeholder en posting uden UUID. LedgerStructuralValidatorPrimaryPostingRefNotFound = Referencen til prim\u00E6r posting {0} skal pege p\u00E5 en posting i samme post. @@ -594,7 +592,6 @@ LedgerStructuralValidatorProjectionAccountRequired = Ledger-projektionen {0} ska LedgerStructuralValidatorProjectionGroupTargetConflict = En projektion skal bruge enten medlemskabsreferencer eller \u00E9n postinggruppereference, ikke begge. -LedgerStructuralValidatorProjectionMembershipRefNotFound = Projektionsmedlemskabsreferencen {0} skal pege p\u00E5 en posting i samme post. LedgerStructuralValidatorProjectionPortfolioRequired = Ledger-projektionen {0} skal referere til en v\u00E6rdipapirkonto. diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_de.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_de.properties index f979af82b7..4a4712d89e 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_de.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_de.properties @@ -556,7 +556,6 @@ LedgerStructuralValidatorEntryDateTimeRequired = Der Ledger-Eintrag {0} muss Dat LedgerStructuralValidatorEntryTypeRequired = Der Ledger-Eintrag {0} muss einen Eintragstyp haben. -LedgerStructuralValidatorEntryUuidRequired = Jeder Ledger-Eintrag muss eine UUID haben. LedgerStructuralValidatorExDateSecurityRequired = EX_DATE kann nur f\u00FCr Buchungen verwendet werden, die auf ein Wertpapier verweisen: {0} @@ -586,7 +585,6 @@ LedgerStructuralValidatorPostingSecurityRequired = Die Wertpapierbuchung {0} mus LedgerStructuralValidatorPostingTypeRequired = Die Ledger-Buchung {0} muss einen Buchungstyp haben. -LedgerStructuralValidatorPostingUuidRequired = Der Ledger-Eintrag {0} enth\u00E4lt eine Buchung ohne UUID. LedgerStructuralValidatorPrimaryPostingRefNotFound = Die prim\u00E4re Buchungsreferenz {0} muss auf eine Buchung im selben Eintrag zeigen. @@ -594,7 +592,6 @@ LedgerStructuralValidatorProjectionAccountRequired = Die Ledger-Projektion {0} m LedgerStructuralValidatorProjectionGroupTargetConflict = Eine Projektion muss entweder Mitgliedschaftsreferenzen oder eine einzelne Buchungsgruppenreferenz verwenden, nicht beides. -LedgerStructuralValidatorProjectionMembershipRefNotFound = Die Projektions-Mitgliedschaftsreferenz {0} muss auf eine Buchung im selben Eintrag zeigen. LedgerStructuralValidatorProjectionPortfolioRequired = Die Ledger-Projektion {0} muss auf ein Depot verweisen. diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_es.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_es.properties index 64e533dc65..eaa4b6fa52 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_es.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_es.properties @@ -554,7 +554,6 @@ LedgerStructuralValidatorEntryDateTimeRequired = La entrada Ledger {0} debe tene LedgerStructuralValidatorEntryTypeRequired = La entrada Ledger {0} debe tener un tipo de entrada. -LedgerStructuralValidatorEntryUuidRequired = Cada entrada Ledger debe tener un UUID. LedgerStructuralValidatorExDateSecurityRequired = EX_DATE solo se puede usar en asientos que referencian un activo: {0} @@ -584,7 +583,6 @@ LedgerStructuralValidatorPostingSecurityRequired = El asiento de activo {0} debe LedgerStructuralValidatorPostingTypeRequired = El asiento Ledger {0} debe tener un tipo de asiento. -LedgerStructuralValidatorPostingUuidRequired = La entrada Ledger {0} contiene un asiento sin UUID. LedgerStructuralValidatorPrimaryPostingRefNotFound = La referencia de asiento principal {0} debe apuntar a un asiento de la misma entrada. @@ -592,7 +590,6 @@ LedgerStructuralValidatorProjectionAccountRequired = La proyecci\u00F3n Ledger { LedgerStructuralValidatorProjectionGroupTargetConflict = Una proyecci\u00F3n debe usar referencias de pertenencia o una sola referencia de grupo de asientos, pero no ambas. -LedgerStructuralValidatorProjectionMembershipRefNotFound = La referencia de pertenencia de proyecci\u00F3n {0} debe apuntar a un asiento de la misma entrada. LedgerStructuralValidatorProjectionPortfolioRequired = La proyecci\u00F3n Ledger {0} debe referenciar una cuenta de valores. diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_fi.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_fi.properties index d0c58f1b83..9f9fddd8ff 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_fi.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_fi.properties @@ -555,7 +555,6 @@ LedgerStructuralValidatorEntryDateTimeRequired = Ledger-kirjauksella {0} on olta LedgerStructuralValidatorEntryTypeRequired = Ledger-kirjauksella {0} on oltava kirjaustyyppi. -LedgerStructuralValidatorEntryUuidRequired = Jokaisella Ledger-kirjauksella on oltava UUID. LedgerStructuralValidatorExDateSecurityRequired = EX_DATE voidaan k\u00E4ytt\u00E4\u00E4 vain kirjausriveill\u00E4, jotka viittaavat arvopaperiin: {0} @@ -585,7 +584,6 @@ LedgerStructuralValidatorPostingSecurityRequired = Arvopaperikirjausrivin {0} on LedgerStructuralValidatorPostingTypeRequired = Ledger-kirjausrivill\u00E4 {0} on oltava kirjausrivin tyyppi. -LedgerStructuralValidatorPostingUuidRequired = Ledger-kirjaus {0} sis\u00E4lt\u00E4\u00E4 kirjausrivin ilman UUID:t\u00E4. LedgerStructuralValidatorPrimaryPostingRefNotFound = Ensisijaisen kirjausrivin viitteen {0} on osoitettava saman kirjauksen kirjausriviin. @@ -593,7 +591,6 @@ LedgerStructuralValidatorProjectionAccountRequired = Ledger-projektion {0} on vi LedgerStructuralValidatorProjectionGroupTargetConflict = Projektion on k\u00E4ytett\u00E4v\u00E4 joko j\u00E4senyysviitteit\u00E4 tai yht\u00E4 kirjausryhm\u00E4viitett\u00E4, ei molempia. -LedgerStructuralValidatorProjectionMembershipRefNotFound = Projektion j\u00E4senyysviitteen {0} on osoitettava saman kirjauksen kirjausriviin. LedgerStructuralValidatorProjectionPortfolioRequired = Ledger-projektion {0} on viitattava s\u00E4ilytystiliin. diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_fr.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_fr.properties index 7b186c811d..92c8640640 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_fr.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_fr.properties @@ -556,7 +556,6 @@ LedgerStructuralValidatorEntryDateTimeRequired = L\u2019\u00E9criture Ledger {0} LedgerStructuralValidatorEntryTypeRequired = L\u2019\u00E9criture Ledger {0} doit avoir un type d\u2019\u00E9criture. -LedgerStructuralValidatorEntryUuidRequired = Chaque \u00E9criture Ledger doit avoir un UUID. LedgerStructuralValidatorExDateSecurityRequired = EX_DATE ne peut \u00EAtre utilis\u00E9 que sur des postings qui r\u00E9f\u00E9rencent un titre : {0} @@ -586,7 +585,6 @@ LedgerStructuralValidatorPostingSecurityRequired = Le posting de titre {0} doit LedgerStructuralValidatorPostingTypeRequired = Le posting Ledger {0} doit avoir un type de posting. -LedgerStructuralValidatorPostingUuidRequired = L\u2019\u00E9criture Ledger {0} contient un posting sans UUID. LedgerStructuralValidatorPrimaryPostingRefNotFound = La r\u00E9f\u00E9rence de posting principal {0} doit pointer vers un posting de la m\u00EAme \u00E9criture. @@ -594,7 +592,6 @@ LedgerStructuralValidatorProjectionAccountRequired = La projection Ledger {0} do LedgerStructuralValidatorProjectionGroupTargetConflict = Une projection doit utiliser soit des r\u00E9f\u00E9rences d\u2019appartenance, soit une seule r\u00E9f\u00E9rence de groupe de postings, mais pas les deux. -LedgerStructuralValidatorProjectionMembershipRefNotFound = La r\u00E9f\u00E9rence d\u2019appartenance de projection {0} doit pointer vers un posting de la m\u00EAme \u00E9criture. LedgerStructuralValidatorProjectionPortfolioRequired = La projection Ledger {0} doit r\u00E9f\u00E9rencer un compte titres. diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_it.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_it.properties index 289acec6ca..e32393c378 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_it.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_it.properties @@ -556,7 +556,6 @@ LedgerStructuralValidatorEntryDateTimeRequired = La voce Ledger {0} deve avere d LedgerStructuralValidatorEntryTypeRequired = La voce Ledger {0} deve avere un tipo di voce. -LedgerStructuralValidatorEntryUuidRequired = Ogni voce Ledger deve avere un UUID. LedgerStructuralValidatorExDateSecurityRequired = EX_DATE pu\u00F2 essere usato solo su registrazioni che riferiscono un titolo: {0} @@ -586,7 +585,6 @@ LedgerStructuralValidatorPostingSecurityRequired = La registrazione titolo {0} d LedgerStructuralValidatorPostingTypeRequired = La registrazione Ledger {0} deve avere un tipo di registrazione. -LedgerStructuralValidatorPostingUuidRequired = La voce Ledger {0} contiene una registrazione senza UUID. LedgerStructuralValidatorPrimaryPostingRefNotFound = Il riferimento alla registrazione principale {0} deve puntare a una registrazione nella stessa voce. @@ -594,7 +592,6 @@ LedgerStructuralValidatorProjectionAccountRequired = La proiezione Ledger {0} de LedgerStructuralValidatorProjectionGroupTargetConflict = Una proiezione deve usare riferimenti di appartenenza oppure un solo riferimento al gruppo di registrazioni, non entrambi. -LedgerStructuralValidatorProjectionMembershipRefNotFound = Il riferimento di appartenenza della proiezione {0} deve puntare a una registrazione nella stessa voce. LedgerStructuralValidatorProjectionPortfolioRequired = La proiezione Ledger {0} deve riferirsi a un conto titoli. diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_nl.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_nl.properties index 6266fc8370..faeaf41717 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_nl.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_nl.properties @@ -556,7 +556,6 @@ LedgerStructuralValidatorEntryDateTimeRequired = Ledger-boeking {0} moet een dat LedgerStructuralValidatorEntryTypeRequired = Ledger-boeking {0} moet een boekingstype hebben. -LedgerStructuralValidatorEntryUuidRequired = Elke Ledger-boeking moet een UUID hebben. LedgerStructuralValidatorExDateSecurityRequired = EX_DATE kan alleen worden gebruikt op postings die naar een effect verwijzen: {0} @@ -586,7 +585,6 @@ LedgerStructuralValidatorPostingSecurityRequired = Effectposting {0} moet naar e LedgerStructuralValidatorPostingTypeRequired = Ledger-posting {0} moet een postingtype hebben. -LedgerStructuralValidatorPostingUuidRequired = Ledger-boeking {0} bevat een posting zonder UUID. LedgerStructuralValidatorPrimaryPostingRefNotFound = Primaire postingverwijzing {0} moet naar een posting in dezelfde boeking wijzen. @@ -594,7 +592,6 @@ LedgerStructuralValidatorProjectionAccountRequired = Ledger-projectie {0} moet n LedgerStructuralValidatorProjectionGroupTargetConflict = Een projectie moet lidmaatschapsverwijzingen of \u00E9\u00E9n postinggroepverwijzing gebruiken, niet beide. -LedgerStructuralValidatorProjectionMembershipRefNotFound = Projectielidmaatschapsverwijzing {0} moet naar een posting in dezelfde boeking wijzen. LedgerStructuralValidatorProjectionPortfolioRequired = Ledger-projectie {0} moet naar een effectenrekening verwijzen. diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_pl.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_pl.properties index f2f9d9f208..66fa212708 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_pl.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_pl.properties @@ -554,7 +554,6 @@ LedgerStructuralValidatorEntryDateTimeRequired = Wpis Ledger {0} musi mie\u0107 LedgerStructuralValidatorEntryTypeRequired = Wpis Ledger {0} musi mie\u0107 typ wpisu. -LedgerStructuralValidatorEntryUuidRequired = Ka\u017Cdy wpis Ledger musi mie\u0107 UUID. LedgerStructuralValidatorExDateSecurityRequired = EX_DATE mo\u017Cna u\u017Cywa\u0107 tylko w ksi\u0119gowaniach odwo\u0142uj\u0105cych si\u0119 do waloru: {0} @@ -584,7 +583,6 @@ LedgerStructuralValidatorPostingSecurityRequired = Ksi\u0119gowanie waloru {0} m LedgerStructuralValidatorPostingTypeRequired = Ksi\u0119gowanie Ledger {0} musi mie\u0107 typ ksi\u0119gowania. -LedgerStructuralValidatorPostingUuidRequired = Wpis Ledger {0} zawiera ksi\u0119gowanie bez UUID. LedgerStructuralValidatorPrimaryPostingRefNotFound = Odniesienie do g\u0142\u00F3wnego ksi\u0119gowania {0} musi wskazywa\u0107 ksi\u0119gowanie w tym samym wpisie. @@ -592,7 +590,6 @@ LedgerStructuralValidatorProjectionAccountRequired = Projekcja Ledger {0} musi o LedgerStructuralValidatorProjectionGroupTargetConflict = Projekcja musi u\u017Cywa\u0107 albo odniesie\u0144 cz\u0142onkostwa, albo jednego odniesienia do grupy ksi\u0119gowa\u0144, nie obu. -LedgerStructuralValidatorProjectionMembershipRefNotFound = Odniesienie cz\u0142onkostwa projekcji {0} musi wskazywa\u0107 ksi\u0119gowanie w tym samym wpisie. LedgerStructuralValidatorProjectionPortfolioRequired = Projekcja Ledger {0} musi odwo\u0142ywa\u0107 si\u0119 do konta walor\u00F3w. diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_pt.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_pt.properties index 0aad87f357..ed2dae6c9e 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_pt.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_pt.properties @@ -540,7 +540,6 @@ LedgerStructuralValidatorEntryDateTimeRequired = A entrada Ledger {0} deve ter d LedgerStructuralValidatorEntryTypeRequired = A entrada Ledger {0} deve ter um tipo de entrada. -LedgerStructuralValidatorEntryUuidRequired = Cada entrada Ledger deve ter um UUID. LedgerStructuralValidatorExDateSecurityRequired = EX_DATE s\u00F3 pode ser usado em lan\u00E7amentos que referenciam um ativo: {0} @@ -570,7 +569,6 @@ LedgerStructuralValidatorPostingSecurityRequired = O lan\u00E7amento de ativo {0 LedgerStructuralValidatorPostingTypeRequired = O lan\u00E7amento Ledger {0} deve ter um tipo de lan\u00E7amento. -LedgerStructuralValidatorPostingUuidRequired = A entrada Ledger {0} cont\u00E9m um lan\u00E7amento sem UUID. LedgerStructuralValidatorPrimaryPostingRefNotFound = A refer\u00EAncia de lan\u00E7amento principal {0} deve apontar para um lan\u00E7amento na mesma entrada. @@ -578,7 +576,6 @@ LedgerStructuralValidatorProjectionAccountRequired = A proje\u00E7\u00E3o Ledger LedgerStructuralValidatorProjectionGroupTargetConflict = Uma proje\u00E7\u00E3o deve usar refer\u00EAncias de perten\u00E7a ou uma \u00FAnica refer\u00EAncia de grupo de lan\u00E7amentos, mas n\u00E3o ambas. -LedgerStructuralValidatorProjectionMembershipRefNotFound = A refer\u00EAncia de perten\u00E7a da proje\u00E7\u00E3o {0} deve apontar para um lan\u00E7amento na mesma entrada. LedgerStructuralValidatorProjectionPortfolioRequired = A proje\u00E7\u00E3o Ledger {0} deve referenciar uma conta de t\u00EDtulos. diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_pt_BR.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_pt_BR.properties index dc76560eec..8fbab2d15e 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_pt_BR.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_pt_BR.properties @@ -554,7 +554,6 @@ LedgerStructuralValidatorEntryDateTimeRequired = A entrada Ledger {0} deve ter d LedgerStructuralValidatorEntryTypeRequired = A entrada Ledger {0} deve ter um tipo de entrada. -LedgerStructuralValidatorEntryUuidRequired = Cada entrada Ledger deve ter um UUID. LedgerStructuralValidatorExDateSecurityRequired = EX_DATE s\u00F3 pode ser usado em lan\u00E7amentos que referenciam um ativo: {0} @@ -584,7 +583,6 @@ LedgerStructuralValidatorPostingSecurityRequired = O lan\u00E7amento de ativo {0 LedgerStructuralValidatorPostingTypeRequired = O lan\u00E7amento Ledger {0} deve ter um tipo de lan\u00E7amento. -LedgerStructuralValidatorPostingUuidRequired = A entrada Ledger {0} cont\u00E9m um lan\u00E7amento sem UUID. LedgerStructuralValidatorPrimaryPostingRefNotFound = A refer\u00EAncia de lan\u00E7amento principal {0} deve apontar para um lan\u00E7amento na mesma entrada. @@ -592,7 +590,6 @@ LedgerStructuralValidatorProjectionAccountRequired = A proje\u00E7\u00E3o Ledger LedgerStructuralValidatorProjectionGroupTargetConflict = Uma proje\u00E7\u00E3o deve usar refer\u00EAncias de perten\u00E7a ou uma \u00FAnica refer\u00EAncia de grupo de lan\u00E7amentos, mas n\u00E3o ambas. -LedgerStructuralValidatorProjectionMembershipRefNotFound = A refer\u00EAncia de perten\u00E7a da proje\u00E7\u00E3o {0} deve apontar para um lan\u00E7amento na mesma entrada. LedgerStructuralValidatorProjectionPortfolioRequired = A proje\u00E7\u00E3o Ledger {0} deve referenciar uma conta de ativos. diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_ru.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_ru.properties index 0e67f28de9..354fd7aabe 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_ru.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_ru.properties @@ -554,7 +554,6 @@ LedgerStructuralValidatorEntryDateTimeRequired = \u0417\u0430\u043F\u0438\u0441\ LedgerStructuralValidatorEntryTypeRequired = \u0417\u0430\u043F\u0438\u0441\u044C Ledger {0} \u0434\u043E\u043B\u0436\u043D\u0430 \u0438\u043C\u0435\u0442\u044C \u0442\u0438\u043F \u0437\u0430\u043F\u0438\u0441\u0438. -LedgerStructuralValidatorEntryUuidRequired = \u041A\u0430\u0436\u0434\u0430\u044F \u0437\u0430\u043F\u0438\u0441\u044C Ledger \u0434\u043E\u043B\u0436\u043D\u0430 \u0438\u043C\u0435\u0442\u044C UUID. LedgerStructuralValidatorExDateSecurityRequired = EX_DATE \u043C\u043E\u0436\u043D\u043E \u0438\u0441\u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u044C \u0442\u043E\u043B\u044C\u043A\u043E \u0432 \u043F\u0440\u043E\u0432\u043E\u0434\u043A\u0430\u0445, \u043A\u043E\u0442\u043E\u0440\u044B\u0435 \u0441\u0441\u044B\u043B\u0430\u044E\u0442\u0441\u044F \u043D\u0430 \u0446\u0435\u043D\u043D\u0443\u044E \u0431\u0443\u043C\u0430\u0433\u0443: {0} @@ -584,7 +583,6 @@ LedgerStructuralValidatorPostingSecurityRequired = \u041F\u0440\u043E\u0432\u043 LedgerStructuralValidatorPostingTypeRequired = \u041F\u0440\u043E\u0432\u043E\u0434\u043A\u0430 Ledger {0} \u0434\u043E\u043B\u0436\u043D\u0430 \u0438\u043C\u0435\u0442\u044C \u0442\u0438\u043F \u043F\u0440\u043E\u0432\u043E\u0434\u043A\u0438. -LedgerStructuralValidatorPostingUuidRequired = \u0417\u0430\u043F\u0438\u0441\u044C Ledger {0} \u0441\u043E\u0434\u0435\u0440\u0436\u0438\u0442 \u043F\u0440\u043E\u0432\u043E\u0434\u043A\u0443 \u0431\u0435\u0437 UUID. LedgerStructuralValidatorPrimaryPostingRefNotFound = \u0421\u0441\u044B\u043B\u043A\u0430 \u043D\u0430 \u043E\u0441\u043D\u043E\u0432\u043D\u0443\u044E \u043F\u0440\u043E\u0432\u043E\u0434\u043A\u0443 {0} \u0434\u043E\u043B\u0436\u043D\u0430 \u0443\u043A\u0430\u0437\u044B\u0432\u0430\u0442\u044C \u043D\u0430 \u043F\u0440\u043E\u0432\u043E\u0434\u043A\u0443 \u0432 \u0442\u043E\u0439 \u0436\u0435 \u0437\u0430\u043F\u0438\u0441\u0438. @@ -592,7 +590,6 @@ LedgerStructuralValidatorProjectionAccountRequired = \u041F\u0440\u043E\u0435\u0 LedgerStructuralValidatorProjectionGroupTargetConflict = \u041F\u0440\u043E\u0435\u043A\u0446\u0438\u044F \u0434\u043E\u043B\u0436\u043D\u0430 \u0438\u0441\u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u044C \u043B\u0438\u0431\u043E \u0441\u0441\u044B\u043B\u043A\u0438 \u0447\u043B\u0435\u043D\u0441\u0442\u0432\u0430, \u043B\u0438\u0431\u043E \u043E\u0434\u043D\u0443 \u0441\u0441\u044B\u043B\u043A\u0443 \u043D\u0430 \u0433\u0440\u0443\u043F\u043F\u0443 \u043F\u0440\u043E\u0432\u043E\u0434\u043E\u043A, \u043D\u043E \u043D\u0435 \u043E\u0431\u0430 \u0432\u0430\u0440\u0438\u0430\u043D\u0442\u0430. -LedgerStructuralValidatorProjectionMembershipRefNotFound = \u0421\u0441\u044B\u043B\u043A\u0430 \u0447\u043B\u0435\u043D\u0441\u0442\u0432\u0430 \u043F\u0440\u043E\u0435\u043A\u0446\u0438\u0438 {0} \u0434\u043E\u043B\u0436\u043D\u0430 \u0443\u043A\u0430\u0437\u044B\u0432\u0430\u0442\u044C \u043D\u0430 \u043F\u0440\u043E\u0432\u043E\u0434\u043A\u0443 \u0432 \u0442\u043E\u0439 \u0436\u0435 \u0437\u0430\u043F\u0438\u0441\u0438. LedgerStructuralValidatorProjectionPortfolioRequired = \u041F\u0440\u043E\u0435\u043A\u0446\u0438\u044F Ledger {0} \u0434\u043E\u043B\u0436\u043D\u0430 \u0441\u0441\u044B\u043B\u0430\u0442\u044C\u0441\u044F \u043D\u0430 \u0441\u0447\u0435\u0442 \u0446\u0435\u043D\u043D\u044B\u0445 \u0431\u0443\u043C\u0430\u0433. diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_sk.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_sk.properties index 40e9c38ea4..001b14abf1 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_sk.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_sk.properties @@ -556,7 +556,6 @@ LedgerStructuralValidatorEntryDateTimeRequired = Z\u00E1znam Ledger {0} mus\u00E LedgerStructuralValidatorEntryTypeRequired = Z\u00E1znam Ledger {0} mus\u00ED ma\u0165 typ z\u00E1znamu. -LedgerStructuralValidatorEntryUuidRequired = Ka\u017Ed\u00FD z\u00E1znam Ledger mus\u00ED ma\u0165 UUID. LedgerStructuralValidatorExDateSecurityRequired = EX_DATE mo\u017Eno pou\u017Ei\u0165 iba pri polo\u017Ek\u00E1ch, ktor\u00E9 odkazuj\u00FA na cenn\u00FD papier: {0} @@ -586,7 +585,6 @@ LedgerStructuralValidatorPostingSecurityRequired = Polo\u017Eka cenn\u00E9ho pap LedgerStructuralValidatorPostingTypeRequired = Polo\u017Eka Ledger {0} mus\u00ED ma\u0165 typ polo\u017Eky. -LedgerStructuralValidatorPostingUuidRequired = Z\u00E1znam Ledger {0} obsahuje polo\u017Eku bez UUID. LedgerStructuralValidatorPrimaryPostingRefNotFound = Odkaz na prim\u00E1rnu polo\u017Eku {0} mus\u00ED ukazova\u0165 na polo\u017Eku v tom istom z\u00E1zname. @@ -594,7 +592,6 @@ LedgerStructuralValidatorProjectionAccountRequired = Projekcia Ledger {0} mus\u0 LedgerStructuralValidatorProjectionGroupTargetConflict = Projekcia mus\u00ED pou\u017Ei\u0165 bu\u010F \u010Dlensk\u00E9 odkazy, alebo jeden odkaz na skupinu polo\u017Eiek, nie oboje. -LedgerStructuralValidatorProjectionMembershipRefNotFound = \u010Clensk\u00FD odkaz projekcie {0} mus\u00ED ukazova\u0165 na polo\u017Eku v tom istom z\u00E1zname. LedgerStructuralValidatorProjectionPortfolioRequired = Projekcia Ledger {0} mus\u00ED odkazova\u0165 na \u00FA\u010Det cenn\u00FDch papierov. diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_tr.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_tr.properties index c65c206a0c..04ded352c4 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_tr.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_tr.properties @@ -553,7 +553,6 @@ LedgerStructuralValidatorEntryDateTimeRequired = Ledger kayd\u0131 {0} tarih ve LedgerStructuralValidatorEntryTypeRequired = Ledger kayd\u0131 {0} bir kay\u0131t t\u00FCr\u00FCne sahip olmal\u0131d\u0131r. -LedgerStructuralValidatorEntryUuidRequired = Her Ledger kayd\u0131n\u0131n bir UUID de\u011Feri olmal\u0131d\u0131r. LedgerStructuralValidatorExDateSecurityRequired = EX_DATE yaln\u0131zca bir menkul k\u0131ymete ba\u015Fvuran kay\u0131tlarda kullan\u0131labilir: {0} @@ -583,7 +582,6 @@ LedgerStructuralValidatorPostingSecurityRequired = Menkul k\u0131ymet kayd\u0131 LedgerStructuralValidatorPostingTypeRequired = Ledger kayd\u0131 {0} bir kay\u0131t t\u00FCr\u00FCne sahip olmal\u0131d\u0131r. -LedgerStructuralValidatorPostingUuidRequired = Ledger giri\u015Fi {0} UUID olmayan bir kay\u0131t i\u00E7eriyor. LedgerStructuralValidatorPrimaryPostingRefNotFound = Birincil kay\u0131t ba\u015Fvurusu {0}, ayn\u0131 giri\u015Fteki bir kayd\u0131 g\u00F6stermelidir. @@ -591,7 +589,6 @@ LedgerStructuralValidatorProjectionAccountRequired = Ledger projeksiyonu {0} bir LedgerStructuralValidatorProjectionGroupTargetConflict = Bir projeksiyon ya \u00FCyelik ba\u015Fvurular\u0131 ya da tek bir kay\u0131t grubu ba\u015Fvurusu kullanmal\u0131d\u0131r, ikisini birden de\u011Fil. -LedgerStructuralValidatorProjectionMembershipRefNotFound = Projeksiyon \u00FCyelik ba\u015Fvurusu {0}, ayn\u0131 giri\u015Fteki bir kayd\u0131 g\u00F6stermelidir. LedgerStructuralValidatorProjectionPortfolioRequired = Ledger projeksiyonu {0} bir menkul k\u0131ymet hesab\u0131na ba\u015Fvurmal\u0131d\u0131r. diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_vi.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_vi.properties index e24db90b5f..3cb9998ce0 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_vi.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_vi.properties @@ -553,7 +553,6 @@ LedgerStructuralValidatorEntryDateTimeRequired = B\u1EA3n ghi Ledger {0} ph\u1EA LedgerStructuralValidatorEntryTypeRequired = B\u1EA3n ghi Ledger {0} ph\u1EA3i c\u00F3 lo\u1EA1i b\u1EA3n ghi. -LedgerStructuralValidatorEntryUuidRequired = M\u1ED7i b\u1EA3n ghi Ledger ph\u1EA3i c\u00F3 UUID. LedgerStructuralValidatorExDateSecurityRequired = EX_DATE ch\u1EC9 c\u00F3 th\u1EC3 d\u00F9ng tr\u00EAn c\u00E1c b\u00FAt to\u00E1n tham chi\u1EBFu \u0111\u1EBFn ch\u1EE9ng kho\u00E1n: {0} @@ -583,7 +582,6 @@ LedgerStructuralValidatorPostingSecurityRequired = B\u00FAt to\u00E1n ch\u1EE9ng LedgerStructuralValidatorPostingTypeRequired = B\u00FAt to\u00E1n Ledger {0} ph\u1EA3i c\u00F3 lo\u1EA1i b\u00FAt to\u00E1n. -LedgerStructuralValidatorPostingUuidRequired = B\u1EA3n ghi Ledger {0} ch\u1EE9a b\u00FAt to\u00E1n kh\u00F4ng c\u00F3 UUID. LedgerStructuralValidatorPrimaryPostingRefNotFound = Tham chi\u1EBFu b\u00FAt to\u00E1n ch\u00EDnh {0} ph\u1EA3i tr\u1ECF \u0111\u1EBFn m\u1ED9t b\u00FAt to\u00E1n trong c\u00F9ng b\u1EA3n ghi. @@ -591,7 +589,6 @@ LedgerStructuralValidatorProjectionAccountRequired = Ph\u00E9p chi\u1EBFu Ledger LedgerStructuralValidatorProjectionGroupTargetConflict = M\u1ED9t ph\u00E9p chi\u1EBFu ph\u1EA3i d\u00F9ng tham chi\u1EBFu th\u00E0nh vi\u00EAn ho\u1EB7c m\u1ED9t tham chi\u1EBFu nh\u00F3m b\u00FAt to\u00E1n duy nh\u1EA5t, kh\u00F4ng d\u00F9ng c\u1EA3 hai. -LedgerStructuralValidatorProjectionMembershipRefNotFound = Tham chi\u1EBFu th\u00E0nh vi\u00EAn ph\u00E9p chi\u1EBFu {0} ph\u1EA3i tr\u1ECF \u0111\u1EBFn m\u1ED9t b\u00FAt to\u00E1n trong c\u00F9ng b\u1EA3n ghi. LedgerStructuralValidatorProjectionPortfolioRequired = Ph\u00E9p chi\u1EBFu Ledger {0} ph\u1EA3i tham chi\u1EBFu \u0111\u1EBFn t\u00E0i kho\u1EA3n ch\u1EE9ng kho\u00E1n. diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_zh.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_zh.properties index 5a69e51c2c..775887fa19 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_zh.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_zh.properties @@ -556,7 +556,6 @@ LedgerStructuralValidatorEntryDateTimeRequired = Ledger \u6761\u76EE {0} \u5FC5\ LedgerStructuralValidatorEntryTypeRequired = Ledger \u6761\u76EE {0} \u5FC5\u987B\u5177\u6709\u6761\u76EE\u7C7B\u578B\u3002 -LedgerStructuralValidatorEntryUuidRequired = \u6BCF\u4E2A Ledger \u6761\u76EE\u90FD\u5FC5\u987B\u5177\u6709 UUID\u3002 LedgerStructuralValidatorExDateSecurityRequired = EX_DATE \u53EA\u80FD\u7528\u4E8E\u5F15\u7528\u8BC1\u5238\u7684\u8FC7\u8D26\uFF1A{0} @@ -586,7 +585,6 @@ LedgerStructuralValidatorPostingSecurityRequired = \u8BC1\u5238\u8FC7\u8D26 {0} LedgerStructuralValidatorPostingTypeRequired = Ledger \u8FC7\u8D26 {0} \u5FC5\u987B\u5177\u6709\u8FC7\u8D26\u7C7B\u578B\u3002 -LedgerStructuralValidatorPostingUuidRequired = Ledger \u6761\u76EE {0} \u5305\u542B\u6CA1\u6709 UUID \u7684\u8FC7\u8D26\u3002 LedgerStructuralValidatorPrimaryPostingRefNotFound = \u4E3B\u8FC7\u8D26\u5F15\u7528 {0} \u5FC5\u987B\u6307\u5411\u540C\u4E00\u6761\u76EE\u4E2D\u7684\u8FC7\u8D26\u3002 @@ -594,7 +592,6 @@ LedgerStructuralValidatorProjectionAccountRequired = Ledger \u6295\u5F71 {0} \u5 LedgerStructuralValidatorProjectionGroupTargetConflict = \u6295\u5F71\u5FC5\u987B\u4F7F\u7528\u6210\u5458\u5F15\u7528\u6216\u5355\u4E2A\u8FC7\u8D26\u7EC4\u5F15\u7528\uFF0C\u4E0D\u80FD\u540C\u65F6\u4F7F\u7528\u4E24\u8005\u3002 -LedgerStructuralValidatorProjectionMembershipRefNotFound = \u6295\u5F71\u6210\u5458\u5F15\u7528 {0} \u5FC5\u987B\u6307\u5411\u540C\u4E00\u6761\u76EE\u4E2D\u7684\u8FC7\u8D26\u3002 LedgerStructuralValidatorProjectionPortfolioRequired = Ledger \u6295\u5F71 {0} \u5FC5\u987B\u5F15\u7528\u8BC1\u5238\u8D26\u6237\u3002 diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_zh_TW.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_zh_TW.properties index f93fca1721..380de66b54 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_zh_TW.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_zh_TW.properties @@ -556,7 +556,6 @@ LedgerStructuralValidatorEntryDateTimeRequired = Ledger \u689D\u76EE {0} \u5FC5\ LedgerStructuralValidatorEntryTypeRequired = Ledger \u689D\u76EE {0} \u5FC5\u987B\u5177\u6709\u689D\u76EE\u7C7B\u578B\u3002 -LedgerStructuralValidatorEntryUuidRequired = \u6BCF\u4E2A Ledger \u689D\u76EE\u90FD\u5FC5\u987B\u5177\u6709 UUID\u3002 LedgerStructuralValidatorExDateSecurityRequired = EX_DATE \u53EA\u80FD\u7528\u4E8E\u53C3\u7167\u8B49\u5238\u7684\u904E\u5E33\uFF1A{0} @@ -586,7 +585,6 @@ LedgerStructuralValidatorPostingSecurityRequired = \u8B49\u5238\u904E\u5E33 {0} LedgerStructuralValidatorPostingTypeRequired = Ledger \u904E\u5E33 {0} \u5FC5\u987B\u5177\u6709\u904E\u5E33\u7C7B\u578B\u3002 -LedgerStructuralValidatorPostingUuidRequired = Ledger \u689D\u76EE {0} \u5305\u542B\u6CA1\u6709 UUID \u7684\u904E\u5E33\u3002 LedgerStructuralValidatorPrimaryPostingRefNotFound = \u4E3B\u904E\u5E33\u53C3\u7167 {0} \u5FC5\u987B\u6307\u5411\u540C\u4E00\u689D\u76EE\u4E2D\u7684\u904E\u5E33\u3002 @@ -594,7 +592,6 @@ LedgerStructuralValidatorProjectionAccountRequired = Ledger \u6295\u5F71 {0} \u5 LedgerStructuralValidatorProjectionGroupTargetConflict = \u6295\u5F71\u5FC5\u987B\u4F7F\u7528\u6210\u54E1\u53C3\u7167\u6216\u5355\u4E2A\u904E\u5E33\u7EC4\u53C3\u7167\uFF0C\u4E0D\u80FD\u540C\u65F6\u4F7F\u7528\u4E24\u8005\u3002 -LedgerStructuralValidatorProjectionMembershipRefNotFound = \u6295\u5F71\u6210\u54E1\u53C3\u7167 {0} \u5FC5\u987B\u6307\u5411\u540C\u4E00\u689D\u76EE\u4E2D\u7684\u904E\u5E33\u3002 LedgerStructuralValidatorProjectionPortfolioRequired = Ledger \u6295\u5F71 {0} \u5FC5\u987B\u53C3\u7167\u8B49\u5238\u5E33\u6236\u3002 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 index ff297f2d7d..f45588f3be 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerStructuralValidator.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerStructuralValidator.java @@ -28,12 +28,8 @@ public final class LedgerStructuralValidator public enum IssueCode { LEDGER_REQUIRED, - DUPLICATE_ENTRY_UUID, - DUPLICATE_POSTING_UUID, - ENTRY_UUID_REQUIRED, ENTRY_TYPE_REQUIRED, ENTRY_DATE_TIME_REQUIRED, - POSTING_UUID_REQUIRED, POSTING_TYPE_REQUIRED, POSTING_CURRENCY_REQUIRED, POSTING_SECURITY_REQUIRED, From acdfe12bf70e6915b05dbca7bfa34366a6db4c27 Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:43:11 +0200 Subject: [PATCH 26/68] Normalize Ledger message bundles Normalized the core and UI message bundles after the Ledger no-UUID cleanup so the managed-bundles check accepts their line endings and canonical key order. This fixes the post-cleanup release gate failure reported by portfolio-build-tools-check. Ledger behavior, Java logic, XML/protobuf schemas, tests, and release-gate coverage were intentionally left unchanged. --- .../src/name/abuchen/portfolio/ui/messages.properties | 4 ++-- .../src/name/abuchen/portfolio/ui/messages_ca.properties | 4 ++-- .../src/name/abuchen/portfolio/ui/messages_cs.properties | 4 ++-- .../src/name/abuchen/portfolio/ui/messages_da.properties | 4 ++-- .../src/name/abuchen/portfolio/ui/messages_de.properties | 4 ++-- .../src/name/abuchen/portfolio/ui/messages_es.properties | 4 ++-- .../src/name/abuchen/portfolio/ui/messages_fi.properties | 4 ++-- .../src/name/abuchen/portfolio/ui/messages_fr.properties | 4 ++-- .../src/name/abuchen/portfolio/ui/messages_it.properties | 4 ++-- .../src/name/abuchen/portfolio/ui/messages_nl.properties | 4 ++-- .../src/name/abuchen/portfolio/ui/messages_pl.properties | 4 ++-- .../src/name/abuchen/portfolio/ui/messages_pt.properties | 4 ++-- .../src/name/abuchen/portfolio/ui/messages_pt_BR.properties | 4 ++-- .../src/name/abuchen/portfolio/ui/messages_ru.properties | 4 ++-- .../src/name/abuchen/portfolio/ui/messages_sk.properties | 4 ++-- .../src/name/abuchen/portfolio/ui/messages_tr.properties | 4 ++-- .../src/name/abuchen/portfolio/ui/messages_vi.properties | 4 ++-- .../src/name/abuchen/portfolio/ui/messages_zh.properties | 4 ++-- .../src/name/abuchen/portfolio/ui/messages_zh_TW.properties | 4 ++-- .../src/name/abuchen/portfolio/messages.properties | 3 --- .../src/name/abuchen/portfolio/messages_ca.properties | 3 --- .../src/name/abuchen/portfolio/messages_cs.properties | 3 --- .../src/name/abuchen/portfolio/messages_da.properties | 3 --- .../src/name/abuchen/portfolio/messages_de.properties | 3 --- .../src/name/abuchen/portfolio/messages_es.properties | 3 --- .../src/name/abuchen/portfolio/messages_fi.properties | 3 --- .../src/name/abuchen/portfolio/messages_fr.properties | 3 --- .../src/name/abuchen/portfolio/messages_it.properties | 3 --- .../src/name/abuchen/portfolio/messages_nl.properties | 3 --- .../src/name/abuchen/portfolio/messages_pl.properties | 3 --- .../src/name/abuchen/portfolio/messages_pt.properties | 3 --- .../src/name/abuchen/portfolio/messages_pt_BR.properties | 3 --- .../src/name/abuchen/portfolio/messages_ru.properties | 3 --- .../src/name/abuchen/portfolio/messages_sk.properties | 3 --- .../src/name/abuchen/portfolio/messages_tr.properties | 3 --- .../src/name/abuchen/portfolio/messages_vi.properties | 3 --- .../src/name/abuchen/portfolio/messages_zh.properties | 3 --- .../src/name/abuchen/portfolio/messages_zh_TW.properties | 3 --- 38 files changed, 38 insertions(+), 95 deletions(-) diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages.properties index b0c2926eb7..63d5af571c 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages.properties @@ -1992,6 +1992,8 @@ LedgerNativeComponentInspectorCode = Code LedgerNativeComponentInspectorDateTime = Date/time +LedgerNativeComponentInspectorDerivedDescriptors = Derived descriptors + LedgerNativeComponentInspectorDialogTitle = Inspect Ledger entry details LedgerNativeComponentInspectorDomain = Domain @@ -2036,8 +2038,6 @@ LedgerNativeComponentInspectorPrimaryExpected = Primary expected LedgerNativeComponentInspectorPrimaryPostingUUID = Primary posting UUID -LedgerNativeComponentInspectorDerivedDescriptors = Derived descriptors - LedgerNativeComponentInspectorProjectionRole = Projection role LedgerNativeComponentInspectorProjectionUUID = Projection UUID diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_ca.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_ca.properties index ce44bdd21d..089079d865 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_ca.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_ca.properties @@ -1981,6 +1981,8 @@ LedgerNativeComponentInspectorCode = Codi LedgerNativeComponentInspectorDateTime = Data/hora +LedgerNativeComponentInspectorDerivedDescriptors = Refer\u00E8ncies de projecci\u00F3 + LedgerNativeComponentInspectorDialogTitle = Inspecciona els detalls de l\u2019entrada Ledger LedgerNativeComponentInspectorDomain = Domini @@ -2025,8 +2027,6 @@ LedgerNativeComponentInspectorPrimaryExpected = Refer\u00E8ncia principal espera LedgerNativeComponentInspectorPrimaryPostingUUID = UUID de l\u2019assentament principal -LedgerNativeComponentInspectorDerivedDescriptors = Refer\u00E8ncies de projecci\u00F3 - LedgerNativeComponentInspectorProjectionRole = Rol de projecci\u00F3 LedgerNativeComponentInspectorProjectionUUID = UUID de projecci\u00F3 diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_cs.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_cs.properties index 18e150b13d..f94db80957 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_cs.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_cs.properties @@ -1984,6 +1984,8 @@ LedgerNativeComponentInspectorCode = K\u00F3d LedgerNativeComponentInspectorDateTime = Datum/\u010Das +LedgerNativeComponentInspectorDerivedDescriptors = Odkazy projekc\u00ED + LedgerNativeComponentInspectorDialogTitle = Zkontrolovat detaily z\u00E1znamu Ledger LedgerNativeComponentInspectorDomain = Dom\u00E9na @@ -2028,8 +2030,6 @@ LedgerNativeComponentInspectorPrimaryExpected = O\u010Dek\u00E1v\u00E1n prim\u00 LedgerNativeComponentInspectorPrimaryPostingUUID = UUID prim\u00E1rn\u00ED polo\u017Eky -LedgerNativeComponentInspectorDerivedDescriptors = Odkazy projekc\u00ED - LedgerNativeComponentInspectorProjectionRole = Role projekce LedgerNativeComponentInspectorProjectionUUID = UUID projekce diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_da.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_da.properties index 7025c35e29..512be44918 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_da.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_da.properties @@ -1981,6 +1981,8 @@ LedgerNativeComponentInspectorCode = Kode LedgerNativeComponentInspectorDateTime = Dato/tid +LedgerNativeComponentInspectorDerivedDescriptors = Projektionsreferencer + LedgerNativeComponentInspectorDialogTitle = Inspicer detaljer for Ledger-post LedgerNativeComponentInspectorDomain = Dom\u00E6ne @@ -2025,8 +2027,6 @@ LedgerNativeComponentInspectorPrimaryExpected = Prim\u00E6r reference forventet LedgerNativeComponentInspectorPrimaryPostingUUID = Prim\u00E6r posting-UUID -LedgerNativeComponentInspectorDerivedDescriptors = Projektionsreferencer - LedgerNativeComponentInspectorProjectionRole = Projektionsrolle LedgerNativeComponentInspectorProjectionUUID = Projektions-UUID diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_de.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_de.properties index b0c6bd40ab..97ac3a62bd 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_de.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_de.properties @@ -1985,6 +1985,8 @@ LedgerNativeComponentInspectorCode = Code LedgerNativeComponentInspectorDateTime = Datum/Uhrzeit +LedgerNativeComponentInspectorDerivedDescriptors = Projektionsreferenzen + LedgerNativeComponentInspectorDialogTitle = Ledger-Eintrag pr\u00FCfen LedgerNativeComponentInspectorDomain = Dom\u00E4ne @@ -2029,8 +2031,6 @@ LedgerNativeComponentInspectorPrimaryExpected = Prim\u00E4rer Bezug erwartet LedgerNativeComponentInspectorPrimaryPostingUUID = Prim\u00E4re Buchungs-UUID -LedgerNativeComponentInspectorDerivedDescriptors = Projektionsreferenzen - LedgerNativeComponentInspectorProjectionRole = Projektionsrolle LedgerNativeComponentInspectorProjectionUUID = Projektions-UUID diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_es.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_es.properties index d4d69816d8..16f8ad3403 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_es.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_es.properties @@ -1981,6 +1981,8 @@ LedgerNativeComponentInspectorCode = C\u00F3digo LedgerNativeComponentInspectorDateTime = Fecha/hora +LedgerNativeComponentInspectorDerivedDescriptors = Referencias de proyecci\u00F3n + LedgerNativeComponentInspectorDialogTitle = Inspeccionar detalles de la entrada Ledger LedgerNativeComponentInspectorDomain = Dominio @@ -2025,8 +2027,6 @@ LedgerNativeComponentInspectorPrimaryExpected = Referencia principal esperada LedgerNativeComponentInspectorPrimaryPostingUUID = UUID del asiento principal -LedgerNativeComponentInspectorDerivedDescriptors = Referencias de proyecci\u00F3n - LedgerNativeComponentInspectorProjectionRole = Rol de proyecci\u00F3n LedgerNativeComponentInspectorProjectionUUID = UUID de proyecci\u00F3n diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_fi.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_fi.properties index a97b9fb426..ade9647a58 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_fi.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_fi.properties @@ -1981,6 +1981,8 @@ LedgerNativeComponentInspectorCode = Koodi LedgerNativeComponentInspectorDateTime = P\u00E4iv\u00E4/aika +LedgerNativeComponentInspectorDerivedDescriptors = Projektioviitteet + LedgerNativeComponentInspectorDialogTitle = Tarkista Ledger-kirjauksen tiedot LedgerNativeComponentInspectorDomain = Toimialue @@ -2025,8 +2027,6 @@ LedgerNativeComponentInspectorPrimaryExpected = Ensisijainen viite odotetaan LedgerNativeComponentInspectorPrimaryPostingUUID = Ensisijaisen kirjausrivin UUID -LedgerNativeComponentInspectorDerivedDescriptors = Projektioviitteet - LedgerNativeComponentInspectorProjectionRole = Projektion rooli LedgerNativeComponentInspectorProjectionUUID = Projektion UUID diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_fr.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_fr.properties index 1299ea96fb..feb952368b 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_fr.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_fr.properties @@ -1982,6 +1982,8 @@ LedgerNativeComponentInspectorCode = Code LedgerNativeComponentInspectorDateTime = Date/heure +LedgerNativeComponentInspectorDerivedDescriptors = R\u00E9f\u00E9rences de projection + LedgerNativeComponentInspectorDialogTitle = Inspecter les d\u00E9tails de l\u2019\u00E9criture Ledger LedgerNativeComponentInspectorDomain = Domaine @@ -2026,8 +2028,6 @@ LedgerNativeComponentInspectorPrimaryExpected = R\u00E9f\u00E9rence principale a LedgerNativeComponentInspectorPrimaryPostingUUID = UUID du posting principal -LedgerNativeComponentInspectorDerivedDescriptors = R\u00E9f\u00E9rences de projection - LedgerNativeComponentInspectorProjectionRole = R\u00F4le de projection LedgerNativeComponentInspectorProjectionUUID = UUID de projection diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_it.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_it.properties index 0c309de3da..601a4a9e28 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_it.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_it.properties @@ -1981,6 +1981,8 @@ LedgerNativeComponentInspectorCode = Codice LedgerNativeComponentInspectorDateTime = Data/ora +LedgerNativeComponentInspectorDerivedDescriptors = Riferimenti di proiezione + LedgerNativeComponentInspectorDialogTitle = Ispeziona i dettagli della voce Ledger LedgerNativeComponentInspectorDomain = Dominio @@ -2025,8 +2027,6 @@ LedgerNativeComponentInspectorPrimaryExpected = Riferimento principale previsto LedgerNativeComponentInspectorPrimaryPostingUUID = UUID della registrazione principale -LedgerNativeComponentInspectorDerivedDescriptors = Riferimenti di proiezione - LedgerNativeComponentInspectorProjectionRole = Ruolo di proiezione LedgerNativeComponentInspectorProjectionUUID = UUID della proiezione diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_nl.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_nl.properties index d7a656a8a5..d72d33853f 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_nl.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_nl.properties @@ -1981,6 +1981,8 @@ LedgerNativeComponentInspectorCode = Code LedgerNativeComponentInspectorDateTime = Datum/tijd +LedgerNativeComponentInspectorDerivedDescriptors = Projectieverwijzingen + LedgerNativeComponentInspectorDialogTitle = Ledger-boekingsdetails inspecteren LedgerNativeComponentInspectorDomain = Domein @@ -2025,8 +2027,6 @@ LedgerNativeComponentInspectorPrimaryExpected = Primaire verwijzing verwacht LedgerNativeComponentInspectorPrimaryPostingUUID = Primaire posting-UUID -LedgerNativeComponentInspectorDerivedDescriptors = Projectieverwijzingen - LedgerNativeComponentInspectorProjectionRole = Projectierol LedgerNativeComponentInspectorProjectionUUID = Projectie-UUID diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_pl.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_pl.properties index 7a77c5747a..4712c1415c 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_pl.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_pl.properties @@ -1981,6 +1981,8 @@ LedgerNativeComponentInspectorCode = Kod LedgerNativeComponentInspectorDateTime = Data/czas +LedgerNativeComponentInspectorDerivedDescriptors = Referencje projekcji + LedgerNativeComponentInspectorDialogTitle = Sprawd\u017A szczeg\u00F3\u0142y wpisu Ledger LedgerNativeComponentInspectorDomain = Domena @@ -2025,8 +2027,6 @@ LedgerNativeComponentInspectorPrimaryExpected = Oczekiwane odniesienie g\u0142\u LedgerNativeComponentInspectorPrimaryPostingUUID = UUID g\u0142\u00F3wnego ksi\u0119gowania -LedgerNativeComponentInspectorDerivedDescriptors = Referencje projekcji - LedgerNativeComponentInspectorProjectionRole = Rola projekcji LedgerNativeComponentInspectorProjectionUUID = UUID projekcji diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_pt.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_pt.properties index 12ce52c2b6..0664073263 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_pt.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_pt.properties @@ -1979,6 +1979,8 @@ LedgerNativeComponentInspectorCode = C\u00F3digo LedgerNativeComponentInspectorDateTime = Data/hora +LedgerNativeComponentInspectorDerivedDescriptors = Refer\u00EAncias de proje\u00E7\u00E3o + LedgerNativeComponentInspectorDialogTitle = Inspecionar detalhes da entrada Ledger LedgerNativeComponentInspectorDomain = Dom\u00EDnio @@ -2023,8 +2025,6 @@ LedgerNativeComponentInspectorPrimaryExpected = Refer\u00EAncia principal espera LedgerNativeComponentInspectorPrimaryPostingUUID = UUID do lan\u00E7amento principal -LedgerNativeComponentInspectorDerivedDescriptors = Refer\u00EAncias de proje\u00E7\u00E3o - LedgerNativeComponentInspectorProjectionRole = Fun\u00E7\u00E3o da proje\u00E7\u00E3o LedgerNativeComponentInspectorProjectionUUID = UUID da proje\u00E7\u00E3o diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_pt_BR.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_pt_BR.properties index f6eb24f555..ac1e795e84 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_pt_BR.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_pt_BR.properties @@ -1979,6 +1979,8 @@ LedgerNativeComponentInspectorCode = C\u00F3digo LedgerNativeComponentInspectorDateTime = Data/hora +LedgerNativeComponentInspectorDerivedDescriptors = Refer\u00EAncias de proje\u00E7\u00E3o + LedgerNativeComponentInspectorDialogTitle = Inspecionar detalhes da entrada Ledger LedgerNativeComponentInspectorDomain = Dom\u00EDnio @@ -2023,8 +2025,6 @@ LedgerNativeComponentInspectorPrimaryExpected = Refer\u00EAncia principal espera LedgerNativeComponentInspectorPrimaryPostingUUID = UUID do lan\u00E7amento principal -LedgerNativeComponentInspectorDerivedDescriptors = Refer\u00EAncias de proje\u00E7\u00E3o - LedgerNativeComponentInspectorProjectionRole = Fun\u00E7\u00E3o da proje\u00E7\u00E3o LedgerNativeComponentInspectorProjectionUUID = UUID da proje\u00E7\u00E3o diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_ru.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_ru.properties index 924d09532b..825dd9de75 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_ru.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_ru.properties @@ -1977,6 +1977,8 @@ LedgerNativeComponentInspectorCode = \u041A\u043E\u0434 LedgerNativeComponentInspectorDateTime = \u0414\u0430\u0442\u0430/\u0432\u0440\u0435\u043C\u044F +LedgerNativeComponentInspectorDerivedDescriptors = \u0421\u0441\u044B\u043B\u043A\u0438 \u043F\u0440\u043E\u0435\u043A\u0446\u0438\u0439 + LedgerNativeComponentInspectorDialogTitle = \u041F\u0440\u043E\u0432\u0435\u0440\u0438\u0442\u044C \u0434\u0435\u0442\u0430\u043B\u0438 \u0437\u0430\u043F\u0438\u0441\u0438 Ledger LedgerNativeComponentInspectorDomain = \u0414\u043E\u043C\u0435\u043D @@ -2021,8 +2023,6 @@ LedgerNativeComponentInspectorPrimaryExpected = \u041E\u0436\u0438\u0434\u0430\u LedgerNativeComponentInspectorPrimaryPostingUUID = UUID \u043E\u0441\u043D\u043E\u0432\u043D\u043E\u0439 \u043F\u0440\u043E\u0432\u043E\u0434\u043A\u0438 -LedgerNativeComponentInspectorDerivedDescriptors = \u0421\u0441\u044B\u043B\u043A\u0438 \u043F\u0440\u043E\u0435\u043A\u0446\u0438\u0439 - LedgerNativeComponentInspectorProjectionRole = \u0420\u043E\u043B\u044C \u043F\u0440\u043E\u0435\u043A\u0446\u0438\u0438 LedgerNativeComponentInspectorProjectionUUID = UUID \u043F\u0440\u043E\u0435\u043A\u0446\u0438\u0438 diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_sk.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_sk.properties index b6aaf4a63f..c3238d1d68 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_sk.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_sk.properties @@ -1981,6 +1981,8 @@ LedgerNativeComponentInspectorCode = K\u00F3d LedgerNativeComponentInspectorDateTime = D\u00E1tum/\u010Das +LedgerNativeComponentInspectorDerivedDescriptors = Odkazy projekci\u00ED + LedgerNativeComponentInspectorDialogTitle = Skontrolova\u0165 detaily z\u00E1znamu Ledger LedgerNativeComponentInspectorDomain = Dom\u00E9na @@ -2025,8 +2027,6 @@ LedgerNativeComponentInspectorPrimaryExpected = O\u010Dak\u00E1va sa prim\u00E1r LedgerNativeComponentInspectorPrimaryPostingUUID = UUID prim\u00E1rnej polo\u017Eky -LedgerNativeComponentInspectorDerivedDescriptors = Odkazy projekci\u00ED - LedgerNativeComponentInspectorProjectionRole = Rola projekcie LedgerNativeComponentInspectorProjectionUUID = UUID projekcie diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_tr.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_tr.properties index 0baed84513..f8ce8664e7 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_tr.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_tr.properties @@ -1981,6 +1981,8 @@ LedgerNativeComponentInspectorCode = Kod LedgerNativeComponentInspectorDateTime = Tarih/saat +LedgerNativeComponentInspectorDerivedDescriptors = Projeksiyon referanslar\u0131 + LedgerNativeComponentInspectorDialogTitle = Ledger kayd\u0131 ayr\u0131nt\u0131lar\u0131n\u0131 incele LedgerNativeComponentInspectorDomain = Alan @@ -2025,8 +2027,6 @@ LedgerNativeComponentInspectorPrimaryExpected = Birincil referans bekleniyor LedgerNativeComponentInspectorPrimaryPostingUUID = Birincil kay\u0131t UUID -LedgerNativeComponentInspectorDerivedDescriptors = Projeksiyon referanslar\u0131 - LedgerNativeComponentInspectorProjectionRole = Projeksiyon rol\u00FC LedgerNativeComponentInspectorProjectionUUID = Projeksiyon UUID diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_vi.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_vi.properties index 6f20b35366..dcc4a85846 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_vi.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_vi.properties @@ -1981,6 +1981,8 @@ LedgerNativeComponentInspectorCode = M\u00E3 LedgerNativeComponentInspectorDateTime = Ng\u00E0y/gi\u1EDD +LedgerNativeComponentInspectorDerivedDescriptors = Tham chi\u1EBFu ph\u00E9p chi\u1EBFu + LedgerNativeComponentInspectorDialogTitle = Ki\u1EC3m tra chi ti\u1EBFt b\u1EA3n ghi Ledger LedgerNativeComponentInspectorDomain = Mi\u1EC1n @@ -2025,8 +2027,6 @@ LedgerNativeComponentInspectorPrimaryExpected = C\u1EA7n tham chi\u1EBFu ch\u00E LedgerNativeComponentInspectorPrimaryPostingUUID = UUID b\u00FAt to\u00E1n ch\u00EDnh -LedgerNativeComponentInspectorDerivedDescriptors = Tham chi\u1EBFu ph\u00E9p chi\u1EBFu - LedgerNativeComponentInspectorProjectionRole = Vai tr\u00F2 ph\u00E9p chi\u1EBFu LedgerNativeComponentInspectorProjectionUUID = UUID ph\u00E9p chi\u1EBFu diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_zh.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_zh.properties index b7604ce8e1..2603c53190 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_zh.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_zh.properties @@ -1981,6 +1981,8 @@ LedgerNativeComponentInspectorCode = \u4EE3\u7801 LedgerNativeComponentInspectorDateTime = \u65E5\u671F/\u65F6\u95F4 +LedgerNativeComponentInspectorDerivedDescriptors = \u6295\u5F71\u5F15\u7528 + LedgerNativeComponentInspectorDialogTitle = \u68C0\u67E5 Ledger \u6761\u76EE\u8BE6\u7EC6\u4FE1\u606F LedgerNativeComponentInspectorDomain = \u57DF @@ -2025,8 +2027,6 @@ LedgerNativeComponentInspectorPrimaryExpected = \u9884\u671F\u4E3B\u5F15\u7528 LedgerNativeComponentInspectorPrimaryPostingUUID = \u4E3B\u8FC7\u8D26 UUID -LedgerNativeComponentInspectorDerivedDescriptors = \u6295\u5F71\u5F15\u7528 - LedgerNativeComponentInspectorProjectionRole = \u6295\u5F71\u89D2\u8272 LedgerNativeComponentInspectorProjectionUUID = \u6295\u5F71 UUID diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_zh_TW.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_zh_TW.properties index 452df8be75..f4a5259935 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_zh_TW.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_zh_TW.properties @@ -1981,6 +1981,8 @@ LedgerNativeComponentInspectorCode = \u4EE3\u78BC LedgerNativeComponentInspectorDateTime = \u65E5\u671F/\u65F6\u95F4 +LedgerNativeComponentInspectorDerivedDescriptors = \u6295\u5F71\u53C3\u7167 + LedgerNativeComponentInspectorDialogTitle = \u68C0\u67E5 Ledger \u689D\u76EE\u8BE6\u7EC6\u4FE1\u606F LedgerNativeComponentInspectorDomain = \u57DF @@ -2025,8 +2027,6 @@ LedgerNativeComponentInspectorPrimaryExpected = \u9884\u671F\u4E3B\u53C3\u7167 LedgerNativeComponentInspectorPrimaryPostingUUID = \u4E3B\u904E\u5E33 UUID -LedgerNativeComponentInspectorDerivedDescriptors = \u6295\u5F71\u53C3\u7167 - LedgerNativeComponentInspectorProjectionRole = \u6295\u5F71\u89D2\u8272 LedgerNativeComponentInspectorProjectionUUID = \u6295\u5F71 UUID diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages.properties index 8598ac4ea4..6cef515d64 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages.properties @@ -556,7 +556,6 @@ LedgerStructuralValidatorEntryDateTimeRequired = Ledger entry {0} must have a da 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. @@ -585,14 +584,12 @@ LedgerStructuralValidatorPostingSecurityRequired = Security posting {0} must ref LedgerStructuralValidatorPostingTypeRequired = Ledger posting {0} must have a posting type. - LedgerStructuralValidatorPrimaryPostingRefNotFound = Primary posting reference {0} must point to a posting in the same entry. LedgerStructuralValidatorProjectionAccountRequired = Ledger projection {0} must reference an account. LedgerStructuralValidatorProjectionGroupTargetConflict = A projection must use either membership references or a single posting-group reference, not both. - LedgerStructuralValidatorProjectionPortfolioRequired = Ledger projection {0} must reference a portfolio. LedgerStructuralValidatorProjectionPrimaryTargetConflict = A projection must use either membership references or a single primary posting reference, not both. diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_ca.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_ca.properties index f276aac973..1699cb6c1f 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_ca.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_ca.properties @@ -555,7 +555,6 @@ LedgerStructuralValidatorEntryDateTimeRequired = L\u2019entrada Ledger {0} ha de LedgerStructuralValidatorEntryTypeRequired = L\u2019entrada Ledger {0} ha de tenir un tipus d\u2019entrada. - LedgerStructuralValidatorExDateSecurityRequired = EX_DATE nom\u00E9s es pot usar en assentaments que referencien un actiu: {0} LedgerStructuralValidatorLedgerRequired = Cal un objecte Ledger. @@ -584,14 +583,12 @@ LedgerStructuralValidatorPostingSecurityRequired = L\u2019assentament d\u2019act LedgerStructuralValidatorPostingTypeRequired = L\u2019assentament Ledger {0} ha de tenir un tipus d\u2019assentament. - LedgerStructuralValidatorPrimaryPostingRefNotFound = La refer\u00E8ncia d\u2019assentament principal {0} ha d\u2019apuntar a un assentament de la mateixa entrada. LedgerStructuralValidatorProjectionAccountRequired = La projecci\u00F3 Ledger {0} ha de referenciar un compte. LedgerStructuralValidatorProjectionGroupTargetConflict = Una projecci\u00F3 ha d\u2019usar refer\u00E8ncies de pertinen\u00E7a o una sola refer\u00E8ncia de grup d\u2019assentaments, per\u00F2 no totes dues. - LedgerStructuralValidatorProjectionPortfolioRequired = La projecci\u00F3 Ledger {0} ha de referenciar un compte de valors. LedgerStructuralValidatorProjectionPrimaryTargetConflict = Una projecci\u00F3 ha d\u2019usar refer\u00E8ncies de pertinen\u00E7a o una sola refer\u00E8ncia d\u2019assentament principal, per\u00F2 no totes dues. diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_cs.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_cs.properties index 29be4f44b5..4be2ebbdc9 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_cs.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_cs.properties @@ -554,7 +554,6 @@ LedgerStructuralValidatorEntryDateTimeRequired = Z\u00E1znam Ledger {0} mus\u00E LedgerStructuralValidatorEntryTypeRequired = Z\u00E1znam Ledger {0} mus\u00ED m\u00EDt typ z\u00E1znamu. - LedgerStructuralValidatorExDateSecurityRequired = EX_DATE lze pou\u017E\u00EDt pouze u polo\u017Eek, kter\u00E9 odkazuj\u00ED na cenn\u00FD pap\u00EDr: {0} LedgerStructuralValidatorLedgerRequired = Je vy\u017Eadov\u00E1n objekt Ledger. @@ -583,14 +582,12 @@ LedgerStructuralValidatorPostingSecurityRequired = Polo\u017Eka cenn\u00E9ho pap LedgerStructuralValidatorPostingTypeRequired = Polo\u017Eka Ledger {0} mus\u00ED m\u00EDt typ polo\u017Eky. - LedgerStructuralValidatorPrimaryPostingRefNotFound = Odkaz na prim\u00E1rn\u00ED polo\u017Eku {0} mus\u00ED ukazovat na polo\u017Eku ve stejn\u00E9m z\u00E1znamu. LedgerStructuralValidatorProjectionAccountRequired = Projekce Ledger {0} mus\u00ED odkazovat na \u00FA\u010Det. LedgerStructuralValidatorProjectionGroupTargetConflict = Projekce mus\u00ED pou\u017E\u00EDt bu\u010F \u010Dlensk\u00E9 odkazy, nebo jeden odkaz na skupinu polo\u017Eek, ne oboj\u00ED. - LedgerStructuralValidatorProjectionPortfolioRequired = Projekce Ledger {0} mus\u00ED odkazovat na \u00FA\u010Det cenn\u00FDch pap\u00EDr\u016F. LedgerStructuralValidatorProjectionPrimaryTargetConflict = Projekce mus\u00ED pou\u017E\u00EDt bu\u010F \u010Dlensk\u00E9 odkazy, nebo jeden odkaz na prim\u00E1rn\u00ED polo\u017Eku, ne oboj\u00ED. diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_da.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_da.properties index b18d9cb043..bd833ab816 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_da.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_da.properties @@ -556,7 +556,6 @@ LedgerStructuralValidatorEntryDateTimeRequired = Ledger-posten {0} skal have dat LedgerStructuralValidatorEntryTypeRequired = Ledger-posten {0} skal have en posttype. - LedgerStructuralValidatorExDateSecurityRequired = EX_DATE kan kun bruges p\u00E5 postinger, der refererer til et v\u00E6rdipapir: {0} LedgerStructuralValidatorLedgerRequired = Et Ledger-objekt er p\u00E5kr\u00E6vet. @@ -585,14 +584,12 @@ LedgerStructuralValidatorPostingSecurityRequired = V\u00E6rdipapirpostingen {0} LedgerStructuralValidatorPostingTypeRequired = Ledger-postingen {0} skal have en postingtype. - LedgerStructuralValidatorPrimaryPostingRefNotFound = Referencen til prim\u00E6r posting {0} skal pege p\u00E5 en posting i samme post. LedgerStructuralValidatorProjectionAccountRequired = Ledger-projektionen {0} skal referere til en konto. LedgerStructuralValidatorProjectionGroupTargetConflict = En projektion skal bruge enten medlemskabsreferencer eller \u00E9n postinggruppereference, ikke begge. - LedgerStructuralValidatorProjectionPortfolioRequired = Ledger-projektionen {0} skal referere til en v\u00E6rdipapirkonto. LedgerStructuralValidatorProjectionPrimaryTargetConflict = En projektion skal bruge enten medlemskabsreferencer eller \u00E9n prim\u00E6r postingreference, ikke begge. diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_de.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_de.properties index 4a4712d89e..70d01dd693 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_de.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_de.properties @@ -556,7 +556,6 @@ LedgerStructuralValidatorEntryDateTimeRequired = Der Ledger-Eintrag {0} muss Dat LedgerStructuralValidatorEntryTypeRequired = Der Ledger-Eintrag {0} muss einen Eintragstyp haben. - LedgerStructuralValidatorExDateSecurityRequired = EX_DATE kann nur f\u00FCr Buchungen verwendet werden, die auf ein Wertpapier verweisen: {0} LedgerStructuralValidatorLedgerRequired = Ein Ledger-Objekt ist erforderlich. @@ -585,14 +584,12 @@ LedgerStructuralValidatorPostingSecurityRequired = Die Wertpapierbuchung {0} mus LedgerStructuralValidatorPostingTypeRequired = Die Ledger-Buchung {0} muss einen Buchungstyp haben. - LedgerStructuralValidatorPrimaryPostingRefNotFound = Die prim\u00E4re Buchungsreferenz {0} muss auf eine Buchung im selben Eintrag zeigen. LedgerStructuralValidatorProjectionAccountRequired = Die Ledger-Projektion {0} muss auf ein Konto verweisen. LedgerStructuralValidatorProjectionGroupTargetConflict = Eine Projektion muss entweder Mitgliedschaftsreferenzen oder eine einzelne Buchungsgruppenreferenz verwenden, nicht beides. - LedgerStructuralValidatorProjectionPortfolioRequired = Die Ledger-Projektion {0} muss auf ein Depot verweisen. LedgerStructuralValidatorProjectionPrimaryTargetConflict = Eine Projektion muss entweder Mitgliedschaftsreferenzen oder eine einzelne prim\u00E4re Buchungsreferenz verwenden, nicht beides. diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_es.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_es.properties index eaa4b6fa52..d870a8ae46 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_es.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_es.properties @@ -554,7 +554,6 @@ LedgerStructuralValidatorEntryDateTimeRequired = La entrada Ledger {0} debe tene LedgerStructuralValidatorEntryTypeRequired = La entrada Ledger {0} debe tener un tipo de entrada. - LedgerStructuralValidatorExDateSecurityRequired = EX_DATE solo se puede usar en asientos que referencian un activo: {0} LedgerStructuralValidatorLedgerRequired = Se requiere un objeto Ledger. @@ -583,14 +582,12 @@ LedgerStructuralValidatorPostingSecurityRequired = El asiento de activo {0} debe LedgerStructuralValidatorPostingTypeRequired = El asiento Ledger {0} debe tener un tipo de asiento. - LedgerStructuralValidatorPrimaryPostingRefNotFound = La referencia de asiento principal {0} debe apuntar a un asiento de la misma entrada. LedgerStructuralValidatorProjectionAccountRequired = La proyecci\u00F3n Ledger {0} debe referenciar una cuenta. LedgerStructuralValidatorProjectionGroupTargetConflict = Una proyecci\u00F3n debe usar referencias de pertenencia o una sola referencia de grupo de asientos, pero no ambas. - LedgerStructuralValidatorProjectionPortfolioRequired = La proyecci\u00F3n Ledger {0} debe referenciar una cuenta de valores. LedgerStructuralValidatorProjectionPrimaryTargetConflict = Una proyecci\u00F3n debe usar referencias de pertenencia o una sola referencia de asiento principal, pero no ambas. diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_fi.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_fi.properties index 9f9fddd8ff..60129117b9 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_fi.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_fi.properties @@ -555,7 +555,6 @@ LedgerStructuralValidatorEntryDateTimeRequired = Ledger-kirjauksella {0} on olta LedgerStructuralValidatorEntryTypeRequired = Ledger-kirjauksella {0} on oltava kirjaustyyppi. - LedgerStructuralValidatorExDateSecurityRequired = EX_DATE voidaan k\u00E4ytt\u00E4\u00E4 vain kirjausriveill\u00E4, jotka viittaavat arvopaperiin: {0} LedgerStructuralValidatorLedgerRequired = Ledger-objekti vaaditaan. @@ -584,14 +583,12 @@ LedgerStructuralValidatorPostingSecurityRequired = Arvopaperikirjausrivin {0} on LedgerStructuralValidatorPostingTypeRequired = Ledger-kirjausrivill\u00E4 {0} on oltava kirjausrivin tyyppi. - LedgerStructuralValidatorPrimaryPostingRefNotFound = Ensisijaisen kirjausrivin viitteen {0} on osoitettava saman kirjauksen kirjausriviin. LedgerStructuralValidatorProjectionAccountRequired = Ledger-projektion {0} on viitattava tiliin. LedgerStructuralValidatorProjectionGroupTargetConflict = Projektion on k\u00E4ytett\u00E4v\u00E4 joko j\u00E4senyysviitteit\u00E4 tai yht\u00E4 kirjausryhm\u00E4viitett\u00E4, ei molempia. - LedgerStructuralValidatorProjectionPortfolioRequired = Ledger-projektion {0} on viitattava s\u00E4ilytystiliin. LedgerStructuralValidatorProjectionPrimaryTargetConflict = Projektion on k\u00E4ytett\u00E4v\u00E4 joko j\u00E4senyysviitteit\u00E4 tai yht\u00E4 ensisijaisen kirjausrivin viitett\u00E4, ei molempia. diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_fr.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_fr.properties index 92c8640640..680e122f1a 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_fr.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_fr.properties @@ -556,7 +556,6 @@ LedgerStructuralValidatorEntryDateTimeRequired = L\u2019\u00E9criture Ledger {0} LedgerStructuralValidatorEntryTypeRequired = L\u2019\u00E9criture Ledger {0} doit avoir un type d\u2019\u00E9criture. - LedgerStructuralValidatorExDateSecurityRequired = EX_DATE ne peut \u00EAtre utilis\u00E9 que sur des postings qui r\u00E9f\u00E9rencent un titre : {0} LedgerStructuralValidatorLedgerRequired = Un objet Ledger est requis. @@ -585,14 +584,12 @@ LedgerStructuralValidatorPostingSecurityRequired = Le posting de titre {0} doit LedgerStructuralValidatorPostingTypeRequired = Le posting Ledger {0} doit avoir un type de posting. - LedgerStructuralValidatorPrimaryPostingRefNotFound = La r\u00E9f\u00E9rence de posting principal {0} doit pointer vers un posting de la m\u00EAme \u00E9criture. LedgerStructuralValidatorProjectionAccountRequired = La projection Ledger {0} doit r\u00E9f\u00E9rencer un compte. LedgerStructuralValidatorProjectionGroupTargetConflict = Une projection doit utiliser soit des r\u00E9f\u00E9rences d\u2019appartenance, soit une seule r\u00E9f\u00E9rence de groupe de postings, mais pas les deux. - LedgerStructuralValidatorProjectionPortfolioRequired = La projection Ledger {0} doit r\u00E9f\u00E9rencer un compte titres. LedgerStructuralValidatorProjectionPrimaryTargetConflict = Une projection doit utiliser soit des r\u00E9f\u00E9rences d\u2019appartenance, soit une seule r\u00E9f\u00E9rence de posting principal, mais pas les deux. diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_it.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_it.properties index e32393c378..463d88410b 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_it.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_it.properties @@ -556,7 +556,6 @@ LedgerStructuralValidatorEntryDateTimeRequired = La voce Ledger {0} deve avere d LedgerStructuralValidatorEntryTypeRequired = La voce Ledger {0} deve avere un tipo di voce. - LedgerStructuralValidatorExDateSecurityRequired = EX_DATE pu\u00F2 essere usato solo su registrazioni che riferiscono un titolo: {0} LedgerStructuralValidatorLedgerRequired = \u00C8 richiesto un oggetto Ledger. @@ -585,14 +584,12 @@ LedgerStructuralValidatorPostingSecurityRequired = La registrazione titolo {0} d LedgerStructuralValidatorPostingTypeRequired = La registrazione Ledger {0} deve avere un tipo di registrazione. - LedgerStructuralValidatorPrimaryPostingRefNotFound = Il riferimento alla registrazione principale {0} deve puntare a una registrazione nella stessa voce. LedgerStructuralValidatorProjectionAccountRequired = La proiezione Ledger {0} deve riferirsi a un conto. LedgerStructuralValidatorProjectionGroupTargetConflict = Una proiezione deve usare riferimenti di appartenenza oppure un solo riferimento al gruppo di registrazioni, non entrambi. - LedgerStructuralValidatorProjectionPortfolioRequired = La proiezione Ledger {0} deve riferirsi a un conto titoli. LedgerStructuralValidatorProjectionPrimaryTargetConflict = Una proiezione deve usare riferimenti di appartenenza oppure un solo riferimento alla registrazione principale, non entrambi. diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_nl.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_nl.properties index faeaf41717..44daaf67e0 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_nl.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_nl.properties @@ -556,7 +556,6 @@ LedgerStructuralValidatorEntryDateTimeRequired = Ledger-boeking {0} moet een dat LedgerStructuralValidatorEntryTypeRequired = Ledger-boeking {0} moet een boekingstype hebben. - LedgerStructuralValidatorExDateSecurityRequired = EX_DATE kan alleen worden gebruikt op postings die naar een effect verwijzen: {0} LedgerStructuralValidatorLedgerRequired = Een Ledger-object is vereist. @@ -585,14 +584,12 @@ LedgerStructuralValidatorPostingSecurityRequired = Effectposting {0} moet naar e LedgerStructuralValidatorPostingTypeRequired = Ledger-posting {0} moet een postingtype hebben. - LedgerStructuralValidatorPrimaryPostingRefNotFound = Primaire postingverwijzing {0} moet naar een posting in dezelfde boeking wijzen. LedgerStructuralValidatorProjectionAccountRequired = Ledger-projectie {0} moet naar een rekening verwijzen. LedgerStructuralValidatorProjectionGroupTargetConflict = Een projectie moet lidmaatschapsverwijzingen of \u00E9\u00E9n postinggroepverwijzing gebruiken, niet beide. - LedgerStructuralValidatorProjectionPortfolioRequired = Ledger-projectie {0} moet naar een effectenrekening verwijzen. LedgerStructuralValidatorProjectionPrimaryTargetConflict = Een projectie moet lidmaatschapsverwijzingen of \u00E9\u00E9n primaire postingverwijzing gebruiken, niet beide. diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_pl.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_pl.properties index 66fa212708..db727bb22e 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_pl.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_pl.properties @@ -554,7 +554,6 @@ LedgerStructuralValidatorEntryDateTimeRequired = Wpis Ledger {0} musi mie\u0107 LedgerStructuralValidatorEntryTypeRequired = Wpis Ledger {0} musi mie\u0107 typ wpisu. - LedgerStructuralValidatorExDateSecurityRequired = EX_DATE mo\u017Cna u\u017Cywa\u0107 tylko w ksi\u0119gowaniach odwo\u0142uj\u0105cych si\u0119 do waloru: {0} LedgerStructuralValidatorLedgerRequired = Wymagany jest obiekt Ledger. @@ -583,14 +582,12 @@ LedgerStructuralValidatorPostingSecurityRequired = Ksi\u0119gowanie waloru {0} m LedgerStructuralValidatorPostingTypeRequired = Ksi\u0119gowanie Ledger {0} musi mie\u0107 typ ksi\u0119gowania. - LedgerStructuralValidatorPrimaryPostingRefNotFound = Odniesienie do g\u0142\u00F3wnego ksi\u0119gowania {0} musi wskazywa\u0107 ksi\u0119gowanie w tym samym wpisie. LedgerStructuralValidatorProjectionAccountRequired = Projekcja Ledger {0} musi odwo\u0142ywa\u0107 si\u0119 do konta. LedgerStructuralValidatorProjectionGroupTargetConflict = Projekcja musi u\u017Cywa\u0107 albo odniesie\u0144 cz\u0142onkostwa, albo jednego odniesienia do grupy ksi\u0119gowa\u0144, nie obu. - LedgerStructuralValidatorProjectionPortfolioRequired = Projekcja Ledger {0} musi odwo\u0142ywa\u0107 si\u0119 do konta walor\u00F3w. LedgerStructuralValidatorProjectionPrimaryTargetConflict = Projekcja musi u\u017Cywa\u0107 albo odniesie\u0144 cz\u0142onkostwa, albo jednego odniesienia do g\u0142\u00F3wnego ksi\u0119gowania, nie obu. diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_pt.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_pt.properties index ed2dae6c9e..1a58d96171 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_pt.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_pt.properties @@ -540,7 +540,6 @@ LedgerStructuralValidatorEntryDateTimeRequired = A entrada Ledger {0} deve ter d LedgerStructuralValidatorEntryTypeRequired = A entrada Ledger {0} deve ter um tipo de entrada. - LedgerStructuralValidatorExDateSecurityRequired = EX_DATE s\u00F3 pode ser usado em lan\u00E7amentos que referenciam um ativo: {0} LedgerStructuralValidatorLedgerRequired = \u00C9 necess\u00E1rio um objeto Ledger. @@ -569,14 +568,12 @@ LedgerStructuralValidatorPostingSecurityRequired = O lan\u00E7amento de ativo {0 LedgerStructuralValidatorPostingTypeRequired = O lan\u00E7amento Ledger {0} deve ter um tipo de lan\u00E7amento. - LedgerStructuralValidatorPrimaryPostingRefNotFound = A refer\u00EAncia de lan\u00E7amento principal {0} deve apontar para um lan\u00E7amento na mesma entrada. LedgerStructuralValidatorProjectionAccountRequired = A proje\u00E7\u00E3o Ledger {0} deve referenciar uma conta. LedgerStructuralValidatorProjectionGroupTargetConflict = Uma proje\u00E7\u00E3o deve usar refer\u00EAncias de perten\u00E7a ou uma \u00FAnica refer\u00EAncia de grupo de lan\u00E7amentos, mas n\u00E3o ambas. - LedgerStructuralValidatorProjectionPortfolioRequired = A proje\u00E7\u00E3o Ledger {0} deve referenciar uma conta de t\u00EDtulos. LedgerStructuralValidatorProjectionPrimaryTargetConflict = Uma proje\u00E7\u00E3o deve usar refer\u00EAncias de perten\u00E7a ou uma \u00FAnica refer\u00EAncia de lan\u00E7amento principal, mas n\u00E3o ambas. diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_pt_BR.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_pt_BR.properties index 8fbab2d15e..f663849a27 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_pt_BR.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_pt_BR.properties @@ -554,7 +554,6 @@ LedgerStructuralValidatorEntryDateTimeRequired = A entrada Ledger {0} deve ter d LedgerStructuralValidatorEntryTypeRequired = A entrada Ledger {0} deve ter um tipo de entrada. - LedgerStructuralValidatorExDateSecurityRequired = EX_DATE s\u00F3 pode ser usado em lan\u00E7amentos que referenciam um ativo: {0} LedgerStructuralValidatorLedgerRequired = \u00C9 necess\u00E1rio um objeto Ledger. @@ -583,14 +582,12 @@ LedgerStructuralValidatorPostingSecurityRequired = O lan\u00E7amento de ativo {0 LedgerStructuralValidatorPostingTypeRequired = O lan\u00E7amento Ledger {0} deve ter um tipo de lan\u00E7amento. - LedgerStructuralValidatorPrimaryPostingRefNotFound = A refer\u00EAncia de lan\u00E7amento principal {0} deve apontar para um lan\u00E7amento na mesma entrada. LedgerStructuralValidatorProjectionAccountRequired = A proje\u00E7\u00E3o Ledger {0} deve referenciar uma conta. LedgerStructuralValidatorProjectionGroupTargetConflict = Uma proje\u00E7\u00E3o deve usar refer\u00EAncias de perten\u00E7a ou uma \u00FAnica refer\u00EAncia de grupo de lan\u00E7amentos, mas n\u00E3o ambas. - LedgerStructuralValidatorProjectionPortfolioRequired = A proje\u00E7\u00E3o Ledger {0} deve referenciar uma conta de ativos. LedgerStructuralValidatorProjectionPrimaryTargetConflict = Uma proje\u00E7\u00E3o deve usar refer\u00EAncias de perten\u00E7a ou uma \u00FAnica refer\u00EAncia de lan\u00E7amento principal, mas n\u00E3o ambas. diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_ru.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_ru.properties index 354fd7aabe..cc45db9fc3 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_ru.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_ru.properties @@ -554,7 +554,6 @@ LedgerStructuralValidatorEntryDateTimeRequired = \u0417\u0430\u043F\u0438\u0441\ LedgerStructuralValidatorEntryTypeRequired = \u0417\u0430\u043F\u0438\u0441\u044C Ledger {0} \u0434\u043E\u043B\u0436\u043D\u0430 \u0438\u043C\u0435\u0442\u044C \u0442\u0438\u043F \u0437\u0430\u043F\u0438\u0441\u0438. - LedgerStructuralValidatorExDateSecurityRequired = EX_DATE \u043C\u043E\u0436\u043D\u043E \u0438\u0441\u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u044C \u0442\u043E\u043B\u044C\u043A\u043E \u0432 \u043F\u0440\u043E\u0432\u043E\u0434\u043A\u0430\u0445, \u043A\u043E\u0442\u043E\u0440\u044B\u0435 \u0441\u0441\u044B\u043B\u0430\u044E\u0442\u0441\u044F \u043D\u0430 \u0446\u0435\u043D\u043D\u0443\u044E \u0431\u0443\u043C\u0430\u0433\u0443: {0} LedgerStructuralValidatorLedgerRequired = \u0422\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044F \u043E\u0431\u044A\u0435\u043A\u0442 Ledger. @@ -583,14 +582,12 @@ LedgerStructuralValidatorPostingSecurityRequired = \u041F\u0440\u043E\u0432\u043 LedgerStructuralValidatorPostingTypeRequired = \u041F\u0440\u043E\u0432\u043E\u0434\u043A\u0430 Ledger {0} \u0434\u043E\u043B\u0436\u043D\u0430 \u0438\u043C\u0435\u0442\u044C \u0442\u0438\u043F \u043F\u0440\u043E\u0432\u043E\u0434\u043A\u0438. - LedgerStructuralValidatorPrimaryPostingRefNotFound = \u0421\u0441\u044B\u043B\u043A\u0430 \u043D\u0430 \u043E\u0441\u043D\u043E\u0432\u043D\u0443\u044E \u043F\u0440\u043E\u0432\u043E\u0434\u043A\u0443 {0} \u0434\u043E\u043B\u0436\u043D\u0430 \u0443\u043A\u0430\u0437\u044B\u0432\u0430\u0442\u044C \u043D\u0430 \u043F\u0440\u043E\u0432\u043E\u0434\u043A\u0443 \u0432 \u0442\u043E\u0439 \u0436\u0435 \u0437\u0430\u043F\u0438\u0441\u0438. LedgerStructuralValidatorProjectionAccountRequired = \u041F\u0440\u043E\u0435\u043A\u0446\u0438\u044F Ledger {0} \u0434\u043E\u043B\u0436\u043D\u0430 \u0441\u0441\u044B\u043B\u0430\u0442\u044C\u0441\u044F \u043D\u0430 \u0441\u0447\u0435\u0442. LedgerStructuralValidatorProjectionGroupTargetConflict = \u041F\u0440\u043E\u0435\u043A\u0446\u0438\u044F \u0434\u043E\u043B\u0436\u043D\u0430 \u0438\u0441\u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u044C \u043B\u0438\u0431\u043E \u0441\u0441\u044B\u043B\u043A\u0438 \u0447\u043B\u0435\u043D\u0441\u0442\u0432\u0430, \u043B\u0438\u0431\u043E \u043E\u0434\u043D\u0443 \u0441\u0441\u044B\u043B\u043A\u0443 \u043D\u0430 \u0433\u0440\u0443\u043F\u043F\u0443 \u043F\u0440\u043E\u0432\u043E\u0434\u043E\u043A, \u043D\u043E \u043D\u0435 \u043E\u0431\u0430 \u0432\u0430\u0440\u0438\u0430\u043D\u0442\u0430. - LedgerStructuralValidatorProjectionPortfolioRequired = \u041F\u0440\u043E\u0435\u043A\u0446\u0438\u044F Ledger {0} \u0434\u043E\u043B\u0436\u043D\u0430 \u0441\u0441\u044B\u043B\u0430\u0442\u044C\u0441\u044F \u043D\u0430 \u0441\u0447\u0435\u0442 \u0446\u0435\u043D\u043D\u044B\u0445 \u0431\u0443\u043C\u0430\u0433. LedgerStructuralValidatorProjectionPrimaryTargetConflict = \u041F\u0440\u043E\u0435\u043A\u0446\u0438\u044F \u0434\u043E\u043B\u0436\u043D\u0430 \u0438\u0441\u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u044C \u043B\u0438\u0431\u043E \u0441\u0441\u044B\u043B\u043A\u0438 \u0447\u043B\u0435\u043D\u0441\u0442\u0432\u0430, \u043B\u0438\u0431\u043E \u043E\u0434\u043D\u0443 \u0441\u0441\u044B\u043B\u043A\u0443 \u043D\u0430 \u043E\u0441\u043D\u043E\u0432\u043D\u0443\u044E \u043F\u0440\u043E\u0432\u043E\u0434\u043A\u0443, \u043D\u043E \u043D\u0435 \u043E\u0431\u0430 \u0432\u0430\u0440\u0438\u0430\u043D\u0442\u0430. diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_sk.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_sk.properties index 001b14abf1..9ae96aaf14 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_sk.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_sk.properties @@ -556,7 +556,6 @@ LedgerStructuralValidatorEntryDateTimeRequired = Z\u00E1znam Ledger {0} mus\u00E LedgerStructuralValidatorEntryTypeRequired = Z\u00E1znam Ledger {0} mus\u00ED ma\u0165 typ z\u00E1znamu. - LedgerStructuralValidatorExDateSecurityRequired = EX_DATE mo\u017Eno pou\u017Ei\u0165 iba pri polo\u017Ek\u00E1ch, ktor\u00E9 odkazuj\u00FA na cenn\u00FD papier: {0} LedgerStructuralValidatorLedgerRequired = Vy\u017Eaduje sa objekt Ledger. @@ -585,14 +584,12 @@ LedgerStructuralValidatorPostingSecurityRequired = Polo\u017Eka cenn\u00E9ho pap LedgerStructuralValidatorPostingTypeRequired = Polo\u017Eka Ledger {0} mus\u00ED ma\u0165 typ polo\u017Eky. - LedgerStructuralValidatorPrimaryPostingRefNotFound = Odkaz na prim\u00E1rnu polo\u017Eku {0} mus\u00ED ukazova\u0165 na polo\u017Eku v tom istom z\u00E1zname. LedgerStructuralValidatorProjectionAccountRequired = Projekcia Ledger {0} mus\u00ED odkazova\u0165 na \u00FA\u010Det. LedgerStructuralValidatorProjectionGroupTargetConflict = Projekcia mus\u00ED pou\u017Ei\u0165 bu\u010F \u010Dlensk\u00E9 odkazy, alebo jeden odkaz na skupinu polo\u017Eiek, nie oboje. - LedgerStructuralValidatorProjectionPortfolioRequired = Projekcia Ledger {0} mus\u00ED odkazova\u0165 na \u00FA\u010Det cenn\u00FDch papierov. LedgerStructuralValidatorProjectionPrimaryTargetConflict = Projekcia mus\u00ED pou\u017Ei\u0165 bu\u010F \u010Dlensk\u00E9 odkazy, alebo jeden odkaz na prim\u00E1rnu polo\u017Eku, nie oboje. diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_tr.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_tr.properties index 04ded352c4..f51d90ce89 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_tr.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_tr.properties @@ -553,7 +553,6 @@ LedgerStructuralValidatorEntryDateTimeRequired = Ledger kayd\u0131 {0} tarih ve LedgerStructuralValidatorEntryTypeRequired = Ledger kayd\u0131 {0} bir kay\u0131t t\u00FCr\u00FCne sahip olmal\u0131d\u0131r. - LedgerStructuralValidatorExDateSecurityRequired = EX_DATE yaln\u0131zca bir menkul k\u0131ymete ba\u015Fvuran kay\u0131tlarda kullan\u0131labilir: {0} LedgerStructuralValidatorLedgerRequired = Bir Ledger nesnesi gereklidir. @@ -582,14 +581,12 @@ LedgerStructuralValidatorPostingSecurityRequired = Menkul k\u0131ymet kayd\u0131 LedgerStructuralValidatorPostingTypeRequired = Ledger kayd\u0131 {0} bir kay\u0131t t\u00FCr\u00FCne sahip olmal\u0131d\u0131r. - LedgerStructuralValidatorPrimaryPostingRefNotFound = Birincil kay\u0131t ba\u015Fvurusu {0}, ayn\u0131 giri\u015Fteki bir kayd\u0131 g\u00F6stermelidir. LedgerStructuralValidatorProjectionAccountRequired = Ledger projeksiyonu {0} bir hesaba ba\u015Fvurmal\u0131d\u0131r. LedgerStructuralValidatorProjectionGroupTargetConflict = Bir projeksiyon ya \u00FCyelik ba\u015Fvurular\u0131 ya da tek bir kay\u0131t grubu ba\u015Fvurusu kullanmal\u0131d\u0131r, ikisini birden de\u011Fil. - LedgerStructuralValidatorProjectionPortfolioRequired = Ledger projeksiyonu {0} bir menkul k\u0131ymet hesab\u0131na ba\u015Fvurmal\u0131d\u0131r. LedgerStructuralValidatorProjectionPrimaryTargetConflict = Bir projeksiyon ya \u00FCyelik ba\u015Fvurular\u0131 ya da tek bir birincil kay\u0131t ba\u015Fvurusu kullanmal\u0131d\u0131r, ikisini birden de\u011Fil. diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_vi.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_vi.properties index 3cb9998ce0..4347a8519d 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_vi.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_vi.properties @@ -553,7 +553,6 @@ LedgerStructuralValidatorEntryDateTimeRequired = B\u1EA3n ghi Ledger {0} ph\u1EA LedgerStructuralValidatorEntryTypeRequired = B\u1EA3n ghi Ledger {0} ph\u1EA3i c\u00F3 lo\u1EA1i b\u1EA3n ghi. - LedgerStructuralValidatorExDateSecurityRequired = EX_DATE ch\u1EC9 c\u00F3 th\u1EC3 d\u00F9ng tr\u00EAn c\u00E1c b\u00FAt to\u00E1n tham chi\u1EBFu \u0111\u1EBFn ch\u1EE9ng kho\u00E1n: {0} LedgerStructuralValidatorLedgerRequired = C\u1EA7n c\u00F3 \u0111\u1ED1i t\u01B0\u1EE3ng Ledger. @@ -582,14 +581,12 @@ LedgerStructuralValidatorPostingSecurityRequired = B\u00FAt to\u00E1n ch\u1EE9ng LedgerStructuralValidatorPostingTypeRequired = B\u00FAt to\u00E1n Ledger {0} ph\u1EA3i c\u00F3 lo\u1EA1i b\u00FAt to\u00E1n. - LedgerStructuralValidatorPrimaryPostingRefNotFound = Tham chi\u1EBFu b\u00FAt to\u00E1n ch\u00EDnh {0} ph\u1EA3i tr\u1ECF \u0111\u1EBFn m\u1ED9t b\u00FAt to\u00E1n trong c\u00F9ng b\u1EA3n ghi. LedgerStructuralValidatorProjectionAccountRequired = Ph\u00E9p chi\u1EBFu Ledger {0} ph\u1EA3i tham chi\u1EBFu \u0111\u1EBFn t\u00E0i kho\u1EA3n. LedgerStructuralValidatorProjectionGroupTargetConflict = M\u1ED9t ph\u00E9p chi\u1EBFu ph\u1EA3i d\u00F9ng tham chi\u1EBFu th\u00E0nh vi\u00EAn ho\u1EB7c m\u1ED9t tham chi\u1EBFu nh\u00F3m b\u00FAt to\u00E1n duy nh\u1EA5t, kh\u00F4ng d\u00F9ng c\u1EA3 hai. - LedgerStructuralValidatorProjectionPortfolioRequired = Ph\u00E9p chi\u1EBFu Ledger {0} ph\u1EA3i tham chi\u1EBFu \u0111\u1EBFn t\u00E0i kho\u1EA3n ch\u1EE9ng kho\u00E1n. LedgerStructuralValidatorProjectionPrimaryTargetConflict = M\u1ED9t ph\u00E9p chi\u1EBFu ph\u1EA3i d\u00F9ng tham chi\u1EBFu th\u00E0nh vi\u00EAn ho\u1EB7c m\u1ED9t tham chi\u1EBFu b\u00FAt to\u00E1n ch\u00EDnh duy nh\u1EA5t, kh\u00F4ng d\u00F9ng c\u1EA3 hai. diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_zh.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_zh.properties index 775887fa19..e928c107a9 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_zh.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_zh.properties @@ -556,7 +556,6 @@ LedgerStructuralValidatorEntryDateTimeRequired = Ledger \u6761\u76EE {0} \u5FC5\ LedgerStructuralValidatorEntryTypeRequired = Ledger \u6761\u76EE {0} \u5FC5\u987B\u5177\u6709\u6761\u76EE\u7C7B\u578B\u3002 - LedgerStructuralValidatorExDateSecurityRequired = EX_DATE \u53EA\u80FD\u7528\u4E8E\u5F15\u7528\u8BC1\u5238\u7684\u8FC7\u8D26\uFF1A{0} LedgerStructuralValidatorLedgerRequired = \u9700\u8981 Ledger \u5BF9\u8C61\u3002 @@ -585,14 +584,12 @@ LedgerStructuralValidatorPostingSecurityRequired = \u8BC1\u5238\u8FC7\u8D26 {0} LedgerStructuralValidatorPostingTypeRequired = Ledger \u8FC7\u8D26 {0} \u5FC5\u987B\u5177\u6709\u8FC7\u8D26\u7C7B\u578B\u3002 - LedgerStructuralValidatorPrimaryPostingRefNotFound = \u4E3B\u8FC7\u8D26\u5F15\u7528 {0} \u5FC5\u987B\u6307\u5411\u540C\u4E00\u6761\u76EE\u4E2D\u7684\u8FC7\u8D26\u3002 LedgerStructuralValidatorProjectionAccountRequired = Ledger \u6295\u5F71 {0} \u5FC5\u987B\u5F15\u7528\u8D26\u6237\u3002 LedgerStructuralValidatorProjectionGroupTargetConflict = \u6295\u5F71\u5FC5\u987B\u4F7F\u7528\u6210\u5458\u5F15\u7528\u6216\u5355\u4E2A\u8FC7\u8D26\u7EC4\u5F15\u7528\uFF0C\u4E0D\u80FD\u540C\u65F6\u4F7F\u7528\u4E24\u8005\u3002 - LedgerStructuralValidatorProjectionPortfolioRequired = Ledger \u6295\u5F71 {0} \u5FC5\u987B\u5F15\u7528\u8BC1\u5238\u8D26\u6237\u3002 LedgerStructuralValidatorProjectionPrimaryTargetConflict = \u6295\u5F71\u5FC5\u987B\u4F7F\u7528\u6210\u5458\u5F15\u7528\u6216\u5355\u4E2A\u4E3B\u8FC7\u8D26\u5F15\u7528\uFF0C\u4E0D\u80FD\u540C\u65F6\u4F7F\u7528\u4E24\u8005\u3002 diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_zh_TW.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_zh_TW.properties index 380de66b54..d17fd45498 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_zh_TW.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages_zh_TW.properties @@ -556,7 +556,6 @@ LedgerStructuralValidatorEntryDateTimeRequired = Ledger \u689D\u76EE {0} \u5FC5\ LedgerStructuralValidatorEntryTypeRequired = Ledger \u689D\u76EE {0} \u5FC5\u987B\u5177\u6709\u689D\u76EE\u7C7B\u578B\u3002 - LedgerStructuralValidatorExDateSecurityRequired = EX_DATE \u53EA\u80FD\u7528\u4E8E\u53C3\u7167\u8B49\u5238\u7684\u904E\u5E33\uFF1A{0} LedgerStructuralValidatorLedgerRequired = \u9700\u8981 Ledger \u5BF9\u8C61\u3002 @@ -585,14 +584,12 @@ LedgerStructuralValidatorPostingSecurityRequired = \u8B49\u5238\u904E\u5E33 {0} LedgerStructuralValidatorPostingTypeRequired = Ledger \u904E\u5E33 {0} \u5FC5\u987B\u5177\u6709\u904E\u5E33\u7C7B\u578B\u3002 - LedgerStructuralValidatorPrimaryPostingRefNotFound = \u4E3B\u904E\u5E33\u53C3\u7167 {0} \u5FC5\u987B\u6307\u5411\u540C\u4E00\u689D\u76EE\u4E2D\u7684\u904E\u5E33\u3002 LedgerStructuralValidatorProjectionAccountRequired = Ledger \u6295\u5F71 {0} \u5FC5\u987B\u53C3\u7167\u5E33\u6236\u3002 LedgerStructuralValidatorProjectionGroupTargetConflict = \u6295\u5F71\u5FC5\u987B\u4F7F\u7528\u6210\u54E1\u53C3\u7167\u6216\u5355\u4E2A\u904E\u5E33\u7EC4\u53C3\u7167\uFF0C\u4E0D\u80FD\u540C\u65F6\u4F7F\u7528\u4E24\u8005\u3002 - LedgerStructuralValidatorProjectionPortfolioRequired = Ledger \u6295\u5F71 {0} \u5FC5\u987B\u53C3\u7167\u8B49\u5238\u5E33\u6236\u3002 LedgerStructuralValidatorProjectionPrimaryTargetConflict = \u6295\u5F71\u5FC5\u987B\u4F7F\u7528\u6210\u54E1\u53C3\u7167\u6216\u5355\u4E2A\u4E3B\u904E\u5E33\u53C3\u7167\uFF0C\u4E0D\u80FD\u540C\u65F6\u4F7F\u7528\u4E24\u8005\u3002 From e6d7871330c4fca7b1afc2232426d9699df4e887 Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:39:22 +0200 Subject: [PATCH 27/68] Restore ClientFactory direct save path Restored ClientFactory save handling to write and lock the target file directly instead of writing a temporary sibling file and moving it over the target. This keeps the application save boundary close to the original Portfolio Performance behavior while preserving the Ledger no-UUID serialization work behind the existing persister calls. Ledger XML/protobuf schemas, Ledger model truth, migration diagnostics, InvestmentPlan linkage, UI/import code, and tests were intentionally left unchanged. --- .../portfolio/model/ClientFactory.java | 83 ++++++------------- 1 file changed, 26 insertions(+), 57 deletions(-) diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ClientFactory.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ClientFactory.java index 5783178e25..e1691d95f9 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ClientFactory.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ClientFactory.java @@ -20,10 +20,6 @@ import java.nio.channels.FileChannel; import java.nio.channels.FileLock; import java.nio.charset.StandardCharsets; -import java.nio.file.AtomicMoveNotSupportedException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardCopyOption; import java.security.AlgorithmParameters; import java.security.GeneralSecurityException; import java.security.NoSuchAlgorithmException; @@ -1308,74 +1304,47 @@ private static void writeFile(final Client client, final File file, char[] passw { PortfolioLog.info(String.format("Saving %s with %s", file.getName(), flags.toString())); //$NON-NLS-1$ - var target = file.toPath(); - var directory = target.toAbsolutePath().getParent(); - var tempFile = Files.createTempFile(directory, "portfolio-save-", ".tmp"); //$NON-NLS-1$ //$NON-NLS-2$ - var moved = false; - // open an output stream for the file using a 64 KB buffer to speed up // writing - try + try (FileOutputStream stream = new FileOutputStream(file); + BufferedOutputStream output = new BufferedOutputStream(stream, 65536)) { - try (FileOutputStream stream = new FileOutputStream(tempFile.toFile()); - BufferedOutputStream output = new BufferedOutputStream(stream, 65536)) + // lock file while writing (apparently network-attache storage is + // garbling up the files if it already starts syncing while the file + // is still being written) + FileChannel channel = stream.getChannel(); + FileLock lock = null; + + try { - // lock file while writing (apparently network-attache storage is - // garbling up the files if it already starts syncing while the file - // is still being written) - FileChannel channel = stream.getChannel(); - FileLock lock = null; + // On OS X fcntl does not support locking files on AFP or SMB + // https://bugs.openjdk.org/browse/JDK-8167023 + if (!Platform.getOS().equals(Platform.OS_MACOSX)) + lock = channel.tryLock(); + } + catch (IOException e) + { + // also on some other platforms (for example reported for Linux + // Mint, locks are not supported on SMB shares) - try - { - // On OS X fcntl does not support locking files on AFP or SMB - // https://bugs.openjdk.org/browse/JDK-8167023 - if (!Platform.OS_MACOSX.equals(Platform.getOS())) - lock = channel.tryLock(); - } - catch (IOException e) - { - // also on some other platforms (for example reported for Linux - // Mint, locks are not supported on SMB shares) + PortfolioLog.warning(MessageFormat.format("Failed to acquire lock {0} with message {1}", //$NON-NLS-1$ + file.getAbsolutePath(), e.getMessage())); + } - PortfolioLog.warning(MessageFormat.format("Failed to acquire lock {0} with message {1}", //$NON-NLS-1$ - tempFile.toAbsolutePath(), e.getMessage())); - } + ClientPersister persister = buildPersister(flags, password); + persister.save(client, output); - ClientPersister persister = buildPersister(flags, password); - persister.save(client, output); + output.flush(); - output.flush(); + if (lock != null && lock.isValid()) + lock.release(); - if (lock != null && lock.isValid()) - lock.release(); - } - - moveSavedFile(tempFile, target); - moved = true; if (updateFlags) { client.getSaveFlags().clear(); client.getSaveFlags().addAll(flags); } } - finally - { - if (!moved) - Files.deleteIfExists(tempFile); - } - } - - private static void moveSavedFile(Path tempFile, Path target) throws IOException - { - try - { - Files.move(tempFile, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); - } - catch (AtomicMoveNotSupportedException e) - { - Files.move(tempFile, target, StandardCopyOption.REPLACE_EXISTING); - } } private static ClientPersister buildPersister(Set flags, char[] password) From 19e5e92df5ba88b6016d8fc7f9023d61430f2638 Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:55:14 +0200 Subject: [PATCH 28/68] Wrap Ledger XML persistence support Moved the Ledger XML converters, load initialization, save validation, and temporary owner-list save state out of ClientFactory into LedgerXmlPersistenceSupport. This keeps ClientFactory closer to the original XML persistence boundary while retaining the no-UUID Ledger XML behavior behind a narrow helper. File-save mechanics, protobuf persistence, Ledger model semantics, migration diagnostics, InvestmentPlan behavior, UI/import paths, and XML schema output were intentionally left unchanged. --- .../portfolio/model/ClientFactory.java | 637 +---------------- .../model/LedgerXmlPersistenceSupport.java | 655 ++++++++++++++++++ 2 files changed, 658 insertions(+), 634 deletions(-) create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerXmlPersistenceSupport.java diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ClientFactory.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ClientFactory.java index e1691d95f9..fb9f516eea 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ClientFactory.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ClientFactory.java @@ -67,43 +67,17 @@ import com.google.common.base.Strings; import com.thoughtworks.xstream.XStream; import com.thoughtworks.xstream.XStreamException; -import com.thoughtworks.xstream.converters.Converter; -import com.thoughtworks.xstream.converters.MarshallingContext; -import com.thoughtworks.xstream.converters.UnmarshallingContext; import com.thoughtworks.xstream.converters.collections.MapConverter; import com.thoughtworks.xstream.converters.reflection.ReflectionConverter; import com.thoughtworks.xstream.converters.reflection.ReflectionProvider; -import com.thoughtworks.xstream.io.HierarchicalStreamReader; -import com.thoughtworks.xstream.io.HierarchicalStreamWriter; import com.thoughtworks.xstream.mapper.Mapper; import name.abuchen.portfolio.Messages; import name.abuchen.portfolio.PortfolioLog; import name.abuchen.portfolio.model.AttributeType.ImageConverter; import name.abuchen.portfolio.model.Classification.Assignment; -import name.abuchen.portfolio.model.InvestmentPlan.LedgerExecutionRef; -import name.abuchen.portfolio.model.InvestmentPlan.LedgerExecutionViewKind; import name.abuchen.portfolio.model.PortfolioTransaction.Type; import name.abuchen.portfolio.model.Transaction.Unit; -import name.abuchen.portfolio.model.ledger.Ledger; -import name.abuchen.portfolio.model.ledger.LedgerDiagnosticMessageFormatter; -import name.abuchen.portfolio.model.ledger.LedgerEntry; -import name.abuchen.portfolio.model.ledger.LedgerParameter; -import name.abuchen.portfolio.model.ledger.LedgerParameter.ValueKind; -import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerPostingDirection; -import name.abuchen.portfolio.model.ledger.LedgerPostingSemanticRole; -import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; -import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; -import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; -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.model.ledger.legacy.LedgerMigrationDiagnostics; -import name.abuchen.portfolio.model.ledger.legacy.LegacyTransactionToLedgerMigrator; -import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; -import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionService; import name.abuchen.portfolio.money.CurrencyUnit; import name.abuchen.portfolio.money.Money; import name.abuchen.portfolio.money.Values; @@ -142,453 +116,6 @@ protected boolean shouldUnmarshalField(Field field) } - private static Optional readAttribute(HierarchicalStreamReader reader, String name) - { - return Optional.ofNullable(reader.getAttribute(name)); - } - - private static void writeAttribute(HierarchicalStreamWriter writer, String name, Object value) - { - if (value != null) - writer.addAttribute(name, String.valueOf(value)); - } - - private static void writeValue(HierarchicalStreamWriter writer, String nodeName, String value) - { - if (value == null) - return; - - writer.startNode(nodeName); - writer.setValue(value); - writer.endNode(); - } - - private static void writeObject(HierarchicalStreamWriter writer, MarshallingContext context, String nodeName, - Object value) - { - if (value == null) - return; - - writer.startNode(nodeName); - context.convertAnother(value); - writer.endNode(); - } - - private static void writeCollection(HierarchicalStreamWriter writer, MarshallingContext context, - String collectionNodeName, String itemNodeName, List values) - { - writer.startNode(collectionNodeName); - - for (var value : values) - writeObject(writer, context, itemNodeName, value); - - writer.endNode(); - } - - private static void writeParameters(HierarchicalStreamWriter writer, MarshallingContext context, - List> parameters) - { - if (!parameters.isEmpty()) - writeCollection(writer, context, "parameters", "ledger-parameter", parameters); //$NON-NLS-1$ //$NON-NLS-2$ - } - - private static class LedgerEntryConverter implements Converter - { - @Override - public boolean canConvert(@SuppressWarnings("rawtypes") Class type) - { - return type == LedgerEntry.class; - } - - @Override - public void marshal(Object source, HierarchicalStreamWriter writer, MarshallingContext context) - { - var entry = (LedgerEntry) source; - - writeAttribute(writer, "type", entry.getType()); //$NON-NLS-1$ - writeAttribute(writer, "dateTime", entry.getDateTime()); //$NON-NLS-1$ - writeAttribute(writer, "updatedAt", entry.getUpdatedAt()); //$NON-NLS-1$ - writeValue(writer, "note", entry.getNote()); //$NON-NLS-1$ - writeValue(writer, "source", entry.getSource()); //$NON-NLS-1$ - writeValue(writer, "generatedByPlanKey", entry.getGeneratedByPlanKey()); //$NON-NLS-1$ - writeValue(writer, "planExecutionDate", //$NON-NLS-1$ - entry.getPlanExecutionDate() != null ? String.valueOf(entry.getPlanExecutionDate()) - : null); - writeValue(writer, "planExecutionSequence", //$NON-NLS-1$ - entry.getPlanExecutionSequence() != null - ? String.valueOf(entry.getPlanExecutionSequence()) - : null); - writeValue(writer, "preferredViewKind", entry.getPreferredViewKind()); //$NON-NLS-1$ - writeParameters(writer, context, entry.getParameters()); - writeCollection(writer, context, "postings", "ledger-posting", entry.getPostings()); //$NON-NLS-1$ //$NON-NLS-2$ - } - - @Override - public Object unmarshal(HierarchicalStreamReader reader, UnmarshallingContext context) - { - var entry = new LedgerEntry(); - var updatedAt = reader.getAttribute("updatedAt"); //$NON-NLS-1$ - - readAttribute(reader, "uuid").ifPresent(entry::setUUID); //$NON-NLS-1$ - readAttribute(reader, "type").map(LedgerEntryType::valueOf).ifPresent(entry::setType); //$NON-NLS-1$ - readAttribute(reader, "dateTime").map(LocalDateTime::parse).ifPresent(entry::setDateTime); //$NON-NLS-1$ - - while (reader.hasMoreChildren()) - { - reader.moveDown(); - - switch (reader.getNodeName()) - { - case "uuid" -> entry.setUUID(reader.getValue()); //$NON-NLS-1$ - case "type" -> entry.setType((LedgerEntryType) context.convertAnother(entry, //$NON-NLS-1$ - LedgerEntryType.class)); - case "dateTime" -> entry.setDateTime((LocalDateTime) context.convertAnother(entry, //$NON-NLS-1$ - LocalDateTime.class)); - case "updatedAt" -> updatedAt = reader.getValue(); //$NON-NLS-1$ - case "note" -> entry.setNote(reader.getValue()); //$NON-NLS-1$ - case "source" -> entry.setSource(reader.getValue()); //$NON-NLS-1$ - case "generatedByPlanKey" -> entry.setGeneratedByPlanKey(reader.getValue()); //$NON-NLS-1$ - case "planExecutionDate" -> entry.setPlanExecutionDate(LocalDate.parse(reader.getValue())); //$NON-NLS-1$ - case "planExecutionSequence" -> entry.setPlanExecutionSequence( - Integer.valueOf(reader.getValue())); //$NON-NLS-1$ - case "preferredViewKind" -> entry.setPreferredViewKind(reader.getValue()); //$NON-NLS-1$ - case "parameters" -> readParameters(reader, context, entry); //$NON-NLS-1$ - case "postings" -> readPostings(reader, context, entry); //$NON-NLS-1$ - case "projectionRefs" -> skipChildren(reader); //$NON-NLS-1$ - default -> { - // Ignore unknown LedgerEntry fields to preserve load recovery behavior. - } - } - - reader.moveUp(); - } - - if (updatedAt != null) - entry.setUpdatedAt(Instant.parse(updatedAt)); - - return entry; - } - - private void readParameters(HierarchicalStreamReader reader, UnmarshallingContext context, LedgerEntry entry) - { - while (reader.hasMoreChildren()) - { - reader.moveDown(); - entry.addParameter((LedgerParameter) context.convertAnother(entry, LedgerParameter.class)); - reader.moveUp(); - } - } - - private void readPostings(HierarchicalStreamReader reader, UnmarshallingContext context, LedgerEntry entry) - { - while (reader.hasMoreChildren()) - { - reader.moveDown(); - entry.addPosting((LedgerPosting) context.convertAnother(entry, LedgerPosting.class)); - reader.moveUp(); - } - } - - private void skipChildren(HierarchicalStreamReader reader) - { - while (reader.hasMoreChildren()) - { - reader.moveDown(); - skipChildren(reader); - reader.moveUp(); - } - } - } - - private static class LedgerPostingConverter implements Converter - { - @Override - public boolean canConvert(@SuppressWarnings("rawtypes") Class type) - { - return type == LedgerPosting.class; - } - - @Override - public void marshal(Object source, HierarchicalStreamWriter writer, MarshallingContext context) - { - var posting = (LedgerPosting) source; - - writeAttribute(writer, "type", posting.getType()); //$NON-NLS-1$ - writeAttribute(writer, "amount", posting.getAmount()); //$NON-NLS-1$ - writeAttribute(writer, "currency", posting.getCurrency()); //$NON-NLS-1$ - writeAttribute(writer, "forexAmount", posting.getForexAmount()); //$NON-NLS-1$ - writeAttribute(writer, "forexCurrency", posting.getForexCurrency()); //$NON-NLS-1$ - writeAttribute(writer, "exchangeRate", posting.getExchangeRate()); //$NON-NLS-1$ - writeAttribute(writer, "shares", posting.getShares()); //$NON-NLS-1$ - writeAttribute(writer, "semanticRole", posting.getSemanticRole()); //$NON-NLS-1$ - writeAttribute(writer, "direction", posting.getDirection()); //$NON-NLS-1$ - writeAttribute(writer, "corporateActionLeg", posting.getCorporateActionLeg()); //$NON-NLS-1$ - writeAttribute(writer, "unitRole", posting.getUnitRole()); //$NON-NLS-1$ - writeAttribute(writer, "groupKey", posting.getGroupKey()); //$NON-NLS-1$ - writeAttribute(writer, "localKey", posting.getLocalKey()); //$NON-NLS-1$ - writeObject(writer, context, "security", posting.getSecurity()); //$NON-NLS-1$ - writeObject(writer, context, "account", posting.getAccount()); //$NON-NLS-1$ - writeObject(writer, context, "portfolio", posting.getPortfolio()); //$NON-NLS-1$ - writeParameters(writer, context, posting.getParameters()); - } - - @Override - public Object unmarshal(HierarchicalStreamReader reader, UnmarshallingContext context) - { - var posting = new LedgerPosting(); - - readAttribute(reader, "uuid").ifPresent(posting::setUUID); //$NON-NLS-1$ - readAttribute(reader, "type").map(LedgerPostingType::valueOf).ifPresent(posting::setType); //$NON-NLS-1$ - readAttribute(reader, "amount").map(Long::parseLong).ifPresent(posting::setAmount); //$NON-NLS-1$ - readAttribute(reader, "currency").ifPresent(posting::setCurrency); //$NON-NLS-1$ - readAttribute(reader, "forexAmount").map(Long::valueOf).ifPresent(posting::setForexAmount); //$NON-NLS-1$ - readAttribute(reader, "forexCurrency").ifPresent(posting::setForexCurrency); //$NON-NLS-1$ - readAttribute(reader, "exchangeRate").map(BigDecimal::new).ifPresent(posting::setExchangeRate); //$NON-NLS-1$ - readAttribute(reader, "shares").map(Long::parseLong).ifPresent(posting::setShares); //$NON-NLS-1$ - readAttribute(reader, "semanticRole").map(LedgerPostingSemanticRole::valueOf) //$NON-NLS-1$ - .ifPresent(posting::setSemanticRole); - readAttribute(reader, "direction").map(LedgerPostingDirection::valueOf) //$NON-NLS-1$ - .ifPresent(posting::setDirection); - readAttribute(reader, "corporateActionLeg").map(CorporateActionLeg::valueOf) //$NON-NLS-1$ - .ifPresent(posting::setCorporateActionLeg); - readAttribute(reader, "unitRole").map(LedgerPostingUnitRole::valueOf) //$NON-NLS-1$ - .ifPresent(posting::setUnitRole); - readAttribute(reader, "groupKey").ifPresent(posting::setGroupKey); //$NON-NLS-1$ - readAttribute(reader, "localKey").ifPresent(posting::setLocalKey); //$NON-NLS-1$ - - while (reader.hasMoreChildren()) - { - reader.moveDown(); - - switch (reader.getNodeName()) - { - case "uuid" -> posting.setUUID(reader.getValue()); //$NON-NLS-1$ - case "type" -> posting.setType((LedgerPostingType) context.convertAnother(posting, //$NON-NLS-1$ - LedgerPostingType.class)); - case "amount" -> posting.setAmount(Long.parseLong(reader.getValue())); //$NON-NLS-1$ - case "currency" -> posting.setCurrency(reader.getValue()); //$NON-NLS-1$ - case "forexAmount" -> posting.setForexAmount(Long.valueOf(reader.getValue())); //$NON-NLS-1$ - case "forexCurrency" -> posting.setForexCurrency(reader.getValue()); //$NON-NLS-1$ - case "exchangeRate" -> posting.setExchangeRate(new BigDecimal(reader.getValue())); //$NON-NLS-1$ - case "security" -> posting.setSecurity((Security) context.convertAnother(posting, //$NON-NLS-1$ - Security.class)); - case "shares" -> posting.setShares(Long.parseLong(reader.getValue())); //$NON-NLS-1$ - case "account" -> posting.setAccount((Account) context.convertAnother(posting, Account.class)); //$NON-NLS-1$ - case "portfolio" -> posting.setPortfolio((Portfolio) context.convertAnother(posting, //$NON-NLS-1$ - Portfolio.class)); - case "semanticRole" -> posting.setSemanticRole( //$NON-NLS-1$ - (LedgerPostingSemanticRole) context.convertAnother(posting, - LedgerPostingSemanticRole.class)); - case "direction" -> posting.setDirection( //$NON-NLS-1$ - (LedgerPostingDirection) context.convertAnother(posting, - LedgerPostingDirection.class)); - case "corporateActionLeg" -> posting.setCorporateActionLeg( //$NON-NLS-1$ - (CorporateActionLeg) context.convertAnother(posting, CorporateActionLeg.class)); - case "unitRole" -> posting.setUnitRole( //$NON-NLS-1$ - (LedgerPostingUnitRole) context.convertAnother(posting, - LedgerPostingUnitRole.class)); - case "groupKey" -> posting.setGroupKey(reader.getValue()); //$NON-NLS-1$ - case "localKey" -> posting.setLocalKey(reader.getValue()); //$NON-NLS-1$ - case "parameters" -> readParameters(reader, context, posting); //$NON-NLS-1$ - default -> { - // Ignore unknown LedgerPosting fields to preserve load recovery behavior. - } - } - - reader.moveUp(); - } - - return posting; - } - - private void readParameters(HierarchicalStreamReader reader, UnmarshallingContext context, - LedgerPosting posting) - { - while (reader.hasMoreChildren()) - { - reader.moveDown(); - posting.addParameter((LedgerParameter) context.convertAnother(posting, LedgerParameter.class)); - reader.moveUp(); - } - } - } - - private static class LedgerParameterConverter implements Converter - { - @Override - public boolean canConvert(@SuppressWarnings("rawtypes") Class type) - { - return type == LedgerParameter.class; - } - - @Override - public void marshal(Object source, HierarchicalStreamWriter writer, MarshallingContext context) - { - var parameter = (LedgerParameter) source; - - writer.addAttribute("type", parameter.getType().getCode()); //$NON-NLS-1$ - writer.addAttribute("valueKind", parameter.getValueKind().name()); //$NON-NLS-1$ - - switch (parameter.getValueKind()) - { - case STRING, DECIMAL, LONG, BOOLEAN, LOCAL_DATE, LOCAL_DATE_TIME: - writer.addAttribute("value", String.valueOf(parameter.getValue())); //$NON-NLS-1$ - break; - case MONEY: - var money = (Money) parameter.getValue(); - writer.addAttribute("amount", String.valueOf(money.getAmount())); //$NON-NLS-1$ - writer.addAttribute("currency", money.getCurrencyCode()); //$NON-NLS-1$ - break; - case SECURITY, ACCOUNT, PORTFOLIO: - writer.startNode("value"); //$NON-NLS-1$ - context.convertAnother(parameter.getValue()); - writer.endNode(); - break; - default: - throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PERSIST_003 - .message(MessageFormat.format(Messages.LedgerParameterUnsupportedValueKind, - parameter.getValueKind()))); - } - } - - @Override - public Object unmarshal(HierarchicalStreamReader reader, UnmarshallingContext context) - { - LedgerParameterType type = typeOrNull(reader.getAttribute("type")); //$NON-NLS-1$ - ValueKind valueKind = valueKindOrNull(reader.getAttribute("valueKind")); //$NON-NLS-1$ - var scalarValue = reader.getAttribute("value"); //$NON-NLS-1$ - var amount = reader.getAttribute("amount"); //$NON-NLS-1$ - var currency = reader.getAttribute("currency"); //$NON-NLS-1$ - Object parameterValue = null; - - while (reader.hasMoreChildren()) - { - reader.moveDown(); - - switch (reader.getNodeName()) - { - case "type" -> type = typeOrNull(reader.getValue()); //$NON-NLS-1$ - case "valueKind" -> valueKind = valueKindOrNull(reader.getValue()); //$NON-NLS-1$ - case "value" -> parameterValue = readValue(reader, context, valueKind); //$NON-NLS-1$ - default -> { - // Ignore unknown LedgerParameter fields to keep XML load recovery tolerant. - } - } - - reader.moveUp(); - } - - if (type == null) - throw new IllegalArgumentException( - LedgerDiagnosticCode.LEDGER_PERSIST_004.message(Messages.LedgerParameterMissingType)); - - if (valueKind == null) - throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PERSIST_005 - .message(Messages.LedgerParameterMissingValueKind)); - - if (parameterValue == null) - parameterValue = readValue(scalarValue, amount, currency, valueKind); - - return newParameter(type, valueKind, parameterValue); - } - - private LedgerParameterType typeOrNull(String value) - { - if (Strings.isNullOrEmpty(value)) - return null; - - return LedgerParameterType.fromCode(value); - } - - private ValueKind valueKindOrNull(String value) - { - if (Strings.isNullOrEmpty(value)) - return null; - - return ValueKind.valueOf(value); - } - - private Object readValue(HierarchicalStreamReader reader, UnmarshallingContext context, ValueKind valueKind) - { - return switch (valueKind) - { - case STRING, DECIMAL, LONG, BOOLEAN, LOCAL_DATE, LOCAL_DATE_TIME -> readValue(reader.getValue(), - valueKind); - case MONEY -> context.convertAnother(null, Money.class); - case SECURITY -> context.convertAnother(null, Security.class); - case ACCOUNT -> context.convertAnother(null, Account.class); - case PORTFOLIO -> context.convertAnother(null, Portfolio.class); - }; - } - - private Object readValue(String value, String amount, String currency, ValueKind valueKind) - { - return switch (valueKind) - { - case STRING, DECIMAL, LONG, BOOLEAN, LOCAL_DATE, LOCAL_DATE_TIME -> readValue(require(value, "value"), //$NON-NLS-1$ - valueKind); - case MONEY -> Money.of(require(currency, "currency"), Long.parseLong(require(amount, "amount"))); //$NON-NLS-1$ //$NON-NLS-2$ - case SECURITY, ACCOUNT, PORTFOLIO -> throw new IllegalArgumentException( - LedgerDiagnosticCode.LEDGER_PERSIST_006 - .message(Messages.LedgerParameterReferenceValueMissingValueNode)); - }; - } - - private Object readValue(String value, ValueKind valueKind) - { - return switch (valueKind) - { - case STRING -> value; - case DECIMAL -> new BigDecimal(value); - case LONG -> Long.valueOf(value); - case BOOLEAN -> Boolean.valueOf(value); - case LOCAL_DATE -> LocalDate.parse(value); - case LOCAL_DATE_TIME -> localDateTime(value); - case MONEY, SECURITY, ACCOUNT, PORTFOLIO -> throw new IllegalArgumentException( - LedgerDiagnosticCode.LEDGER_PERSIST_007 - .message(MessageFormat.format( - Messages.LedgerParameterValueKindRequiresStructuredValue, - valueKind))); - }; - } - - private LocalDateTime localDateTime(String value) - { - try - { - return LocalDateTime.parse(value); - } - catch (RuntimeException e) - { - return LocalDate.parse(value).atStartOfDay(); - } - } - - private String require(String value, String name) - { - if (value == null) - throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PERSIST_008 - .message(MessageFormat.format(Messages.LedgerParameterMissingAttribute, name))); - - return value; - } - - private LedgerParameter newParameter(LedgerParameterType type, ValueKind valueKind, Object value) - { - try - { - var constructor = LedgerParameter.class.getDeclaredConstructor(LedgerParameterType.class, - ValueKind.class, Object.class); - constructor.setAccessible(true); - return constructor.newInstance(type, valueKind, value); - } - catch (ReflectiveOperationException e) - { - throw new IllegalStateException(e); - } - } - } - /* package */ static class XmlSerialization { private boolean idReferences; @@ -619,7 +146,7 @@ public Client load(Reader input) throws IOException client.getVersion())); upgradeModel(client); - initializeLedgerXmlState(client); + LedgerXmlPersistenceSupport.initializeAfterLoad(client); return client; } @@ -632,138 +159,7 @@ public Client load(Reader input) throws IOException void save(Client client, OutputStream output) throws IOException { Writer writer = new OutputStreamWriter(output, StandardCharsets.UTF_8); - var saveState = new LedgerXmlSaveState(); - - try - { - prepareLedgerXmlSave(client, saveState); - makeXStream(false).toXML(client, writer); - writer.flush(); - } - finally - { - saveState.restore(); - } - } - - private void initializeLedgerXmlState(Client client) throws IOException - { - if (client.getLedger().getEntries().isEmpty()) - { - new LegacyTransactionToLedgerMigrator().migrate(client); - convertPlanTransactionsToLedgerRefs(client); - return; - } - - removeLegacyProjectionShadows(client); - LedgerProjectionService.restoreIfValid(client); - LedgerMigrationDiagnostics.logMixedState(client, "xml-mixed-state"); //$NON-NLS-1$ - } - - private void prepareLedgerXmlSave(Client client, LedgerXmlSaveState saveState) throws IOException - { - validateLedger(client); - - for (var account : client.getAccounts()) - { - saveState.removeLedgerBackedTransactions(account.getTransactions()); - } - - for (var portfolio : client.getPortfolios()) - { - saveState.removeLedgerBackedTransactions(portfolio.getTransactions()); - } - - for (var plan : client.getPlans()) - saveState.replaceLedgerBackedPlanTransactions(plan); - } - - private LedgerStructuralValidator.ValidationResult validateLedger(Client client) throws IOException - { - var result = LedgerStructuralValidator.validate(client.getLedger()); - - if (!result.isOK()) - { - LedgerProjectionService.logSkipped(client.getLedger(), result); - throw new IOException(LedgerDiagnosticCode.LEDGER_PERSIST_001 - .message(MessageFormat.format(Messages.LedgerXmlInvalidLedgerStructure, - LedgerDiagnosticMessageFormatter.formatValidationResult( - client.getLedger(), result)))); - } - - return result; - } - - private void removeLegacyProjectionShadows(Client client) - { - convertPlanTransactionsToLedgerRefs(client); - } - - private void convertPlanTransactionsToLedgerRefs(Client client) - { - // Old unreleased projection-ref shadow rows are no longer matched by - // persisted projection UUIDs. Released legacy plan transactions remain - // available as normal legacy rows. - } - - private LedgerExecutionViewKind viewKind(LedgerProjectionRole role) - { - return switch (role) - { - case ACCOUNT, SOURCE_ACCOUNT, TARGET_ACCOUNT, CASH_COMPENSATION -> LedgerExecutionViewKind.ACCOUNT; - default -> LedgerExecutionViewKind.PORTFOLIO; - }; - } - } - - private static final class LedgerXmlSaveState - { - private final List removedElements = new ArrayList<>(); - - private void removeLedgerBackedTransactions(List transactions) - { - for (var index = transactions.size() - 1; index >= 0; index--) - { - var transaction = transactions.get(index); - - if (transaction instanceof LedgerBackedTransaction) - remove(transactions, index); - } - } - - private void replaceLedgerBackedPlanTransactions(InvestmentPlan plan) - { - for (var index = plan.getTransactions().size() - 1; index >= 0; index--) - { - var transaction = plan.getTransactions().get(index); - - if (transaction instanceof LedgerBackedTransaction ledgerBackedTransaction) - { - plan.markLedgerExecution(ledgerBackedTransaction); - remove(plan.getTransactions(), index); - } - } - } - - @SuppressWarnings("rawtypes") - private void remove(List list, int index) - { - removedElements.add(new RemovedListElement(list, index, list.remove(index))); - } - - private void restore() - { - for (var index = removedElements.size() - 1; index >= 0; index--) - removedElements.get(index).restore(); - } - } - - private record RemovedListElement(@SuppressWarnings("rawtypes") List list, int index, Object element) - { - @SuppressWarnings("unchecked") - private void restore() - { - list.add(index, element); + LedgerXmlPersistenceSupport.save(client, makeXStream(false), writer); } } @@ -2481,8 +1877,6 @@ private static XStream xstreamFactory() var xstream = new XStream(); xstream.allowTypesByWildcard(new String[] { "name.abuchen.portfolio.model.**" }); - xstream.allowTypes(new Class[] { Money.class }); - xstream.setClassLoader(ClientFactory.class.getClassLoader()); // because we introduced LocalDate and LocalDateTime before Xstream @@ -2497,9 +1891,6 @@ private static XStream xstreamFactory() xstream.registerConverter(new XStreamSecurityPriceConverter()); xstream.registerConverter( new PortfolioTransactionConverter(xstream.getMapper(), xstream.getReflectionProvider())); - xstream.registerConverter(new LedgerEntryConverter()); - xstream.registerConverter(new LedgerPostingConverter()); - xstream.registerConverter(new LedgerParameterConverter()); xstream.registerConverter(new MapConverter(xstream.getMapper(), TypedMap.class)); xstream.registerConverter(new XStreamArrayListConverter(xstream.getMapper())); @@ -2517,35 +1908,13 @@ private static XStream xstreamFactory() xstream.useAttributeFor(Transaction.Unit.class, "type"); xstream.alias("account-transaction", AccountTransaction.class); xstream.alias("portfolio-transaction", PortfolioTransaction.class); - xstream.alias("ledger", Ledger.class); - xstream.alias("ledger-entry", LedgerEntry.class); - xstream.useAttributeFor(LedgerEntry.class, "uuid"); - xstream.useAttributeFor(LedgerEntry.class, "type"); - xstream.useAttributeFor(LedgerEntry.class, "dateTime"); - xstream.useAttributeFor(LedgerEntry.class, "updatedAt"); - xstream.alias("ledger-posting", LedgerPosting.class); - xstream.useAttributeFor(LedgerPosting.class, "uuid"); - xstream.useAttributeFor(LedgerPosting.class, "type"); - xstream.useAttributeFor(LedgerPosting.class, "amount"); - xstream.useAttributeFor(LedgerPosting.class, "currency"); - xstream.useAttributeFor(LedgerPosting.class, "forexAmount"); - xstream.useAttributeFor(LedgerPosting.class, "forexCurrency"); - xstream.useAttributeFor(LedgerPosting.class, "exchangeRate"); - xstream.useAttributeFor(LedgerPosting.class, "shares"); - xstream.alias("ledger-posting-parameter", LedgerParameter.class); - xstream.alias("ledger-posting-parameter-type", LedgerParameterType.class); - xstream.alias("ledger-parameter", LedgerParameter.class); - xstream.alias("ledger-parameter-type", LedgerParameterType.class); - xstream.alias("ledger-entry-type", LedgerEntryType.class); - xstream.alias("ledger-posting-type", LedgerPostingType.class); - xstream.alias("ledger-projection-role", LedgerProjectionRole.class); + LedgerXmlPersistenceSupport.configureXStream(xstream); xstream.alias("security", Security.class); xstream.addImplicitCollection(Security.class, "properties"); xstream.alias("latest", LatestSecurityPrice.class); xstream.alias("category", Category.class); // NOSONAR xstream.alias("watchlist", Watchlist.class); xstream.alias("investment-plan", InvestmentPlan.class); - xstream.alias("ledger-execution-ref", LedgerExecutionRef.class); xstream.alias("attribute-type", AttributeType.class); xstream.alias("price", SecurityPrice.class); diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerXmlPersistenceSupport.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerXmlPersistenceSupport.java new file mode 100644 index 0000000000..1771c1a26d --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerXmlPersistenceSupport.java @@ -0,0 +1,655 @@ +package name.abuchen.portfolio.model; + +import java.io.IOException; +import java.io.Writer; +import java.math.BigDecimal; +import java.text.MessageFormat; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import com.google.common.base.Strings; +import com.thoughtworks.xstream.XStream; +import com.thoughtworks.xstream.converters.Converter; +import com.thoughtworks.xstream.converters.MarshallingContext; +import com.thoughtworks.xstream.converters.UnmarshallingContext; +import com.thoughtworks.xstream.io.HierarchicalStreamReader; +import com.thoughtworks.xstream.io.HierarchicalStreamWriter; + +import name.abuchen.portfolio.Messages; +import name.abuchen.portfolio.model.ledger.Ledger; +import name.abuchen.portfolio.model.ledger.LedgerDiagnosticMessageFormatter; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerParameter; +import name.abuchen.portfolio.model.ledger.LedgerParameter.ValueKind; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerPostingDirection; +import name.abuchen.portfolio.model.ledger.LedgerPostingSemanticRole; +import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; +import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; +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.model.ledger.legacy.LedgerMigrationDiagnostics; +import name.abuchen.portfolio.model.ledger.legacy.LegacyTransactionToLedgerMigrator; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionService; +import name.abuchen.portfolio.money.Money; + +public final class LedgerXmlPersistenceSupport +{ + private LedgerXmlPersistenceSupport() + { + } + + @SuppressWarnings("nls") + public static void configureXStream(XStream xstream) + { + xstream.allowTypes(new Class[] { Money.class }); + + xstream.registerConverter(new LedgerEntryConverter()); + xstream.registerConverter(new LedgerPostingConverter()); + xstream.registerConverter(new LedgerParameterConverter()); + + xstream.alias("ledger", Ledger.class); + xstream.alias("ledger-entry", LedgerEntry.class); + xstream.useAttributeFor(LedgerEntry.class, "uuid"); + xstream.useAttributeFor(LedgerEntry.class, "type"); + xstream.useAttributeFor(LedgerEntry.class, "dateTime"); + xstream.useAttributeFor(LedgerEntry.class, "updatedAt"); + xstream.alias("ledger-posting", LedgerPosting.class); + xstream.useAttributeFor(LedgerPosting.class, "uuid"); + xstream.useAttributeFor(LedgerPosting.class, "type"); + xstream.useAttributeFor(LedgerPosting.class, "amount"); + xstream.useAttributeFor(LedgerPosting.class, "currency"); + xstream.useAttributeFor(LedgerPosting.class, "forexAmount"); + xstream.useAttributeFor(LedgerPosting.class, "forexCurrency"); + xstream.useAttributeFor(LedgerPosting.class, "exchangeRate"); + xstream.useAttributeFor(LedgerPosting.class, "shares"); + xstream.alias("ledger-posting-parameter", LedgerParameter.class); + xstream.alias("ledger-posting-parameter-type", LedgerParameterType.class); + xstream.alias("ledger-parameter", LedgerParameter.class); + xstream.alias("ledger-parameter-type", LedgerParameterType.class); + xstream.alias("ledger-entry-type", LedgerEntryType.class); + xstream.alias("ledger-posting-type", LedgerPostingType.class); + xstream.alias("ledger-execution-ref", InvestmentPlan.LedgerExecutionRef.class); + } + + public static void initializeAfterLoad(Client client) throws IOException + { + if (client.getLedger().getEntries().isEmpty()) + { + new LegacyTransactionToLedgerMigrator().migrate(client); + convertPlanTransactionsToLedgerRefs(client); + return; + } + + removeLegacyProjectionShadows(client); + LedgerProjectionService.restoreIfValid(client); + LedgerMigrationDiagnostics.logMixedState(client, "xml-mixed-state"); //$NON-NLS-1$ + } + + public static void save(Client client, XStream xstream, Writer writer) throws IOException + { + var saveState = new LedgerXmlSaveState(); + + try + { + prepareLedgerXmlSave(client, saveState); + xstream.toXML(client, writer); + writer.flush(); + } + finally + { + saveState.restore(); + } + } + + private static Optional readAttribute(HierarchicalStreamReader reader, String name) + { + return Optional.ofNullable(reader.getAttribute(name)); + } + + private static void writeAttribute(HierarchicalStreamWriter writer, String name, Object value) + { + if (value != null) + writer.addAttribute(name, String.valueOf(value)); + } + + private static void writeValue(HierarchicalStreamWriter writer, String nodeName, String value) + { + if (value == null) + return; + + writer.startNode(nodeName); + writer.setValue(value); + writer.endNode(); + } + + private static void writeObject(HierarchicalStreamWriter writer, MarshallingContext context, String nodeName, + Object value) + { + if (value == null) + return; + + writer.startNode(nodeName); + context.convertAnother(value); + writer.endNode(); + } + + private static void writeCollection(HierarchicalStreamWriter writer, MarshallingContext context, + String collectionNodeName, String itemNodeName, List values) + { + writer.startNode(collectionNodeName); + + for (var value : values) + writeObject(writer, context, itemNodeName, value); + + writer.endNode(); + } + + private static void writeParameters(HierarchicalStreamWriter writer, MarshallingContext context, + List> parameters) + { + if (!parameters.isEmpty()) + writeCollection(writer, context, "parameters", "ledger-parameter", parameters); //$NON-NLS-1$ //$NON-NLS-2$ + } + + private static void prepareLedgerXmlSave(Client client, LedgerXmlSaveState saveState) throws IOException + { + validateLedger(client); + + for (var account : client.getAccounts()) + { + saveState.removeLedgerBackedTransactions(account.getTransactions()); + } + + for (var portfolio : client.getPortfolios()) + { + saveState.removeLedgerBackedTransactions(portfolio.getTransactions()); + } + + for (var plan : client.getPlans()) + saveState.replaceLedgerBackedPlanTransactions(plan); + } + + private static LedgerStructuralValidator.ValidationResult validateLedger(Client client) throws IOException + { + var result = LedgerStructuralValidator.validate(client.getLedger()); + + if (!result.isOK()) + { + LedgerProjectionService.logSkipped(client.getLedger(), result); + throw new IOException(LedgerDiagnosticCode.LEDGER_PERSIST_001 + .message(MessageFormat.format(Messages.LedgerXmlInvalidLedgerStructure, + LedgerDiagnosticMessageFormatter.formatValidationResult( + client.getLedger(), result)))); + } + + return result; + } + + private static void removeLegacyProjectionShadows(Client client) + { + convertPlanTransactionsToLedgerRefs(client); + } + + private static void convertPlanTransactionsToLedgerRefs(Client client) + { + // Old unreleased projection-ref shadow rows are no longer matched by + // persisted projection UUIDs. Released legacy plan transactions remain + // available as normal legacy rows. + } + + private static class LedgerEntryConverter implements Converter + { + @Override + public boolean canConvert(@SuppressWarnings("rawtypes") Class type) + { + return type == LedgerEntry.class; + } + + @Override + public void marshal(Object source, HierarchicalStreamWriter writer, MarshallingContext context) + { + var entry = (LedgerEntry) source; + + writeAttribute(writer, "type", entry.getType()); //$NON-NLS-1$ + writeAttribute(writer, "dateTime", entry.getDateTime()); //$NON-NLS-1$ + writeAttribute(writer, "updatedAt", entry.getUpdatedAt()); //$NON-NLS-1$ + writeValue(writer, "note", entry.getNote()); //$NON-NLS-1$ + writeValue(writer, "source", entry.getSource()); //$NON-NLS-1$ + writeValue(writer, "generatedByPlanKey", entry.getGeneratedByPlanKey()); //$NON-NLS-1$ + writeValue(writer, "planExecutionDate", //$NON-NLS-1$ + entry.getPlanExecutionDate() != null ? String.valueOf(entry.getPlanExecutionDate()) + : null); + writeValue(writer, "planExecutionSequence", //$NON-NLS-1$ + entry.getPlanExecutionSequence() != null + ? String.valueOf(entry.getPlanExecutionSequence()) + : null); + writeValue(writer, "preferredViewKind", entry.getPreferredViewKind()); //$NON-NLS-1$ + writeParameters(writer, context, entry.getParameters()); + writeCollection(writer, context, "postings", "ledger-posting", entry.getPostings()); //$NON-NLS-1$ //$NON-NLS-2$ + } + + @Override + public Object unmarshal(HierarchicalStreamReader reader, UnmarshallingContext context) + { + var entry = new LedgerEntry(); + var updatedAt = reader.getAttribute("updatedAt"); //$NON-NLS-1$ + + readAttribute(reader, "uuid").ifPresent(entry::setUUID); //$NON-NLS-1$ + readAttribute(reader, "type").map(LedgerEntryType::valueOf).ifPresent(entry::setType); //$NON-NLS-1$ + readAttribute(reader, "dateTime").map(LocalDateTime::parse).ifPresent(entry::setDateTime); //$NON-NLS-1$ + + while (reader.hasMoreChildren()) + { + reader.moveDown(); + + switch (reader.getNodeName()) + { + case "uuid" -> entry.setUUID(reader.getValue()); //$NON-NLS-1$ + case "type" -> entry.setType((LedgerEntryType) context.convertAnother(entry, //$NON-NLS-1$ + LedgerEntryType.class)); + case "dateTime" -> entry.setDateTime((LocalDateTime) context.convertAnother(entry, //$NON-NLS-1$ + LocalDateTime.class)); + case "updatedAt" -> updatedAt = reader.getValue(); //$NON-NLS-1$ + case "note" -> entry.setNote(reader.getValue()); //$NON-NLS-1$ + case "source" -> entry.setSource(reader.getValue()); //$NON-NLS-1$ + case "generatedByPlanKey" -> entry.setGeneratedByPlanKey(reader.getValue()); //$NON-NLS-1$ + case "planExecutionDate" -> entry.setPlanExecutionDate(LocalDate.parse(reader.getValue())); //$NON-NLS-1$ + case "planExecutionSequence" -> entry.setPlanExecutionSequence( + Integer.valueOf(reader.getValue())); //$NON-NLS-1$ + case "preferredViewKind" -> entry.setPreferredViewKind(reader.getValue()); //$NON-NLS-1$ + case "parameters" -> readParameters(reader, context, entry); //$NON-NLS-1$ + case "postings" -> readPostings(reader, context, entry); //$NON-NLS-1$ + case "projectionRefs" -> skipChildren(reader); //$NON-NLS-1$ + default -> { + // Ignore unknown LedgerEntry fields to preserve load recovery behavior. + } + } + + reader.moveUp(); + } + + if (updatedAt != null) + entry.setUpdatedAt(Instant.parse(updatedAt)); + + return entry; + } + + private void readParameters(HierarchicalStreamReader reader, UnmarshallingContext context, LedgerEntry entry) + { + while (reader.hasMoreChildren()) + { + reader.moveDown(); + entry.addParameter((LedgerParameter) context.convertAnother(entry, LedgerParameter.class)); + reader.moveUp(); + } + } + + private void readPostings(HierarchicalStreamReader reader, UnmarshallingContext context, LedgerEntry entry) + { + while (reader.hasMoreChildren()) + { + reader.moveDown(); + entry.addPosting((LedgerPosting) context.convertAnother(entry, LedgerPosting.class)); + reader.moveUp(); + } + } + + private void skipChildren(HierarchicalStreamReader reader) + { + while (reader.hasMoreChildren()) + { + reader.moveDown(); + skipChildren(reader); + reader.moveUp(); + } + } + } + + private static class LedgerPostingConverter implements Converter + { + @Override + public boolean canConvert(@SuppressWarnings("rawtypes") Class type) + { + return type == LedgerPosting.class; + } + + @Override + public void marshal(Object source, HierarchicalStreamWriter writer, MarshallingContext context) + { + var posting = (LedgerPosting) source; + + writeAttribute(writer, "type", posting.getType()); //$NON-NLS-1$ + writeAttribute(writer, "amount", posting.getAmount()); //$NON-NLS-1$ + writeAttribute(writer, "currency", posting.getCurrency()); //$NON-NLS-1$ + writeAttribute(writer, "forexAmount", posting.getForexAmount()); //$NON-NLS-1$ + writeAttribute(writer, "forexCurrency", posting.getForexCurrency()); //$NON-NLS-1$ + writeAttribute(writer, "exchangeRate", posting.getExchangeRate()); //$NON-NLS-1$ + writeAttribute(writer, "shares", posting.getShares()); //$NON-NLS-1$ + writeAttribute(writer, "semanticRole", posting.getSemanticRole()); //$NON-NLS-1$ + writeAttribute(writer, "direction", posting.getDirection()); //$NON-NLS-1$ + writeAttribute(writer, "corporateActionLeg", posting.getCorporateActionLeg()); //$NON-NLS-1$ + writeAttribute(writer, "unitRole", posting.getUnitRole()); //$NON-NLS-1$ + writeAttribute(writer, "groupKey", posting.getGroupKey()); //$NON-NLS-1$ + writeAttribute(writer, "localKey", posting.getLocalKey()); //$NON-NLS-1$ + writeObject(writer, context, "security", posting.getSecurity()); //$NON-NLS-1$ + writeObject(writer, context, "account", posting.getAccount()); //$NON-NLS-1$ + writeObject(writer, context, "portfolio", posting.getPortfolio()); //$NON-NLS-1$ + writeParameters(writer, context, posting.getParameters()); + } + + @Override + public Object unmarshal(HierarchicalStreamReader reader, UnmarshallingContext context) + { + var posting = new LedgerPosting(); + + readAttribute(reader, "uuid").ifPresent(posting::setUUID); //$NON-NLS-1$ + readAttribute(reader, "type").map(LedgerPostingType::valueOf).ifPresent(posting::setType); //$NON-NLS-1$ + readAttribute(reader, "amount").map(Long::parseLong).ifPresent(posting::setAmount); //$NON-NLS-1$ + readAttribute(reader, "currency").ifPresent(posting::setCurrency); //$NON-NLS-1$ + readAttribute(reader, "forexAmount").map(Long::valueOf).ifPresent(posting::setForexAmount); //$NON-NLS-1$ + readAttribute(reader, "forexCurrency").ifPresent(posting::setForexCurrency); //$NON-NLS-1$ + readAttribute(reader, "exchangeRate").map(BigDecimal::new).ifPresent(posting::setExchangeRate); //$NON-NLS-1$ + readAttribute(reader, "shares").map(Long::parseLong).ifPresent(posting::setShares); //$NON-NLS-1$ + readAttribute(reader, "semanticRole").map(LedgerPostingSemanticRole::valueOf) //$NON-NLS-1$ + .ifPresent(posting::setSemanticRole); + readAttribute(reader, "direction").map(LedgerPostingDirection::valueOf) //$NON-NLS-1$ + .ifPresent(posting::setDirection); + readAttribute(reader, "corporateActionLeg").map(CorporateActionLeg::valueOf) //$NON-NLS-1$ + .ifPresent(posting::setCorporateActionLeg); + readAttribute(reader, "unitRole").map(LedgerPostingUnitRole::valueOf) //$NON-NLS-1$ + .ifPresent(posting::setUnitRole); + readAttribute(reader, "groupKey").ifPresent(posting::setGroupKey); //$NON-NLS-1$ + readAttribute(reader, "localKey").ifPresent(posting::setLocalKey); //$NON-NLS-1$ + + while (reader.hasMoreChildren()) + { + reader.moveDown(); + + switch (reader.getNodeName()) + { + case "uuid" -> posting.setUUID(reader.getValue()); //$NON-NLS-1$ + case "type" -> posting.setType((LedgerPostingType) context.convertAnother(posting, //$NON-NLS-1$ + LedgerPostingType.class)); + case "amount" -> posting.setAmount(Long.parseLong(reader.getValue())); //$NON-NLS-1$ + case "currency" -> posting.setCurrency(reader.getValue()); //$NON-NLS-1$ + case "forexAmount" -> posting.setForexAmount(Long.valueOf(reader.getValue())); //$NON-NLS-1$ + case "forexCurrency" -> posting.setForexCurrency(reader.getValue()); //$NON-NLS-1$ + case "exchangeRate" -> posting.setExchangeRate(new BigDecimal(reader.getValue())); //$NON-NLS-1$ + case "security" -> posting.setSecurity((Security) context.convertAnother(posting, //$NON-NLS-1$ + Security.class)); + case "shares" -> posting.setShares(Long.parseLong(reader.getValue())); //$NON-NLS-1$ + case "account" -> posting.setAccount((Account) context.convertAnother(posting, Account.class)); //$NON-NLS-1$ + case "portfolio" -> posting.setPortfolio((Portfolio) context.convertAnother(posting, //$NON-NLS-1$ + Portfolio.class)); + case "semanticRole" -> posting.setSemanticRole( //$NON-NLS-1$ + (LedgerPostingSemanticRole) context.convertAnother(posting, + LedgerPostingSemanticRole.class)); + case "direction" -> posting.setDirection( //$NON-NLS-1$ + (LedgerPostingDirection) context.convertAnother(posting, + LedgerPostingDirection.class)); + case "corporateActionLeg" -> posting.setCorporateActionLeg( //$NON-NLS-1$ + (CorporateActionLeg) context.convertAnother(posting, CorporateActionLeg.class)); + case "unitRole" -> posting.setUnitRole( //$NON-NLS-1$ + (LedgerPostingUnitRole) context.convertAnother(posting, + LedgerPostingUnitRole.class)); + case "groupKey" -> posting.setGroupKey(reader.getValue()); //$NON-NLS-1$ + case "localKey" -> posting.setLocalKey(reader.getValue()); //$NON-NLS-1$ + case "parameters" -> readParameters(reader, context, posting); //$NON-NLS-1$ + default -> { + // Ignore unknown LedgerPosting fields to preserve load recovery behavior. + } + } + + reader.moveUp(); + } + + return posting; + } + + private void readParameters(HierarchicalStreamReader reader, UnmarshallingContext context, + LedgerPosting posting) + { + while (reader.hasMoreChildren()) + { + reader.moveDown(); + posting.addParameter((LedgerParameter) context.convertAnother(posting, LedgerParameter.class)); + reader.moveUp(); + } + } + } + + private static class LedgerParameterConverter implements Converter + { + @Override + public boolean canConvert(@SuppressWarnings("rawtypes") Class type) + { + return type == LedgerParameter.class; + } + + @Override + public void marshal(Object source, HierarchicalStreamWriter writer, MarshallingContext context) + { + var parameter = (LedgerParameter) source; + + writer.addAttribute("type", parameter.getType().getCode()); //$NON-NLS-1$ + writer.addAttribute("valueKind", parameter.getValueKind().name()); //$NON-NLS-1$ + + switch (parameter.getValueKind()) + { + case STRING, DECIMAL, LONG, BOOLEAN, LOCAL_DATE, LOCAL_DATE_TIME: + writer.addAttribute("value", String.valueOf(parameter.getValue())); //$NON-NLS-1$ + break; + case MONEY: + var money = (Money) parameter.getValue(); + writer.addAttribute("amount", String.valueOf(money.getAmount())); //$NON-NLS-1$ + writer.addAttribute("currency", money.getCurrencyCode()); //$NON-NLS-1$ + break; + case SECURITY, ACCOUNT, PORTFOLIO: + writer.startNode("value"); //$NON-NLS-1$ + context.convertAnother(parameter.getValue()); + writer.endNode(); + break; + default: + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PERSIST_003 + .message(MessageFormat.format(Messages.LedgerParameterUnsupportedValueKind, + parameter.getValueKind()))); + } + } + + @Override + public Object unmarshal(HierarchicalStreamReader reader, UnmarshallingContext context) + { + LedgerParameterType type = typeOrNull(reader.getAttribute("type")); //$NON-NLS-1$ + ValueKind valueKind = valueKindOrNull(reader.getAttribute("valueKind")); //$NON-NLS-1$ + var scalarValue = reader.getAttribute("value"); //$NON-NLS-1$ + var amount = reader.getAttribute("amount"); //$NON-NLS-1$ + var currency = reader.getAttribute("currency"); //$NON-NLS-1$ + Object parameterValue = null; + + while (reader.hasMoreChildren()) + { + reader.moveDown(); + + switch (reader.getNodeName()) + { + case "type" -> type = typeOrNull(reader.getValue()); //$NON-NLS-1$ + case "valueKind" -> valueKind = valueKindOrNull(reader.getValue()); //$NON-NLS-1$ + case "value" -> parameterValue = readValue(reader, context, valueKind); //$NON-NLS-1$ + default -> { + // Ignore unknown LedgerParameter fields to keep XML load recovery tolerant. + } + } + + reader.moveUp(); + } + + if (type == null) + throw new IllegalArgumentException( + LedgerDiagnosticCode.LEDGER_PERSIST_004.message(Messages.LedgerParameterMissingType)); + + if (valueKind == null) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PERSIST_005 + .message(Messages.LedgerParameterMissingValueKind)); + + if (parameterValue == null) + parameterValue = readValue(scalarValue, amount, currency, valueKind); + + return newParameter(type, valueKind, parameterValue); + } + + private LedgerParameterType typeOrNull(String value) + { + if (Strings.isNullOrEmpty(value)) + return null; + + return LedgerParameterType.fromCode(value); + } + + private ValueKind valueKindOrNull(String value) + { + if (Strings.isNullOrEmpty(value)) + return null; + + return ValueKind.valueOf(value); + } + + private Object readValue(HierarchicalStreamReader reader, UnmarshallingContext context, ValueKind valueKind) + { + return switch (valueKind) + { + case STRING, DECIMAL, LONG, BOOLEAN, LOCAL_DATE, LOCAL_DATE_TIME -> readValue(reader.getValue(), + valueKind); + case MONEY -> context.convertAnother(null, Money.class); + case SECURITY -> context.convertAnother(null, Security.class); + case ACCOUNT -> context.convertAnother(null, Account.class); + case PORTFOLIO -> context.convertAnother(null, Portfolio.class); + }; + } + + private Object readValue(String value, String amount, String currency, ValueKind valueKind) + { + return switch (valueKind) + { + case STRING, DECIMAL, LONG, BOOLEAN, LOCAL_DATE, LOCAL_DATE_TIME -> readValue(require(value, "value"), //$NON-NLS-1$ + valueKind); + case MONEY -> Money.of(require(currency, "currency"), Long.parseLong(require(amount, "amount"))); //$NON-NLS-1$ //$NON-NLS-2$ + case SECURITY, ACCOUNT, PORTFOLIO -> throw new IllegalArgumentException( + LedgerDiagnosticCode.LEDGER_PERSIST_006 + .message(Messages.LedgerParameterReferenceValueMissingValueNode)); + }; + } + + private Object readValue(String value, ValueKind valueKind) + { + return switch (valueKind) + { + case STRING -> value; + case DECIMAL -> new BigDecimal(value); + case LONG -> Long.valueOf(value); + case BOOLEAN -> Boolean.valueOf(value); + case LOCAL_DATE -> LocalDate.parse(value); + case LOCAL_DATE_TIME -> localDateTime(value); + case MONEY, SECURITY, ACCOUNT, PORTFOLIO -> throw new IllegalArgumentException( + LedgerDiagnosticCode.LEDGER_PERSIST_007 + .message(MessageFormat.format( + Messages.LedgerParameterValueKindRequiresStructuredValue, + valueKind))); + }; + } + + private LocalDateTime localDateTime(String value) + { + try + { + return LocalDateTime.parse(value); + } + catch (RuntimeException e) + { + return LocalDate.parse(value).atStartOfDay(); + } + } + + private String require(String value, String name) + { + if (value == null) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PERSIST_008 + .message(MessageFormat.format(Messages.LedgerParameterMissingAttribute, name))); + + return value; + } + + private LedgerParameter newParameter(LedgerParameterType type, ValueKind valueKind, Object value) + { + try + { + var constructor = LedgerParameter.class.getDeclaredConstructor(LedgerParameterType.class, + ValueKind.class, Object.class); + constructor.setAccessible(true); + return constructor.newInstance(type, valueKind, value); + } + catch (ReflectiveOperationException e) + { + throw new IllegalStateException(e); + } + } + } + + private static final class LedgerXmlSaveState + { + private final List removedElements = new ArrayList<>(); + + private void removeLedgerBackedTransactions(List transactions) + { + for (var index = transactions.size() - 1; index >= 0; index--) + { + var transaction = transactions.get(index); + + if (transaction instanceof LedgerBackedTransaction) + remove(transactions, index); + } + } + + private void replaceLedgerBackedPlanTransactions(InvestmentPlan plan) + { + for (var index = plan.getTransactions().size() - 1; index >= 0; index--) + { + var transaction = plan.getTransactions().get(index); + + if (transaction instanceof LedgerBackedTransaction ledgerBackedTransaction) + { + plan.markLedgerExecution(ledgerBackedTransaction); + remove(plan.getTransactions(), index); + } + } + } + + @SuppressWarnings("rawtypes") + private void remove(List list, int index) + { + removedElements.add(new RemovedListElement(list, index, list.remove(index))); + } + + private void restore() + { + for (var index = removedElements.size() - 1; index >= 0; index--) + removedElements.get(index).restore(); + } + } + + private record RemovedListElement(@SuppressWarnings("rawtypes") List list, int index, Object element) + { + @SuppressWarnings("unchecked") + private void restore() + { + list.add(index, element); + } + } +} From e57f1c4de8c9aa7ef9a16a3c04d47c7764377cee Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:11:58 +0200 Subject: [PATCH 29/68] Wrap Ledger protobuf persistence support Move Ledger protobuf load and save, compatibility shadow handling, plan execution compatibility, and validation helpers out of ProtobufWriter into LedgerProtobufPersistenceSupport. Keep ProtobufWriter close to the original save and load flow while preserving no-UUID Ledger protobuf behavior behind a narrow helper. Schema, generated protobuf code, XML persistence, Ledger model semantics, migration behavior, InvestmentPlan semantics, UI and import code, and file save mechanics are intentionally unchanged. --- .../LedgerProtobufPersistenceSupport.java | 559 +++++++++++++++++ .../portfolio/model/ProtobufWriter.java | 564 +----------------- 2 files changed, 590 insertions(+), 533 deletions(-) create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceSupport.java diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceSupport.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceSupport.java new file mode 100644 index 0000000000..3dc4e79823 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceSupport.java @@ -0,0 +1,559 @@ +package name.abuchen.portfolio.model; + +import static name.abuchen.portfolio.util.ProtobufUtil.asDecimalValue; +import static name.abuchen.portfolio.util.ProtobufUtil.asLocalDateTime; +import static name.abuchen.portfolio.util.ProtobufUtil.asTimestamp; +import static name.abuchen.portfolio.util.ProtobufUtil.asUpdatedAtTimestamp; +import static name.abuchen.portfolio.util.ProtobufUtil.fromDecimalValue; +import static name.abuchen.portfolio.util.ProtobufUtil.fromLocalDateTime; +import static name.abuchen.portfolio.util.ProtobufUtil.fromTimestamp; +import static name.abuchen.portfolio.util.ProtobufUtil.fromUpdatedAtTimestamp; + +import java.math.BigDecimal; +import java.text.MessageFormat; +import java.time.LocalDate; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; + +import name.abuchen.portfolio.Messages; +import name.abuchen.portfolio.model.InvestmentPlan.LedgerExecutionViewKind; +import name.abuchen.portfolio.model.ledger.Ledger; +import name.abuchen.portfolio.model.ledger.LedgerDiagnosticMessageFormatter; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerParameter; +import name.abuchen.portfolio.model.ledger.LedgerParameter.ValueKind; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerPostingDirection; +import name.abuchen.portfolio.model.ledger.LedgerPostingSemanticRole; +import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; +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.model.ledger.legacy.LedgerMigrationDiagnostics; +import name.abuchen.portfolio.model.ledger.legacy.LegacyTransactionToLedgerMigrator; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionService; +import name.abuchen.portfolio.model.proto.v1.PClient; +import name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef; +import name.abuchen.portfolio.model.proto.v1.PLedger; +import name.abuchen.portfolio.model.proto.v1.PLedgerEntry; +import name.abuchen.portfolio.model.proto.v1.PLedgerParameter; +import name.abuchen.portfolio.model.proto.v1.PLedgerParameterValueKind; +import name.abuchen.portfolio.model.proto.v1.PLedgerPosting; +import name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRole; +import name.abuchen.portfolio.model.proto.v1.PTransaction; +import name.abuchen.portfolio.money.Money; + +final class LedgerProtobufPersistenceSupport +{ + private static final String LEDGER_COMPATIBILITY_SHADOW_PREFIX = "ledger-shadow:"; //$NON-NLS-1$ + + record LoadState(boolean hasLedgerTruth, Set ledgerProjectionUUIDs) + { + } + + private LedgerProtobufPersistenceSupport() + { + } + + static LoadState loadLedgerTruth(PClient newClient, Client client, ProtobufWriter.Lookup lookup) + { + boolean hasLedgerTruth = hasLedgerTruth(newClient); + Set ledgerProjectionUUIDs = Set.of(); + + if (hasLedgerTruth) + { + loadLedger(newClient.getLedger(), client, lookup); + ledgerProjectionUUIDs = ledgerProjectionUUIDs(client.getLedger()); + } + + return new LoadState(hasLedgerTruth, ledgerProjectionUUIDs); + } + + static void finalizeAfterLoad(Client client, boolean hasLedgerTruth) + { + if (hasLedgerTruth) + { + LedgerProjectionService.restoreIfValid(client); + LedgerMigrationDiagnostics.logMixedState(client, "protobuf-mixed-state"); //$NON-NLS-1$ + } + else + { + new LegacyTransactionToLedgerMigrator().migrate(client); + } + } + + static boolean isLedgerCompatibilityShadow(PTransaction newTransaction, LoadState loadState) + { + return loadState.hasLedgerTruth() && (isLedgerCompatibilityShadowUUID(newTransaction.getUuid()) + || (newTransaction.hasOtherUuid() + && isLedgerCompatibilityShadowUUID(newTransaction.getOtherUuid()))) + || loadState.ledgerProjectionUUIDs().contains(newTransaction.getUuid()) + || (newTransaction.hasOtherUuid() + && loadState.ledgerProjectionUUIDs().contains(newTransaction.getOtherUuid())); + } + + static void saveLedger(Client client, PClient.Builder newClient) + { + validateLedger(client); + + PLedger.Builder newLedger = PLedger.newBuilder(); + + for (LedgerEntry entry : client.getLedger().getEntries()) + newLedger.addEntries(saveLedgerEntry(entry)); + + newClient.setLedger(newLedger); + } + + static void saveLedgerCompatibilityShadows(Client client, PClient.Builder newClient, ProtobufWriter writer) + { + for (LedgerEntry entry : client.getLedger().getEntries()) + { + if (!entry.getType().isLegacyFixedShape()) + continue; + + for (Transaction transaction : LedgerProjectionService.createProjections(entry)) + { + if (!shouldSaveLedgerCompatibilityShadow(transaction)) + continue; + + LedgerBackedTransaction ledgerBackedTransaction = (LedgerBackedTransaction) transaction; + + if (transaction instanceof AccountTransaction accountTransaction) + writer.addTransaction(newClient, ledgerBackedTransaction.getLedgerProjectionDescriptor().getAccount(), + accountTransaction); + else if (transaction instanceof PortfolioTransaction portfolioTransaction) + writer.addTransaction(newClient, + ledgerBackedTransaction.getLedgerProjectionDescriptor().getPortfolio(), + portfolioTransaction); + else + throw new UnsupportedOperationException(transaction.getClass().getName()); + } + } + } + + static boolean shouldSaveLegacyTransaction(Transaction transaction, Set ledgerProjectionUUIDs) + { + return !(transaction instanceof LedgerBackedTransaction) // + && !isLedgerCompatibilityShadowUUID(transaction.getUUID()) // + && !ledgerProjectionUUIDs.contains(transaction.getUUID()); + } + + static String protobufTransactionUUID(Transaction transaction) + { + if (transaction instanceof LedgerBackedTransaction ledgerBackedTransaction) + { + return LEDGER_COMPATIBILITY_SHADOW_PREFIX + ledgerBackedTransaction.getLedgerEntry().getUUID() + + ":" + ledgerBackedTransaction.getLedgerProjectionRole(); //$NON-NLS-1$ + } + + return transaction.getUUID(); + } + + static void prepareInvestmentPlanLedgerExecutions(Client client) + { + for (var plan : client.getPlans()) + for (var transaction : plan.getTransactions()) + markPlanExecution(plan, transaction); + } + + static boolean markPlanExecution(InvestmentPlan plan, Transaction transaction) + { + if (!(transaction instanceof LedgerBackedTransaction ledgerBackedTransaction)) + return false; + + plan.markLedgerExecution(ledgerBackedTransaction); + return true; + } + + static boolean markPlanExecutionForProjectionUUID(Client client, InvestmentPlan plan, String projectionUUID) + { + return false; + } + + static void markPlanExecutionForLegacyRef(Client client, InvestmentPlan plan, PInvestmentPlanLedgerExecutionRef ref) + { + for (LedgerEntry entry : client.getLedger().getEntries()) + { + if (!entry.getUUID().equals(ref.getLedgerEntryUUID())) + continue; + + var preferredViewKind = ref.hasProjectionRole() ? viewKind(fromProto(ref.getProjectionRole())) : null; + plan.markLedgerExecution(entry, entry.getDateTime().toLocalDate(), preferredViewKind); + return; + } + } + + static Set ledgerProjectionUUIDs(Ledger ledger) + { + return new HashSet<>(); + } + + private static boolean hasLedgerTruth(PClient newClient) + { + return newClient.hasLedger() && newClient.getLedger().getEntriesCount() > 0; + } + + private static void loadLedger(PLedger newLedger, Client client, ProtobufWriter.Lookup lookup) + { + for (PLedgerEntry newEntry : newLedger.getEntriesList()) + { + LedgerEntry entry = LedgerModelLoadSupport.newEntry(UUID.randomUUID().toString(), + LedgerEntryType.fromProtobufId(newEntry.getTypeId()), + fromTimestamp(newEntry.getDateTime())); + + if (newEntry.hasNote()) + LedgerModelLoadSupport.setEntryNote(entry, newEntry.getNote()); + if (newEntry.hasSource()) + LedgerModelLoadSupport.setEntrySource(entry, newEntry.getSource()); + if (newEntry.hasUpdatedAt()) + LedgerModelLoadSupport.setEntryUpdatedAt(entry, fromUpdatedAtTimestamp(newEntry.getUpdatedAt())); + if (newEntry.hasGeneratedByPlanKey()) + LedgerModelLoadSupport.setGeneratedByPlanKey(entry, newEntry.getGeneratedByPlanKey()); + if (newEntry.hasPlanExecutionDate()) + LedgerModelLoadSupport.setPlanExecutionDate(entry, + LocalDate.ofEpochDay(newEntry.getPlanExecutionDate())); + if (newEntry.hasPlanExecutionSequence()) + LedgerModelLoadSupport.setPlanExecutionSequence(entry, newEntry.getPlanExecutionSequence()); + if (newEntry.hasPreferredViewKind()) + LedgerModelLoadSupport.setPreferredViewKind(entry, newEntry.getPreferredViewKind()); + + for (PLedgerParameter newParameter : newEntry.getParametersList()) + LedgerModelLoadSupport.addEntryParameter(entry, loadLedgerParameter(newParameter, lookup)); + + for (PLedgerPosting newPosting : newEntry.getPostingsList()) + LedgerModelLoadSupport.addPosting(entry, loadLedgerPosting(newPosting, lookup)); + + if (newEntry.hasUpdatedAt()) + LedgerModelLoadSupport.setEntryUpdatedAt(entry, fromUpdatedAtTimestamp(newEntry.getUpdatedAt())); + + LedgerModelLoadSupport.addEntry(client.getLedger(), entry); + } + } + + private static LedgerPosting loadLedgerPosting(PLedgerPosting newPosting, ProtobufWriter.Lookup lookup) + { + LedgerPosting posting = LedgerModelLoadSupport.newPosting(UUID.randomUUID().toString(), + LedgerPostingType.fromCode(newPosting.getTypeCode())); + + LedgerModelLoadSupport.setPostingAmount(posting, newPosting.getAmount()); + if (newPosting.hasCurrency()) + LedgerModelLoadSupport.setPostingCurrency(posting, newPosting.getCurrency()); + if (newPosting.hasForexAmount()) + LedgerModelLoadSupport.setPostingForexAmount(posting, newPosting.getForexAmount()); + if (newPosting.hasForexCurrency()) + LedgerModelLoadSupport.setPostingForexCurrency(posting, newPosting.getForexCurrency()); + if (newPosting.hasExchangeRate()) + LedgerModelLoadSupport.setPostingExchangeRate(posting, fromDecimalValue(newPosting.getExchangeRate())); + if (newPosting.hasSecurity()) + LedgerModelLoadSupport.setPostingSecurity(posting, lookup.getSecurity(newPosting.getSecurity())); + LedgerModelLoadSupport.setPostingShares(posting, newPosting.getShares()); + if (newPosting.hasAccount()) + LedgerModelLoadSupport.setPostingAccount(posting, lookup.getAccount(newPosting.getAccount())); + if (newPosting.hasPortfolio()) + LedgerModelLoadSupport.setPostingPortfolio(posting, lookup.getPortfolio(newPosting.getPortfolio())); + if (newPosting.hasSemanticRole()) + posting.setSemanticRole(LedgerPostingSemanticRole.valueOf(newPosting.getSemanticRole())); + if (newPosting.hasDirection()) + posting.setDirection(LedgerPostingDirection.valueOf(newPosting.getDirection())); + if (newPosting.hasCorporateActionLeg()) + posting.setCorporateActionLeg(corporateActionLeg(newPosting.getCorporateActionLeg())); + if (newPosting.hasUnitRole()) + posting.setUnitRole(LedgerPostingUnitRole.valueOf(newPosting.getUnitRole())); + if (newPosting.hasGroupKey()) + posting.setGroupKey(newPosting.getGroupKey()); + if (newPosting.hasLocalKey()) + posting.setLocalKey(newPosting.getLocalKey()); + + for (PLedgerParameter newParameter : newPosting.getParametersList()) + LedgerModelLoadSupport.addPostingParameter(posting, loadLedgerParameter(newParameter, lookup)); + + return posting; + } + + private static CorporateActionLeg corporateActionLeg(String code) + { + for (var leg : CorporateActionLeg.values()) + if (leg.getCode().equals(code)) + return leg; + + return CorporateActionLeg.valueOf(code); + } + + private static LedgerParameter loadLedgerParameter(PLedgerParameter newParameter, + ProtobufWriter.Lookup lookup) + { + LedgerParameterType type = LedgerParameterType.fromCode(newParameter.getTypeCode()); + + switch (newParameter.getValueKind()) + { + case LEDGER_PARAMETER_VALUE_KIND_STRING: + return LedgerParameter.ofString(type, newParameter.getStringValue()); + case LEDGER_PARAMETER_VALUE_KIND_DECIMAL: + return LedgerParameter.ofDecimal(type, fromDecimalValue(newParameter.getDecimalValue())); + case LEDGER_PARAMETER_VALUE_KIND_LONG: + return LedgerParameter.ofLong(type, newParameter.getLongValue()); + case LEDGER_PARAMETER_VALUE_KIND_MONEY: + return LedgerParameter.ofMoney(type, + Money.of(newParameter.getMoneyCurrency(), newParameter.getMoneyAmount())); + case LEDGER_PARAMETER_VALUE_KIND_SECURITY: + return LedgerParameter.ofSecurity(type, lookup.getSecurity(newParameter.getSecurity())); + case LEDGER_PARAMETER_VALUE_KIND_ACCOUNT: + return LedgerParameter.ofAccount(type, lookup.getAccount(newParameter.getAccount())); + case LEDGER_PARAMETER_VALUE_KIND_PORTFOLIO: + return LedgerParameter.ofPortfolio(type, lookup.getPortfolio(newParameter.getPortfolio())); + case LEDGER_PARAMETER_VALUE_KIND_BOOLEAN: + if (!newParameter.hasBooleanValue()) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PERSIST_009 + .message(Messages.LedgerProtobufBooleanParameterMissingBooleanValue)); + return LedgerParameter.ofBoolean(type, Boolean.valueOf(newParameter.getBooleanValue())); + case LEDGER_PARAMETER_VALUE_KIND_LOCAL_DATE: + if (!newParameter.hasLocalDateValue()) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PERSIST_010 + .message(Messages.LedgerProtobufLocalDateParameterMissingLocalDateValue)); + return LedgerParameter.ofLocalDate(type, LocalDate.ofEpochDay(newParameter.getLocalDateValue())); + case LEDGER_PARAMETER_VALUE_KIND_LOCAL_DATE_TIME: + return LedgerParameter.ofLocalDateTime(type, + fromLocalDateTime(newParameter.getLocalDateTimeValue())); + case LEDGER_PARAMETER_VALUE_KIND_UNSPECIFIED: + case UNRECOGNIZED: + default: + throw new UnsupportedOperationException(newParameter.getValueKind().toString()); + } + } + + private static PLedgerEntry saveLedgerEntry(LedgerEntry entry) + { + PLedgerEntry.Builder newEntry = PLedgerEntry.newBuilder(); + + newEntry.setTypeId(entry.getType().getProtobufId()); + newEntry.setDateTime(asTimestamp(entry.getDateTime())); + + if (entry.getNote() != null) + newEntry.setNote(entry.getNote()); + if (entry.getSource() != null) + newEntry.setSource(entry.getSource()); + if (entry.getUpdatedAt() != null) + newEntry.setUpdatedAt(asUpdatedAtTimestamp(entry.getUpdatedAt())); + if (entry.getGeneratedByPlanKey() != null) + newEntry.setGeneratedByPlanKey(entry.getGeneratedByPlanKey()); + if (entry.getPlanExecutionDate() != null) + newEntry.setPlanExecutionDate(entry.getPlanExecutionDate().toEpochDay()); + if (entry.getPlanExecutionSequence() != null) + newEntry.setPlanExecutionSequence(entry.getPlanExecutionSequence()); + if (entry.getPreferredViewKind() != null) + newEntry.setPreferredViewKind(entry.getPreferredViewKind()); + + for (LedgerParameter parameter : entry.getParameters()) + newEntry.addParameters(saveLedgerParameter(parameter)); + + for (LedgerPosting posting : entry.getPostings()) + newEntry.addPostings(saveLedgerPosting(posting)); + + return newEntry.build(); + } + + private static PLedgerPosting saveLedgerPosting(LedgerPosting posting) + { + PLedgerPosting.Builder newPosting = PLedgerPosting.newBuilder(); + + newPosting.setTypeCode(posting.getType().getCode()); + newPosting.setAmount(posting.getAmount()); + + if (posting.getCurrency() != null) + newPosting.setCurrency(posting.getCurrency()); + if (posting.getForexAmount() != null) + newPosting.setForexAmount(posting.getForexAmount()); + if (posting.getForexCurrency() != null) + newPosting.setForexCurrency(posting.getForexCurrency()); + if (posting.getExchangeRate() != null) + newPosting.setExchangeRate(asDecimalValue(posting.getExchangeRate())); + if (posting.getSecurity() != null) + newPosting.setSecurity(posting.getSecurity().getUUID()); + newPosting.setShares(posting.getShares()); + if (posting.getAccount() != null) + newPosting.setAccount(posting.getAccount().getUUID()); + if (posting.getPortfolio() != null) + newPosting.setPortfolio(posting.getPortfolio().getUUID()); + if (posting.getSemanticRole() != null) + newPosting.setSemanticRole(posting.getSemanticRole().name()); + if (posting.getDirection() != null) + newPosting.setDirection(posting.getDirection().name()); + if (posting.getCorporateActionLeg() != null) + newPosting.setCorporateActionLeg(posting.getCorporateActionLeg().getCode()); + if (posting.getUnitRole() != null) + newPosting.setUnitRole(posting.getUnitRole().name()); + if (posting.getGroupKey() != null) + newPosting.setGroupKey(posting.getGroupKey()); + if (posting.getLocalKey() != null) + newPosting.setLocalKey(posting.getLocalKey()); + + for (LedgerParameter parameter : posting.getParameters()) + newPosting.addParameters(saveLedgerParameter(parameter)); + + return newPosting.build(); + } + + private static PLedgerParameter saveLedgerParameter(LedgerParameter parameter) + { + PLedgerParameter.Builder newParameter = PLedgerParameter.newBuilder(); + + newParameter.setTypeCode(parameter.getType().getCode()); + newParameter.setValueKind(toProto(parameter.getValueKind())); + + switch (parameter.getValueKind()) + { + case STRING: + newParameter.setStringValue((String) parameter.getValue()); + break; + case DECIMAL: + newParameter.setDecimalValue(asDecimalValue((BigDecimal) parameter.getValue())); + break; + case LONG: + newParameter.setLongValue((Long) parameter.getValue()); + break; + case MONEY: + Money money = (Money) parameter.getValue(); + newParameter.setMoneyAmount(money.getAmount()); + newParameter.setMoneyCurrency(money.getCurrencyCode()); + break; + case SECURITY: + newParameter.setSecurity(((Security) parameter.getValue()).getUUID()); + break; + case ACCOUNT: + newParameter.setAccount(((Account) parameter.getValue()).getUUID()); + break; + case PORTFOLIO: + newParameter.setPortfolio(((Portfolio) parameter.getValue()).getUUID()); + break; + case BOOLEAN: + newParameter.setBooleanValue(((Boolean) parameter.getValue()).booleanValue()); + break; + case LOCAL_DATE: + newParameter.setLocalDateValue(((LocalDate) parameter.getValue()).toEpochDay()); + break; + case LOCAL_DATE_TIME: + newParameter.setLocalDateTimeValue(asLocalDateTime((java.time.LocalDateTime) parameter.getValue())); + break; + default: + throw new UnsupportedOperationException(parameter.getValueKind().toString()); + } + + return newParameter.build(); + } + + private static boolean shouldSaveLedgerCompatibilityShadow(Transaction transaction) + { + if (transaction instanceof AccountTransaction accountTransaction) + return accountTransaction.getType() != AccountTransaction.Type.BUY + && accountTransaction.getType() != AccountTransaction.Type.SELL + && accountTransaction.getType() != AccountTransaction.Type.TRANSFER_IN; + + if (transaction instanceof PortfolioTransaction portfolioTransaction) + return portfolioTransaction.getType() != PortfolioTransaction.Type.TRANSFER_IN; + + return false; + } + + private static boolean isLedgerCompatibilityShadowUUID(String uuid) + { + return uuid != null && uuid.startsWith(LEDGER_COMPATIBILITY_SHADOW_PREFIX); + } + + private static void validateLedger(Client client) + { + var ledger = client.getLedger(); + var result = LedgerStructuralValidator.validate(ledger); + + if (!result.isOK()) + { + LedgerProjectionService.logSkipped(ledger, result); + throw new UnsupportedOperationException( + LedgerDiagnosticCode.LEDGER_PERSIST_002 + .message(MessageFormat.format( + Messages.LedgerProtobufInvalidLedgerPersistenceState, + LedgerDiagnosticMessageFormatter.formatValidationResult( + ledger, result)))); + } + } + + private static LedgerExecutionViewKind viewKind(LedgerProjectionRole role) + { + if (role == null) + return null; + + return switch (role) + { + case ACCOUNT, SOURCE_ACCOUNT, TARGET_ACCOUNT, CASH_COMPENSATION -> LedgerExecutionViewKind.ACCOUNT; + default -> LedgerExecutionViewKind.PORTFOLIO; + }; + } + + private static LedgerProjectionRole fromProto(PLedgerProjectionRole role) + { + switch (role) + { + case LEDGER_PROJECTION_ROLE_ACCOUNT: + return LedgerProjectionRole.ACCOUNT; + case LEDGER_PROJECTION_ROLE_PORTFOLIO: + return LedgerProjectionRole.PORTFOLIO; + case LEDGER_PROJECTION_ROLE_SOURCE_ACCOUNT: + return LedgerProjectionRole.SOURCE_ACCOUNT; + case LEDGER_PROJECTION_ROLE_TARGET_ACCOUNT: + return LedgerProjectionRole.TARGET_ACCOUNT; + case LEDGER_PROJECTION_ROLE_SOURCE_PORTFOLIO: + return LedgerProjectionRole.SOURCE_PORTFOLIO; + case LEDGER_PROJECTION_ROLE_TARGET_PORTFOLIO: + return LedgerProjectionRole.TARGET_PORTFOLIO; + case LEDGER_PROJECTION_ROLE_DELIVERY: + return LedgerProjectionRole.DELIVERY; + case LEDGER_PROJECTION_ROLE_DELIVERY_INBOUND: + return LedgerProjectionRole.DELIVERY_INBOUND; + case LEDGER_PROJECTION_ROLE_DELIVERY_OUTBOUND: + return LedgerProjectionRole.DELIVERY_OUTBOUND; + case LEDGER_PROJECTION_ROLE_CASH_COMPENSATION: + return LedgerProjectionRole.CASH_COMPENSATION; + case LEDGER_PROJECTION_ROLE_OLD_SECURITY_LEG: + return LedgerProjectionRole.OLD_SECURITY_LEG; + case LEDGER_PROJECTION_ROLE_NEW_SECURITY_LEG: + return LedgerProjectionRole.NEW_SECURITY_LEG; + case LEDGER_PROJECTION_ROLE_UNSPECIFIED: + case UNRECOGNIZED: + default: + throw new UnsupportedOperationException(role.toString()); + } + } + + private static PLedgerParameterValueKind toProto(ValueKind valueKind) + { + switch (valueKind) + { + case STRING: + return PLedgerParameterValueKind.LEDGER_PARAMETER_VALUE_KIND_STRING; + case DECIMAL: + return PLedgerParameterValueKind.LEDGER_PARAMETER_VALUE_KIND_DECIMAL; + case LONG: + return PLedgerParameterValueKind.LEDGER_PARAMETER_VALUE_KIND_LONG; + case MONEY: + return PLedgerParameterValueKind.LEDGER_PARAMETER_VALUE_KIND_MONEY; + case SECURITY: + return PLedgerParameterValueKind.LEDGER_PARAMETER_VALUE_KIND_SECURITY; + case ACCOUNT: + return PLedgerParameterValueKind.LEDGER_PARAMETER_VALUE_KIND_ACCOUNT; + case PORTFOLIO: + return PLedgerParameterValueKind.LEDGER_PARAMETER_VALUE_KIND_PORTFOLIO; + case BOOLEAN: + return PLedgerParameterValueKind.LEDGER_PARAMETER_VALUE_KIND_BOOLEAN; + case LOCAL_DATE: + return PLedgerParameterValueKind.LEDGER_PARAMETER_VALUE_KIND_LOCAL_DATE; + case LOCAL_DATE_TIME: + return PLedgerParameterValueKind.LEDGER_PARAMETER_VALUE_KIND_LOCAL_DATE_TIME; + default: + throw new UnsupportedOperationException(valueKind.toString()); + } + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ProtobufWriter.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ProtobufWriter.java index 88223148dd..ac88598132 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ProtobufWriter.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ProtobufWriter.java @@ -14,18 +14,14 @@ import java.io.InputStream; import java.io.OutputStream; import java.math.BigDecimal; -import java.text.MessageFormat; import java.time.LocalDate; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collections; import java.util.EnumSet; import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; -import java.util.UUID; import java.util.stream.Collectors; import com.google.protobuf.Any; @@ -37,25 +33,6 @@ import name.abuchen.portfolio.model.ClientFactory.ClientPersister; import name.abuchen.portfolio.model.ConfigurationSet.Configuration; import name.abuchen.portfolio.model.SecurityEvent.DividendEvent; -import name.abuchen.portfolio.model.ledger.Ledger; -import name.abuchen.portfolio.model.ledger.LedgerDiagnosticMessageFormatter; -import name.abuchen.portfolio.model.ledger.LedgerEntry; -import name.abuchen.portfolio.model.ledger.LedgerParameter; -import name.abuchen.portfolio.model.ledger.LedgerParameter.ValueKind; -import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerPostingDirection; -import name.abuchen.portfolio.model.ledger.LedgerPostingSemanticRole; -import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; -import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; -import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; -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.model.ledger.legacy.LedgerMigrationDiagnostics; -import name.abuchen.portfolio.model.ledger.legacy.LegacyTransactionToLedgerMigrator; -import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; -import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionService; import name.abuchen.portfolio.model.proto.v1.PAccount; import name.abuchen.portfolio.model.proto.v1.PAnyValue; import name.abuchen.portfolio.model.proto.v1.PAttributeType; @@ -67,14 +44,7 @@ import name.abuchen.portfolio.model.proto.v1.PHistoricalPrice; import name.abuchen.portfolio.model.proto.v1.PInvestmentPlan; import name.abuchen.portfolio.model.proto.v1.PInvestmentPlan.Type; -import name.abuchen.portfolio.model.proto.v1.PInvestmentPlanLedgerExecutionRef; import name.abuchen.portfolio.model.proto.v1.PKeyValue; -import name.abuchen.portfolio.model.proto.v1.PLedger; -import name.abuchen.portfolio.model.proto.v1.PLedgerEntry; -import name.abuchen.portfolio.model.proto.v1.PLedgerParameter; -import name.abuchen.portfolio.model.proto.v1.PLedgerParameterValueKind; -import name.abuchen.portfolio.model.proto.v1.PLedgerPosting; -import name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRole; import name.abuchen.portfolio.model.proto.v1.PMap; import name.abuchen.portfolio.model.proto.v1.PPortfolio; import name.abuchen.portfolio.model.proto.v1.PSecurity; @@ -88,7 +58,7 @@ /* package */ class ProtobufWriter implements ClientPersister { - private static class Lookup + static class Lookup { private Map uuid2security = new HashMap<>(); @@ -128,7 +98,6 @@ void add(Account account) } private static final byte[] SIGNATURE = new byte[] { 'P', 'P', 'P', 'B', 'V', '1' }; - private static final String LEDGER_COMPATIBILITY_SHADOW_PREFIX = "ledger-shadow:"; //$NON-NLS-1$ @Override public Client load(InputStream input) throws IOException @@ -161,15 +130,9 @@ public Client load(InputStream input) throws IOException loadAccounts(newClient, client, lookup); loadPortfolios(newClient, client, lookup); - boolean hasLedgerTruth = hasLedgerTruth(newClient); - Set ledgerProjectionUUIDs = Collections.emptySet(); - if (hasLedgerTruth) - { - loadLedger(newClient.getLedger(), client, lookup); - ledgerProjectionUUIDs = ledgerProjectionUUIDs(client.getLedger()); - } + var ledgerLoadState = LedgerProtobufPersistenceSupport.loadLedgerTruth(newClient, client, lookup); - loadTransactions(newClient, lookup, ledgerProjectionUUIDs, hasLedgerTruth); + loadTransactions(newClient, lookup, ledgerLoadState); client.getProperties().putAll(newClient.getPropertiesMap()); loadTaxonomies(newClient, client, lookup); @@ -182,15 +145,7 @@ public Client load(InputStream input) throws IOException ClientFactory.upgradeModel(client); - if (hasLedgerTruth) - { - LedgerProjectionService.restoreIfValid(client); - LedgerMigrationDiagnostics.logMixedState(client, "protobuf-mixed-state"); //$NON-NLS-1$ - } - else - { - new LegacyTransactionToLedgerMigrator().migrate(client); - } + LedgerProtobufPersistenceSupport.finalizeAfterLoad(client, ledgerLoadState.hasLedgerTruth()); return client; } @@ -355,12 +310,12 @@ private void loadPortfolios(PClient newClient, Client client, Lookup lookup) } } - private void loadTransactions(PClient newClient, Lookup lookup, Set ledgerProjectionUUIDs, - boolean hasLedgerTruth) + private void loadTransactions(PClient newClient, Lookup lookup, + LedgerProtobufPersistenceSupport.LoadState ledgerLoadState) { for (PTransaction newTransaction : newClient.getTransactionsList()) { - if (isLedgerCompatibilityShadow(newTransaction, ledgerProjectionUUIDs, hasLedgerTruth)) + if (LedgerProtobufPersistenceSupport.isLedgerCompatibilityShadow(newTransaction, ledgerLoadState)) continue; PTransaction.Type type = newTransaction.getType(); @@ -600,21 +555,6 @@ private void loadTransactions(PClient newClient, Lookup lookup, Set ledg } } - private boolean isLedgerCompatibilityShadow(PTransaction newTransaction, Set ledgerProjectionUUIDs, - boolean hasLedgerTruth) - { - return hasLedgerTruth && (isLedgerCompatibilityShadowUUID(newTransaction.getUuid()) - || (newTransaction.hasOtherUuid() - && isLedgerCompatibilityShadowUUID(newTransaction.getOtherUuid()))) - || ledgerProjectionUUIDs.contains(newTransaction.getUuid()) - || (newTransaction.hasOtherUuid() && ledgerProjectionUUIDs.contains(newTransaction.getOtherUuid())); - } - - private boolean isLedgerCompatibilityShadowUUID(String uuid) - { - return uuid != null && uuid.startsWith(LEDGER_COMPATIBILITY_SHADOW_PREFIX); - } - private void loadCommonTransaction(PTransaction newTransaction, Transaction t, Lookup lookup, boolean requiresSecurity) { @@ -678,144 +618,6 @@ private void loadTransactionUnits(PTransaction newTransaction, Transaction t) } } - private boolean hasLedgerTruth(PClient newClient) - { - return newClient.hasLedger() && newClient.getLedger().getEntriesCount() > 0; - } - - private Set ledgerProjectionUUIDs(Ledger ledger) - { - return new HashSet<>(); - } - - private void loadLedger(PLedger newLedger, Client client, Lookup lookup) - { - for (PLedgerEntry newEntry : newLedger.getEntriesList()) - { - LedgerEntry entry = LedgerModelLoadSupport.newEntry(UUID.randomUUID().toString(), - LedgerEntryType.fromProtobufId(newEntry.getTypeId()), - fromTimestamp(newEntry.getDateTime())); - - if (newEntry.hasNote()) - LedgerModelLoadSupport.setEntryNote(entry, newEntry.getNote()); - if (newEntry.hasSource()) - LedgerModelLoadSupport.setEntrySource(entry, newEntry.getSource()); - if (newEntry.hasUpdatedAt()) - LedgerModelLoadSupport.setEntryUpdatedAt(entry, fromUpdatedAtTimestamp(newEntry.getUpdatedAt())); - if (newEntry.hasGeneratedByPlanKey()) - LedgerModelLoadSupport.setGeneratedByPlanKey(entry, newEntry.getGeneratedByPlanKey()); - if (newEntry.hasPlanExecutionDate()) - LedgerModelLoadSupport.setPlanExecutionDate(entry, - LocalDate.ofEpochDay(newEntry.getPlanExecutionDate())); - if (newEntry.hasPlanExecutionSequence()) - LedgerModelLoadSupport.setPlanExecutionSequence(entry, newEntry.getPlanExecutionSequence()); - if (newEntry.hasPreferredViewKind()) - LedgerModelLoadSupport.setPreferredViewKind(entry, newEntry.getPreferredViewKind()); - - for (PLedgerParameter newParameter : newEntry.getParametersList()) - LedgerModelLoadSupport.addEntryParameter(entry, loadLedgerParameter(newParameter, lookup)); - - for (PLedgerPosting newPosting : newEntry.getPostingsList()) - LedgerModelLoadSupport.addPosting(entry, loadLedgerPosting(newPosting, lookup)); - - if (newEntry.hasUpdatedAt()) - LedgerModelLoadSupport.setEntryUpdatedAt(entry, fromUpdatedAtTimestamp(newEntry.getUpdatedAt())); - - LedgerModelLoadSupport.addEntry(client.getLedger(), entry); - } - } - - private LedgerPosting loadLedgerPosting(PLedgerPosting newPosting, Lookup lookup) - { - LedgerPosting posting = LedgerModelLoadSupport.newPosting(UUID.randomUUID().toString(), - LedgerPostingType.fromCode(newPosting.getTypeCode())); - - LedgerModelLoadSupport.setPostingAmount(posting, newPosting.getAmount()); - if (newPosting.hasCurrency()) - LedgerModelLoadSupport.setPostingCurrency(posting, newPosting.getCurrency()); - if (newPosting.hasForexAmount()) - LedgerModelLoadSupport.setPostingForexAmount(posting, newPosting.getForexAmount()); - if (newPosting.hasForexCurrency()) - LedgerModelLoadSupport.setPostingForexCurrency(posting, newPosting.getForexCurrency()); - if (newPosting.hasExchangeRate()) - LedgerModelLoadSupport.setPostingExchangeRate(posting, - fromDecimalValue(newPosting.getExchangeRate())); - if (newPosting.hasSecurity()) - LedgerModelLoadSupport.setPostingSecurity(posting, lookup.getSecurity(newPosting.getSecurity())); - LedgerModelLoadSupport.setPostingShares(posting, newPosting.getShares()); - if (newPosting.hasAccount()) - LedgerModelLoadSupport.setPostingAccount(posting, lookup.getAccount(newPosting.getAccount())); - if (newPosting.hasPortfolio()) - LedgerModelLoadSupport.setPostingPortfolio(posting, lookup.getPortfolio(newPosting.getPortfolio())); - if (newPosting.hasSemanticRole()) - posting.setSemanticRole(LedgerPostingSemanticRole.valueOf(newPosting.getSemanticRole())); - if (newPosting.hasDirection()) - posting.setDirection(LedgerPostingDirection.valueOf(newPosting.getDirection())); - if (newPosting.hasCorporateActionLeg()) - posting.setCorporateActionLeg(corporateActionLeg(newPosting.getCorporateActionLeg())); - if (newPosting.hasUnitRole()) - posting.setUnitRole(LedgerPostingUnitRole.valueOf(newPosting.getUnitRole())); - if (newPosting.hasGroupKey()) - posting.setGroupKey(newPosting.getGroupKey()); - if (newPosting.hasLocalKey()) - posting.setLocalKey(newPosting.getLocalKey()); - - for (PLedgerParameter newParameter : newPosting.getParametersList()) - LedgerModelLoadSupport.addPostingParameter(posting, loadLedgerParameter(newParameter, lookup)); - - return posting; - } - - private CorporateActionLeg corporateActionLeg(String code) - { - for (var leg : CorporateActionLeg.values()) - if (leg.getCode().equals(code)) - return leg; - - return CorporateActionLeg.valueOf(code); - } - - private LedgerParameter loadLedgerParameter(PLedgerParameter newParameter, Lookup lookup) - { - LedgerParameterType type = LedgerParameterType.fromCode(newParameter.getTypeCode()); - - switch (newParameter.getValueKind()) - { - case LEDGER_PARAMETER_VALUE_KIND_STRING: - return LedgerParameter.ofString(type, newParameter.getStringValue()); - case LEDGER_PARAMETER_VALUE_KIND_DECIMAL: - return LedgerParameter.ofDecimal(type, fromDecimalValue(newParameter.getDecimalValue())); - case LEDGER_PARAMETER_VALUE_KIND_LONG: - return LedgerParameter.ofLong(type, newParameter.getLongValue()); - case LEDGER_PARAMETER_VALUE_KIND_MONEY: - return LedgerParameter.ofMoney(type, - Money.of(newParameter.getMoneyCurrency(), newParameter.getMoneyAmount())); - case LEDGER_PARAMETER_VALUE_KIND_SECURITY: - return LedgerParameter.ofSecurity(type, lookup.getSecurity(newParameter.getSecurity())); - case LEDGER_PARAMETER_VALUE_KIND_ACCOUNT: - return LedgerParameter.ofAccount(type, lookup.getAccount(newParameter.getAccount())); - case LEDGER_PARAMETER_VALUE_KIND_PORTFOLIO: - return LedgerParameter.ofPortfolio(type, lookup.getPortfolio(newParameter.getPortfolio())); - case LEDGER_PARAMETER_VALUE_KIND_BOOLEAN: - if (!newParameter.hasBooleanValue()) - throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PERSIST_009 - .message(Messages.LedgerProtobufBooleanParameterMissingBooleanValue)); - return LedgerParameter.ofBoolean(type, Boolean.valueOf(newParameter.getBooleanValue())); - case LEDGER_PARAMETER_VALUE_KIND_LOCAL_DATE: - if (!newParameter.hasLocalDateValue()) - throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PERSIST_010 - .message(Messages.LedgerProtobufLocalDateParameterMissingLocalDateValue)); - return LedgerParameter.ofLocalDate(type, LocalDate.ofEpochDay(newParameter.getLocalDateValue())); - case LEDGER_PARAMETER_VALUE_KIND_LOCAL_DATE_TIME: - return LedgerParameter.ofLocalDateTime(type, - fromLocalDateTime(newParameter.getLocalDateTimeValue())); - case LEDGER_PARAMETER_VALUE_KIND_UNSPECIFIED: - case UNRECOGNIZED: - default: - throw new UnsupportedOperationException(newParameter.getValueKind().toString()); - } - } - @SuppressWarnings("unchecked") private void loadSettings(PClient newClient, Client client) throws IOException { @@ -1042,49 +844,19 @@ private void loadInvestmentPlans(PClient newClient, Client client, Lookup lookup continue; } - if (markPlanExecutionForProjectionUUID(client, plan, uuid)) + if (LedgerProtobufPersistenceSupport.markPlanExecutionForProjectionUUID(client, plan, uuid)) continue; throw new UnsupportedOperationException(uuid); } - for (PInvestmentPlanLedgerExecutionRef newRef : newPlan.getLedgerExecutionRefsList()) - markPlanExecutionForLegacyRef(client, plan, newRef); + newPlan.getLedgerExecutionRefsList().forEach( + ref -> LedgerProtobufPersistenceSupport.markPlanExecutionForLegacyRef(client, plan, ref)); client.addPlan(plan); } } - private boolean markPlanExecutionForProjectionUUID(Client client, InvestmentPlan plan, String projectionUUID) - { - return false; - } - - private void markPlanExecutionForLegacyRef(Client client, InvestmentPlan plan, PInvestmentPlanLedgerExecutionRef ref) - { - for (LedgerEntry entry : client.getLedger().getEntries()) - { - if (!entry.getUUID().equals(ref.getLedgerEntryUUID())) - continue; - - var preferredViewKind = ref.hasProjectionRole() ? viewKind(fromProto(ref.getProjectionRole())) : null; - plan.markLedgerExecution(entry, entry.getDateTime().toLocalDate(), preferredViewKind); - return; - } - } - - private InvestmentPlan.LedgerExecutionViewKind viewKind(LedgerProjectionRole role) - { - if (role == null) - return null; - - return switch (role) - { - case ACCOUNT, SOURCE_ACCOUNT, TARGET_ACCOUNT, CASH_COMPENSATION -> InvestmentPlan.LedgerExecutionViewKind.ACCOUNT; - default -> InvestmentPlan.LedgerExecutionViewKind.PORTFOLIO; - }; - } - private void loadExtensions(PClient newClient, Client client) { // Load extension data from the Any fields @@ -1116,8 +888,8 @@ public void save(Client client, OutputStream output) throws IOException saveSecurities(client, newClient); saveAccounts(client, newClient); savePortfolios(client, newClient); - prepareInvestmentPlanLedgerExecutions(client); - saveLedger(client, newClient); + LedgerProtobufPersistenceSupport.prepareInvestmentPlanLedgerExecutions(client); + LedgerProtobufPersistenceSupport.saveLedger(client, newClient); saveTransactions(client, newClient); newClient.putAllProperties(client.getProperties()); @@ -1296,148 +1068,17 @@ private void savePortfolios(Client client, PClient.Builder newClient) } } - private void saveLedger(Client client, PClient.Builder newClient) - { - validateLedger(client); - - PLedger.Builder newLedger = PLedger.newBuilder(); - - for (LedgerEntry entry : client.getLedger().getEntries()) - newLedger.addEntries(saveLedgerEntry(entry)); - - newClient.setLedger(newLedger); - } - - private PLedgerEntry saveLedgerEntry(LedgerEntry entry) - { - PLedgerEntry.Builder newEntry = PLedgerEntry.newBuilder(); - - newEntry.setTypeId(entry.getType().getProtobufId()); - newEntry.setDateTime(asTimestamp(entry.getDateTime())); - - if (entry.getNote() != null) - newEntry.setNote(entry.getNote()); - if (entry.getSource() != null) - newEntry.setSource(entry.getSource()); - if (entry.getUpdatedAt() != null) - newEntry.setUpdatedAt(asUpdatedAtTimestamp(entry.getUpdatedAt())); - if (entry.getGeneratedByPlanKey() != null) - newEntry.setGeneratedByPlanKey(entry.getGeneratedByPlanKey()); - if (entry.getPlanExecutionDate() != null) - newEntry.setPlanExecutionDate(entry.getPlanExecutionDate().toEpochDay()); - if (entry.getPlanExecutionSequence() != null) - newEntry.setPlanExecutionSequence(entry.getPlanExecutionSequence()); - if (entry.getPreferredViewKind() != null) - newEntry.setPreferredViewKind(entry.getPreferredViewKind()); - - for (LedgerParameter parameter : entry.getParameters()) - newEntry.addParameters(saveLedgerParameter(parameter)); - - for (LedgerPosting posting : entry.getPostings()) - newEntry.addPostings(saveLedgerPosting(posting)); - - return newEntry.build(); - } - - private PLedgerPosting saveLedgerPosting(LedgerPosting posting) - { - PLedgerPosting.Builder newPosting = PLedgerPosting.newBuilder(); - - newPosting.setTypeCode(posting.getType().getCode()); - newPosting.setAmount(posting.getAmount()); - - if (posting.getCurrency() != null) - newPosting.setCurrency(posting.getCurrency()); - if (posting.getForexAmount() != null) - newPosting.setForexAmount(posting.getForexAmount()); - if (posting.getForexCurrency() != null) - newPosting.setForexCurrency(posting.getForexCurrency()); - if (posting.getExchangeRate() != null) - newPosting.setExchangeRate(asDecimalValue(posting.getExchangeRate())); - if (posting.getSecurity() != null) - newPosting.setSecurity(posting.getSecurity().getUUID()); - newPosting.setShares(posting.getShares()); - if (posting.getAccount() != null) - newPosting.setAccount(posting.getAccount().getUUID()); - if (posting.getPortfolio() != null) - newPosting.setPortfolio(posting.getPortfolio().getUUID()); - if (posting.getSemanticRole() != null) - newPosting.setSemanticRole(posting.getSemanticRole().name()); - if (posting.getDirection() != null) - newPosting.setDirection(posting.getDirection().name()); - if (posting.getCorporateActionLeg() != null) - newPosting.setCorporateActionLeg(posting.getCorporateActionLeg().getCode()); - if (posting.getUnitRole() != null) - newPosting.setUnitRole(posting.getUnitRole().name()); - if (posting.getGroupKey() != null) - newPosting.setGroupKey(posting.getGroupKey()); - if (posting.getLocalKey() != null) - newPosting.setLocalKey(posting.getLocalKey()); - - for (LedgerParameter parameter : posting.getParameters()) - newPosting.addParameters(saveLedgerParameter(parameter)); - - return newPosting.build(); - } - - private PLedgerParameter saveLedgerParameter(LedgerParameter parameter) - { - PLedgerParameter.Builder newParameter = PLedgerParameter.newBuilder(); - - newParameter.setTypeCode(parameter.getType().getCode()); - newParameter.setValueKind(toProto(parameter.getValueKind())); - - switch (parameter.getValueKind()) - { - case STRING: - newParameter.setStringValue((String) parameter.getValue()); - break; - case DECIMAL: - newParameter.setDecimalValue(asDecimalValue((BigDecimal) parameter.getValue())); - break; - case LONG: - newParameter.setLongValue((Long) parameter.getValue()); - break; - case MONEY: - Money money = (Money) parameter.getValue(); - newParameter.setMoneyAmount(money.getAmount()); - newParameter.setMoneyCurrency(money.getCurrencyCode()); - break; - case SECURITY: - newParameter.setSecurity(((Security) parameter.getValue()).getUUID()); - break; - case ACCOUNT: - newParameter.setAccount(((Account) parameter.getValue()).getUUID()); - break; - case PORTFOLIO: - newParameter.setPortfolio(((Portfolio) parameter.getValue()).getUUID()); - break; - case BOOLEAN: - newParameter.setBooleanValue(((Boolean) parameter.getValue()).booleanValue()); - break; - case LOCAL_DATE: - newParameter.setLocalDateValue(((LocalDate) parameter.getValue()).toEpochDay()); - break; - case LOCAL_DATE_TIME: - newParameter.setLocalDateTimeValue(asLocalDateTime((java.time.LocalDateTime) parameter.getValue())); - break; - default: - throw new UnsupportedOperationException(parameter.getValueKind().toString()); - } - - return newParameter.build(); - } - private void saveTransactions(Client client, PClient.Builder newClient) { - Set ledgerProjectionUUIDs = ledgerProjectionUUIDs(client.getLedger()); - saveLedgerCompatibilityShadows(client, newClient); + Set ledgerProjectionUUIDs = LedgerProtobufPersistenceSupport.ledgerProjectionUUIDs(client.getLedger()); + LedgerProtobufPersistenceSupport.saveLedgerCompatibilityShadows(client, newClient, this); for (Portfolio portfolio : client.getPortfolios()) { portfolio.getTransactions().stream() .filter(t -> t.getType() != PortfolioTransaction.Type.TRANSFER_IN) - .filter(t -> shouldSaveLegacyTransaction(t, ledgerProjectionUUIDs)) + .filter(t -> LedgerProtobufPersistenceSupport.shouldSaveLegacyTransaction(t, + ledgerProjectionUUIDs)) .forEach(t -> addTransaction(newClient, portfolio, t)); } @@ -1448,95 +1089,42 @@ private void saveTransactions(Client client, PClient.Builder newClient) { account.getTransactions().stream() // .filter(t -> !exclude.contains(t.getType())) - .filter(t -> shouldSaveLegacyTransaction(t, ledgerProjectionUUIDs)) + .filter(t -> LedgerProtobufPersistenceSupport.shouldSaveLegacyTransaction(t, + ledgerProjectionUUIDs)) .forEach(t -> addTransaction(newClient, account, t)); } } - private void saveLedgerCompatibilityShadows(Client client, PClient.Builder newClient) - { - for (LedgerEntry entry : client.getLedger().getEntries()) - { - if (!entry.getType().isLegacyFixedShape()) - continue; - - for (Transaction transaction : LedgerProjectionService.createProjections(entry)) - { - if (!shouldSaveLedgerCompatibilityShadow(transaction)) - continue; - - LedgerBackedTransaction ledgerBackedTransaction = (LedgerBackedTransaction) transaction; - - if (transaction instanceof AccountTransaction accountTransaction) - addTransaction(newClient, ledgerBackedTransaction.getLedgerProjectionDescriptor().getAccount(), - accountTransaction); - else if (transaction instanceof PortfolioTransaction portfolioTransaction) - addTransaction(newClient, ledgerBackedTransaction.getLedgerProjectionDescriptor().getPortfolio(), - portfolioTransaction); - else - throw new UnsupportedOperationException(transaction.getClass().getName()); - } - } - } - - private boolean shouldSaveLedgerCompatibilityShadow(Transaction transaction) - { - if (transaction instanceof AccountTransaction accountTransaction) - return accountTransaction.getType() != AccountTransaction.Type.BUY - && accountTransaction.getType() != AccountTransaction.Type.SELL - && accountTransaction.getType() != AccountTransaction.Type.TRANSFER_IN; - - if (transaction instanceof PortfolioTransaction portfolioTransaction) - return portfolioTransaction.getType() != PortfolioTransaction.Type.TRANSFER_IN; - - return false; - } - - private boolean shouldSaveLegacyTransaction(Transaction transaction, Set ledgerProjectionUUIDs) - { - return !(transaction instanceof LedgerBackedTransaction) // - && !isLedgerCompatibilityShadowUUID(transaction.getUUID()) // - && !ledgerProjectionUUIDs.contains(transaction.getUUID()); - } - - private String protobufTransactionUUID(Transaction transaction) - { - if (transaction instanceof LedgerBackedTransaction ledgerBackedTransaction) - { - return LEDGER_COMPATIBILITY_SHADOW_PREFIX + ledgerBackedTransaction.getLedgerEntry().getUUID() - + ":" + ledgerBackedTransaction.getLedgerProjectionRole(); //$NON-NLS-1$ - } - - return transaction.getUUID(); - } - - private void addTransaction(PClient.Builder newClient, Portfolio portfolio, PortfolioTransaction t) + void addTransaction(PClient.Builder newClient, Portfolio portfolio, PortfolioTransaction t) { PTransaction.Builder newTransaction = PTransaction.newBuilder(); newTransaction.setPortfolio(portfolio.getUUID()); - newTransaction.setUuid(protobufTransactionUUID(t)); + newTransaction.setUuid(LedgerProtobufPersistenceSupport.protobufTransactionUUID(t)); switch (t.getType()) { case BUY: newTransaction.setTypeValue(PTransaction.Type.PURCHASE_VALUE); newTransaction.setAccount(t.getCrossEntry().getCrossOwner(t).getUUID()); - newTransaction.setOtherUuid(protobufTransactionUUID(t.getCrossEntry().getCrossTransaction(t))); + newTransaction.setOtherUuid(LedgerProtobufPersistenceSupport.protobufTransactionUUID( + t.getCrossEntry().getCrossTransaction(t))); newTransaction.setOtherUpdatedAt( asUpdatedAtTimestamp(t.getCrossEntry().getCrossTransaction(t).getUpdatedAt())); break; case SELL: newTransaction.setTypeValue(PTransaction.Type.SALE_VALUE); newTransaction.setAccount(t.getCrossEntry().getCrossOwner(t).getUUID()); - newTransaction.setOtherUuid(protobufTransactionUUID(t.getCrossEntry().getCrossTransaction(t))); + newTransaction.setOtherUuid(LedgerProtobufPersistenceSupport.protobufTransactionUUID( + t.getCrossEntry().getCrossTransaction(t))); newTransaction.setOtherUpdatedAt( asUpdatedAtTimestamp(t.getCrossEntry().getCrossTransaction(t).getUpdatedAt())); break; case TRANSFER_OUT: newTransaction.setTypeValue(PTransaction.Type.SECURITY_TRANSFER_VALUE); newTransaction.setOtherPortfolio(t.getCrossEntry().getCrossOwner(t).getUUID()); - newTransaction.setOtherUuid(protobufTransactionUUID(t.getCrossEntry().getCrossTransaction(t))); + newTransaction.setOtherUuid(LedgerProtobufPersistenceSupport.protobufTransactionUUID( + t.getCrossEntry().getCrossTransaction(t))); newTransaction.setOtherUpdatedAt( asUpdatedAtTimestamp(t.getCrossEntry().getCrossTransaction(t).getUpdatedAt())); break; @@ -1556,11 +1144,11 @@ private void addTransaction(PClient.Builder newClient, Portfolio portfolio, Port newClient.addTransactions(newTransaction.build()); } - private void addTransaction(PClient.Builder newClient, Account account, AccountTransaction t) + void addTransaction(PClient.Builder newClient, Account account, AccountTransaction t) { PTransaction.Builder newTransaction = PTransaction.newBuilder(); newTransaction.setAccount(account.getUUID()); - newTransaction.setUuid(protobufTransactionUUID(t)); + newTransaction.setUuid(LedgerProtobufPersistenceSupport.protobufTransactionUUID(t)); switch (t.getType()) { @@ -1596,7 +1184,8 @@ private void addTransaction(PClient.Builder newClient, Account account, AccountT case TRANSFER_OUT: newTransaction.setTypeValue(PTransaction.Type.CASH_TRANSFER_VALUE); newTransaction.setOtherAccount(t.getCrossEntry().getCrossOwner(t).getUUID()); - newTransaction.setOtherUuid(protobufTransactionUUID(t.getCrossEntry().getCrossTransaction(t))); + newTransaction.setOtherUuid(LedgerProtobufPersistenceSupport.protobufTransactionUUID( + t.getCrossEntry().getCrossTransaction(t))); newTransaction.setOtherUpdatedAt( asUpdatedAtTimestamp(t.getCrossEntry().getCrossTransaction(t).getUpdatedAt())); break; @@ -1861,9 +1450,7 @@ private void saveInvestmentPlans(Client client, PClient.Builder newClient) } plan.getTransactions().forEach(t -> { - if (t instanceof LedgerBackedTransaction ledgerBackedTransaction) - plan.markLedgerExecution(ledgerBackedTransaction); - else + if (!LedgerProtobufPersistenceSupport.markPlanExecution(plan, t)) newPlan.addTransactions(t.getUUID()); }); @@ -1871,95 +1458,6 @@ private void saveInvestmentPlans(Client client, PClient.Builder newClient) }); } - private void prepareInvestmentPlanLedgerExecutions(Client client) - { - for (var plan : client.getPlans()) - for (var transaction : plan.getTransactions()) - if (transaction instanceof LedgerBackedTransaction ledgerBackedTransaction) - plan.markLedgerExecution(ledgerBackedTransaction); - } - - private void validateLedger(Client client) - { - var ledger = client.getLedger(); - var result = LedgerStructuralValidator.validate(ledger); - - if (!result.isOK()) - { - LedgerProjectionService.logSkipped(ledger, result); - throw new UnsupportedOperationException( - LedgerDiagnosticCode.LEDGER_PERSIST_002 - .message(MessageFormat.format( - Messages.LedgerProtobufInvalidLedgerPersistenceState, - LedgerDiagnosticMessageFormatter.formatValidationResult( - ledger, result)))); - } - } - - private LedgerProjectionRole fromProto(PLedgerProjectionRole role) - { - switch (role) - { - case LEDGER_PROJECTION_ROLE_ACCOUNT: - return LedgerProjectionRole.ACCOUNT; - case LEDGER_PROJECTION_ROLE_PORTFOLIO: - return LedgerProjectionRole.PORTFOLIO; - case LEDGER_PROJECTION_ROLE_SOURCE_ACCOUNT: - return LedgerProjectionRole.SOURCE_ACCOUNT; - case LEDGER_PROJECTION_ROLE_TARGET_ACCOUNT: - return LedgerProjectionRole.TARGET_ACCOUNT; - case LEDGER_PROJECTION_ROLE_SOURCE_PORTFOLIO: - return LedgerProjectionRole.SOURCE_PORTFOLIO; - case LEDGER_PROJECTION_ROLE_TARGET_PORTFOLIO: - return LedgerProjectionRole.TARGET_PORTFOLIO; - case LEDGER_PROJECTION_ROLE_DELIVERY: - return LedgerProjectionRole.DELIVERY; - case LEDGER_PROJECTION_ROLE_DELIVERY_INBOUND: - return LedgerProjectionRole.DELIVERY_INBOUND; - case LEDGER_PROJECTION_ROLE_DELIVERY_OUTBOUND: - return LedgerProjectionRole.DELIVERY_OUTBOUND; - case LEDGER_PROJECTION_ROLE_CASH_COMPENSATION: - return LedgerProjectionRole.CASH_COMPENSATION; - case LEDGER_PROJECTION_ROLE_OLD_SECURITY_LEG: - return LedgerProjectionRole.OLD_SECURITY_LEG; - case LEDGER_PROJECTION_ROLE_NEW_SECURITY_LEG: - return LedgerProjectionRole.NEW_SECURITY_LEG; - case LEDGER_PROJECTION_ROLE_UNSPECIFIED: - case UNRECOGNIZED: - default: - throw new UnsupportedOperationException(role.toString()); - } - } - - private PLedgerParameterValueKind toProto(ValueKind valueKind) - { - switch (valueKind) - { - case STRING: - return PLedgerParameterValueKind.LEDGER_PARAMETER_VALUE_KIND_STRING; - case DECIMAL: - return PLedgerParameterValueKind.LEDGER_PARAMETER_VALUE_KIND_DECIMAL; - case LONG: - return PLedgerParameterValueKind.LEDGER_PARAMETER_VALUE_KIND_LONG; - case MONEY: - return PLedgerParameterValueKind.LEDGER_PARAMETER_VALUE_KIND_MONEY; - case SECURITY: - return PLedgerParameterValueKind.LEDGER_PARAMETER_VALUE_KIND_SECURITY; - case ACCOUNT: - return PLedgerParameterValueKind.LEDGER_PARAMETER_VALUE_KIND_ACCOUNT; - case PORTFOLIO: - return PLedgerParameterValueKind.LEDGER_PARAMETER_VALUE_KIND_PORTFOLIO; - case BOOLEAN: - return PLedgerParameterValueKind.LEDGER_PARAMETER_VALUE_KIND_BOOLEAN; - case LOCAL_DATE: - return PLedgerParameterValueKind.LEDGER_PARAMETER_VALUE_KIND_LOCAL_DATE; - case LOCAL_DATE_TIME: - return PLedgerParameterValueKind.LEDGER_PARAMETER_VALUE_KIND_LOCAL_DATE_TIME; - default: - throw new UnsupportedOperationException(valueKind.toString()); - } - } - private void saveExtensions(Client client, PClient.Builder newClient) { // Save extension data to the Any fields From 78f5d91833bc5779bc9b1d7572b1b3117ca6eb55 Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:26:54 +0200 Subject: [PATCH 30/68] Wrap Ledger import insertion support Move Ledger-specific import insertion and generated InvestmentPlan update logic out of InsertAction into LedgerImportInsertionSupport. This keeps InsertAction close to the original import insertion control flow while preserving semantic no-UUID Ledger creation at the insertion boundary. DetectDuplicatesAction, XML/protobuf persistence, Ledger model semantics, migration diagnostics, InvestmentPlan linkage, UI routing, and Corporate Action behavior are intentionally unchanged. --- .../actions/InsertActionTest.java | 11 +- .../datatransfer/actions/InsertAction.java | 226 +++-------------- .../actions/LedgerImportInsertionSupport.java | 228 ++++++++++++++++++ 3 files changed, 269 insertions(+), 196 deletions(-) create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/datatransfer/actions/LedgerImportInsertionSupport.java diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/datatransfer/actions/InsertActionTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/datatransfer/actions/InsertActionTest.java index bb112358d3..cfbc1129cc 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/datatransfer/actions/InsertActionTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/datatransfer/actions/InsertActionTest.java @@ -14,7 +14,6 @@ import java.beans.PropertyDescriptor; import java.io.ByteArrayInputStream; import java.io.File; -import java.lang.reflect.InvocationTargetException; import java.math.BigDecimal; import java.nio.file.Files; import java.time.Instant; @@ -408,14 +407,12 @@ public void testInvestmentPlanGeneratedLedgerBuyRejectsImportedSellWithDiagnosti imported.setDate(generated.getDateTime()); imported.setSecurity(security); - InsertAction insert = new InsertAction(client); - var update = InsertAction.class.getDeclaredMethod("updateLedgerBackedInvestmentPlanTransaction", - Transaction.class, BuySellEntry.class); - update.setAccessible(true); + LedgerImportInsertionSupport insert = new LedgerImportInsertionSupport(client); - var exception = assertThrows(InvocationTargetException.class, () -> update.invoke(insert, generated, imported)); + var exception = assertThrows(UnsupportedOperationException.class, + () -> insert.updateLedgerBackedInvestmentPlanTransaction(generated, imported)); - assertThat(exception.getCause().getMessage(), is(LedgerDiagnosticCode.LEDGER_UI_006.message( + assertThat(exception.getMessage(), is(LedgerDiagnosticCode.LEDGER_UI_006.message( Messages.LedgerInsertActionGeneratedBuyTypeMismatch))); } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/datatransfer/actions/InsertAction.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/datatransfer/actions/InsertAction.java index 18b9a8bac7..7860e92d20 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/datatransfer/actions/InsertAction.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/datatransfer/actions/InsertAction.java @@ -1,36 +1,21 @@ package name.abuchen.portfolio.datatransfer.actions; -import java.text.MessageFormat; -import java.util.ArrayList; -import java.util.Iterator; -import java.util.List; - -import name.abuchen.portfolio.Messages; import name.abuchen.portfolio.datatransfer.ImportAction; import name.abuchen.portfolio.model.Account; import name.abuchen.portfolio.model.AccountTransaction; import name.abuchen.portfolio.model.AccountTransferEntry; import name.abuchen.portfolio.model.BuySellEntry; import name.abuchen.portfolio.model.Client; -import name.abuchen.portfolio.model.InvestmentPlan; -import name.abuchen.portfolio.model.LedgerDiagnosticCode; import name.abuchen.portfolio.model.Portfolio; import name.abuchen.portfolio.model.PortfolioTransaction; import name.abuchen.portfolio.model.PortfolioTransferEntry; import name.abuchen.portfolio.model.Security; import name.abuchen.portfolio.model.SecurityPrice; -import name.abuchen.portfolio.model.Transaction; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountOnlyTransactionCreator; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountTransferTransactionCreator; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerBuySellTransactionCreator; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerDeliveryTransactionCreator; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerDividendTransactionCreator; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferTransactionCreator; -import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; public class InsertAction implements ImportAction { private final Client client; + private final LedgerImportInsertionSupport ledgerImportInsertionSupport; private boolean convertBuySellToDelivery = false; private boolean removeDividends = false; private boolean investmentPlanItem = false; @@ -38,6 +23,7 @@ public class InsertAction implements ImportAction public InsertAction(Client client) { this.client = client; + this.ledgerImportInsertionSupport = new LedgerImportInsertionSupport(client); } public void setConvertBuySellToDelivery(boolean flag) @@ -78,27 +64,16 @@ public Status process(AccountTransaction transaction, Account account) // security via the dialog) if (transaction.getSecurity() != null) process(transaction.getSecurity()); - - if (transaction.getType() == AccountTransaction.Type.DIVIDENDS) - { - new LedgerDividendTransactionCreator(client).create(account, transaction.getDateTime(), - transaction.getAmount(), transaction.getCurrencyCode(), transaction.getSecurity(), - transaction.getShares(), transaction.getExDate(), null, null, transaction.getUnits().toList(), - transaction.getNote(), transaction.getSource()); - } - else - { - new LedgerAccountOnlyTransactionCreator(client).create(account, transaction.getType(), - transaction.getDateTime(), transaction.getAmount(), transaction.getCurrencyCode(), - transaction.getSecurity(), transaction.getUnits().toList(), transaction.getNote(), - transaction.getSource()); - } + ledgerImportInsertionSupport.insert(transaction, account); if (removeDividends && transaction.getType() == AccountTransaction.Type.DIVIDENDS) { - new LedgerAccountOnlyTransactionCreator(client).create(account, AccountTransaction.Type.REMOVAL, - transaction.getDateTime(), transaction.getAmount(), transaction.getCurrencyCode(), null, - List.of(), transaction.getNote(), transaction.getSource()); + AccountTransaction removal = new AccountTransaction(transaction.getDateTime(), + transaction.getCurrencyCode(), transaction.getAmount(), null, + AccountTransaction.Type.REMOVAL); + removal.setNote(transaction.getNote()); + removal.setSource(transaction.getSource()); + ledgerImportInsertionSupport.insert(removal, account); } return Status.OK_STATUS; @@ -110,11 +85,7 @@ public Status process(PortfolioTransaction transaction, Portfolio portfolio) // ensure consistency (in case the user deleted the creation of the // security via the dialog) process(transaction.getSecurity()); - - new LedgerDeliveryTransactionCreator(client).create(portfolio, transaction.getType(), transaction.getDateTime(), - transaction.getAmount(), transaction.getCurrencyCode(), transaction.getSecurity(), - transaction.getShares(), null, null, transaction.getUnits().toList(), transaction.getNote(), - transaction.getSource()); + ledgerImportInsertionSupport.insert(transaction, portfolio); return Status.OK_STATUS; } @@ -129,165 +100,45 @@ public Status process(BuySellEntry entry, Account account, Portfolio portfolio) // investment plan, find the existing item and update it if (investmentPlanItem) { - DetectDuplicatesAction action = new DetectDuplicatesAction(client); - List matchingInvestmentPlanTransactions = new ArrayList<>(); - PortfolioTransaction t = entry.getPortfolioTransaction(); - - // search for a match in existing investment plan transactions - List plans = client.getPlans(); - Iterator i = plans.stream() - .filter(p -> p.getSecurity() != null && p.getSecurity().equals(t.getSecurity())).iterator(); - - while (i.hasNext()) - { - List transactions = i.next().getTransactions(client).stream() // - .map(pair -> (Transaction) pair.getTransaction()).toList(); - matchingInvestmentPlanTransactions.addAll(action.findInvestmentPlanTransactions(t, transactions)); - } - - if (matchingInvestmentPlanTransactions.size() > 1) - return new Status(Status.Code.WARNING, Messages.LabelPotentialDuplicate); - - if (matchingInvestmentPlanTransactions.size() == 1) - { - Transaction existingTransaction = matchingInvestmentPlanTransactions.get(0); - if (existingTransaction instanceof LedgerBackedTransaction) - { - updateLedgerBackedInvestmentPlanTransaction(existingTransaction, entry); - return Status.OK_STATUS; - } - - existingTransaction.setDateTime(t.getDateTime()); - existingTransaction.setNote(t.getNote()); - existingTransaction.setSource(t.getSource()); - existingTransaction.setShares(t.getShares()); - existingTransaction.setAmount(t.getAmount()); - existingTransaction.clearUnits(); - t.getUnits().forEach(existingTransaction::addUnit); - - if (existingTransaction.getCrossEntry() != null) - { - Transaction crossTransaction = existingTransaction.getCrossEntry() - .getCrossTransaction(existingTransaction); - crossTransaction.setDateTime(t.getDateTime()); - crossTransaction.setAmount(t.getAmount()); - crossTransaction.setNote(t.getNote()); - crossTransaction.setSource(t.getSource()); - } - return Status.OK_STATUS; - } + Status status = ledgerImportInsertionSupport.updateInvestmentPlanItemIfPresent(entry); + if (status != null) + return status; } if (convertBuySellToDelivery) { PortfolioTransaction t = entry.getPortfolioTransaction(); - new LedgerDeliveryTransactionCreator(client).create(portfolio, - t.getType() == PortfolioTransaction.Type.BUY ? PortfolioTransaction.Type.DELIVERY_INBOUND - : PortfolioTransaction.Type.DELIVERY_OUTBOUND, - t.getDateTime(), t.getAmount(), t.getCurrencyCode(), t.getSecurity(), t.getShares(), null, - null, t.getUnits().toList(), t.getNote(), t.getSource()); - } - else - { - PortfolioTransaction t = entry.getPortfolioTransaction(); - new LedgerBuySellTransactionCreator(client).create(portfolio, account, t.getType(), t.getDateTime(), - t.getAmount(), t.getCurrencyCode(), t.getSecurity(), t.getShares(), t.getUnits().toList(), - t.getNote(), t.getSource()); - } + PortfolioTransaction delivery = new PortfolioTransaction(); + delivery.setType(t.getType() == PortfolioTransaction.Type.BUY ? PortfolioTransaction.Type.DELIVERY_INBOUND + : PortfolioTransaction.Type.DELIVERY_OUTBOUND); - return Status.OK_STATUS; - } + delivery.setDateTime(t.getDateTime()); + delivery.setSecurity(t.getSecurity()); + delivery.setMonetaryAmount(t.getMonetaryAmount()); + delivery.setNote(t.getNote()); + delivery.setSource(t.getSource()); + delivery.setShares(t.getShares()); + delivery.addUnits(t.getUnits()); - private void updateLedgerBackedInvestmentPlanTransaction(Transaction existingTransaction, BuySellEntry importedEntry) - { - if (!(existingTransaction instanceof PortfolioTransaction existingPortfolioTransaction)) - throw new UnsupportedOperationException( - LedgerDiagnosticCode.LEDGER_UI_003.message( - Messages.LedgerInsertActionInvestmentPlanLegacySettersNotSupported)); - - PortfolioTransaction importedPortfolioTransaction = importedEntry.getPortfolioTransaction(); - - switch (existingPortfolioTransaction.getType()) + ledgerImportInsertionSupport.insert(delivery, portfolio); + return Status.OK_STATUS; + } + else { - case BUY: - updateGeneratedBuy(existingPortfolioTransaction, importedPortfolioTransaction); - break; - case DELIVERY_INBOUND: - updateGeneratedInboundDelivery(existingPortfolioTransaction, importedPortfolioTransaction); - break; - case SELL, DELIVERY_OUTBOUND, TRANSFER_IN, TRANSFER_OUT: - throw new UnsupportedOperationException( - LedgerDiagnosticCode.LEDGER_UI_004.message(MessageFormat.format( - Messages.LedgerInsertActionUnsupportedInvestmentPlanTransactionUpdateType, - existingPortfolioTransaction.getType()))); - default: - throw new UnsupportedOperationException( - LedgerDiagnosticCode.LEDGER_UI_005.message(MessageFormat.format( - Messages.LedgerInsertActionUnsupportedInvestmentPlanTransactionUpdateType, - existingPortfolioTransaction.getType()))); + entry.setPortfolio(portfolio); + entry.setAccount(account); + ledgerImportInsertionSupport.insert(entry, account, portfolio); + return Status.OK_STATUS; } } - private void updateGeneratedBuy(PortfolioTransaction existingPortfolioTransaction, - PortfolioTransaction importedPortfolioTransaction) - { - if (importedPortfolioTransaction.getType() != PortfolioTransaction.Type.BUY) - throw new UnsupportedOperationException( - LedgerDiagnosticCode.LEDGER_UI_006.message( - Messages.LedgerInsertActionGeneratedBuyTypeMismatch)); - - if (!(existingPortfolioTransaction.getCrossEntry() instanceof BuySellEntry existingEntry)) - throw new UnsupportedOperationException( - LedgerDiagnosticCode.LEDGER_UI_007 - .message(Messages.LedgerInsertActionGeneratedBuyMissingBuySellEntry)); - - new LedgerBuySellTransactionCreator(client).update(existingEntry, existingEntry.getPortfolio(), - existingEntry.getAccount(), PortfolioTransaction.Type.BUY, - importedPortfolioTransaction.getDateTime(), importedPortfolioTransaction.getAmount(), - importedPortfolioTransaction.getCurrencyCode(), importedPortfolioTransaction.getSecurity(), - importedPortfolioTransaction.getShares(), importedPortfolioTransaction.getUnits().toList(), - importedPortfolioTransaction.getNote(), importedPortfolioTransaction.getSource()); - } - - private void updateGeneratedInboundDelivery(PortfolioTransaction existingPortfolioTransaction, - PortfolioTransaction importedPortfolioTransaction) - { - if (importedPortfolioTransaction.getType() != PortfolioTransaction.Type.BUY) - throw new UnsupportedOperationException( - LedgerDiagnosticCode.LEDGER_UI_008.message( - Messages.LedgerInsertActionGeneratedDeliveryTypeMismatch)); - - new LedgerDeliveryTransactionCreator(client).update(existingPortfolioTransaction, - ownerOf(existingPortfolioTransaction), PortfolioTransaction.Type.DELIVERY_INBOUND, - importedPortfolioTransaction.getDateTime(), importedPortfolioTransaction.getAmount(), - importedPortfolioTransaction.getCurrencyCode(), importedPortfolioTransaction.getSecurity(), - importedPortfolioTransaction.getShares(), null, null, - importedPortfolioTransaction.getUnits().toList(), importedPortfolioTransaction.getNote(), - importedPortfolioTransaction.getSource()); - } - - private Portfolio ownerOf(PortfolioTransaction transaction) - { - return client.getPortfolios().stream().filter(portfolio -> portfolio.getTransactions().contains(transaction)) - .findFirst() - .orElseThrow(() -> new IllegalStateException( - Messages.LedgerInsertActionGeneratedDeliveryOwnerNotFound)); - } - @Override public Status process(AccountTransferEntry entry, Account source, Account target) { - var sourceTransaction = entry.getSourceTransaction(); - var targetTransaction = entry.getTargetTransaction(); - var sourceForex = sourceTransaction.getUnit(Transaction.Unit.Type.GROSS_VALUE); - - new LedgerAccountTransferTransactionCreator(client).create(source, target, sourceTransaction.getDateTime(), - sourceTransaction.getAmount(), sourceTransaction.getCurrencyCode(), targetTransaction.getAmount(), - targetTransaction.getCurrencyCode(), sourceForex.map(Transaction.Unit::getForex).orElse(null), - sourceForex.map(Transaction.Unit::getExchangeRate).orElse(null), sourceTransaction.getNote(), - sourceTransaction.getSource()); - + entry.setSourceAccount(source); + entry.setTargetAccount(target); + ledgerImportInsertionSupport.insert(entry, source, target); return Status.OK_STATUS; } @@ -298,12 +149,9 @@ public Status process(PortfolioTransferEntry entry, Portfolio source, Portfolio // security via the dialog) process(entry.getSourceTransaction().getSecurity()); - var sourceTransaction = entry.getSourceTransaction(); - - new LedgerPortfolioTransferTransactionCreator(client).create(source, target, sourceTransaction.getSecurity(), - sourceTransaction.getDateTime(), sourceTransaction.getShares(), sourceTransaction.getAmount(), - sourceTransaction.getCurrencyCode(), sourceTransaction.getNote(), sourceTransaction.getSource()); - + entry.setSourcePortfolio(source); + entry.setTargetPortfolio(target); + ledgerImportInsertionSupport.insert(entry, source, target); return Status.OK_STATUS; } } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/datatransfer/actions/LedgerImportInsertionSupport.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/datatransfer/actions/LedgerImportInsertionSupport.java new file mode 100644 index 0000000000..132ff40af5 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/datatransfer/actions/LedgerImportInsertionSupport.java @@ -0,0 +1,228 @@ +package name.abuchen.portfolio.datatransfer.actions; + +import java.text.MessageFormat; +import java.util.ArrayList; +import java.util.List; + +import name.abuchen.portfolio.Messages; +import name.abuchen.portfolio.datatransfer.ImportAction.Status; +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.AccountTransferEntry; +import name.abuchen.portfolio.model.BuySellEntry; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.InvestmentPlan; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.PortfolioTransaction; +import name.abuchen.portfolio.model.PortfolioTransferEntry; +import name.abuchen.portfolio.model.Transaction; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountOnlyTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountTransferTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerBuySellTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerDeliveryTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerDividendTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferTransactionCreator; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; + +final class LedgerImportInsertionSupport +{ + private final Client client; + + LedgerImportInsertionSupport(Client client) + { + this.client = client; + } + + void insert(AccountTransaction transaction, Account account) + { + if (transaction.getType() == AccountTransaction.Type.DIVIDENDS) + { + new LedgerDividendTransactionCreator(client).create(account, transaction.getDateTime(), + transaction.getAmount(), transaction.getCurrencyCode(), transaction.getSecurity(), + transaction.getShares(), transaction.getExDate(), null, null, transaction.getUnits().toList(), + transaction.getNote(), transaction.getSource()); + } + else + { + new LedgerAccountOnlyTransactionCreator(client).create(account, transaction.getType(), + transaction.getDateTime(), transaction.getAmount(), transaction.getCurrencyCode(), + transaction.getSecurity(), transaction.getUnits().toList(), transaction.getNote(), + transaction.getSource()); + } + } + + void insert(PortfolioTransaction transaction, Portfolio portfolio) + { + new LedgerDeliveryTransactionCreator(client).create(portfolio, transaction.getType(), transaction.getDateTime(), + transaction.getAmount(), transaction.getCurrencyCode(), transaction.getSecurity(), + transaction.getShares(), null, null, transaction.getUnits().toList(), transaction.getNote(), + transaction.getSource()); + } + + void insert(BuySellEntry entry, Account account, Portfolio portfolio) + { + PortfolioTransaction transaction = entry.getPortfolioTransaction(); + + new LedgerBuySellTransactionCreator(client).create(portfolio, account, transaction.getType(), + transaction.getDateTime(), transaction.getAmount(), transaction.getCurrencyCode(), + transaction.getSecurity(), transaction.getShares(), transaction.getUnits().toList(), + transaction.getNote(), transaction.getSource()); + } + + void insert(AccountTransferEntry entry, Account source, Account target) + { + var sourceTransaction = entry.getSourceTransaction(); + var targetTransaction = entry.getTargetTransaction(); + var sourceForex = sourceTransaction.getUnit(Transaction.Unit.Type.GROSS_VALUE); + + new LedgerAccountTransferTransactionCreator(client).create(source, target, sourceTransaction.getDateTime(), + sourceTransaction.getAmount(), sourceTransaction.getCurrencyCode(), targetTransaction.getAmount(), + targetTransaction.getCurrencyCode(), sourceForex.map(Transaction.Unit::getForex).orElse(null), + sourceForex.map(Transaction.Unit::getExchangeRate).orElse(null), sourceTransaction.getNote(), + sourceTransaction.getSource()); + } + + void insert(PortfolioTransferEntry entry, Portfolio source, Portfolio target) + { + var sourceTransaction = entry.getSourceTransaction(); + + new LedgerPortfolioTransferTransactionCreator(client).create(source, target, sourceTransaction.getSecurity(), + sourceTransaction.getDateTime(), sourceTransaction.getShares(), sourceTransaction.getAmount(), + sourceTransaction.getCurrencyCode(), sourceTransaction.getNote(), sourceTransaction.getSource()); + } + + Status updateInvestmentPlanItemIfPresent(BuySellEntry entry) + { + DetectDuplicatesAction action = new DetectDuplicatesAction(client); + List matchingInvestmentPlanTransactions = new ArrayList<>(); + PortfolioTransaction transaction = entry.getPortfolioTransaction(); + + List plans = client.getPlans(); + var iterator = plans.stream().filter( + plan -> plan.getSecurity() != null && plan.getSecurity().equals(transaction.getSecurity())) + .iterator(); + + while (iterator.hasNext()) + { + List transactions = iterator.next().getTransactions(client).stream() + .map(pair -> (Transaction) pair.getTransaction()).toList(); + matchingInvestmentPlanTransactions.addAll(action.findInvestmentPlanTransactions(transaction, transactions)); + } + + if (matchingInvestmentPlanTransactions.size() > 1) + return new Status(Status.Code.WARNING, Messages.LabelPotentialDuplicate); + + if (matchingInvestmentPlanTransactions.size() == 1) + { + updateInvestmentPlanTransaction(matchingInvestmentPlanTransactions.get(0), entry); + return Status.OK_STATUS; + } + + return null; + } + + private void updateInvestmentPlanTransaction(Transaction existingTransaction, BuySellEntry importedEntry) + { + if (existingTransaction instanceof LedgerBackedTransaction) + { + updateLedgerBackedInvestmentPlanTransaction(existingTransaction, importedEntry); + return; + } + + PortfolioTransaction importedPortfolioTransaction = importedEntry.getPortfolioTransaction(); + + existingTransaction.setDateTime(importedPortfolioTransaction.getDateTime()); + existingTransaction.setNote(importedPortfolioTransaction.getNote()); + existingTransaction.setSource(importedPortfolioTransaction.getSource()); + existingTransaction.setShares(importedPortfolioTransaction.getShares()); + existingTransaction.setAmount(importedPortfolioTransaction.getAmount()); + existingTransaction.clearUnits(); + importedPortfolioTransaction.getUnits().forEach(existingTransaction::addUnit); + + if (existingTransaction.getCrossEntry() != null) + { + Transaction crossTransaction = existingTransaction.getCrossEntry().getCrossTransaction(existingTransaction); + crossTransaction.setDateTime(importedPortfolioTransaction.getDateTime()); + crossTransaction.setAmount(importedPortfolioTransaction.getAmount()); + crossTransaction.setNote(importedPortfolioTransaction.getNote()); + crossTransaction.setSource(importedPortfolioTransaction.getSource()); + } + } + + void updateLedgerBackedInvestmentPlanTransaction(Transaction existingTransaction, BuySellEntry importedEntry) + { + if (!(existingTransaction instanceof PortfolioTransaction existingPortfolioTransaction)) + throw new UnsupportedOperationException( + LedgerDiagnosticCode.LEDGER_UI_003.message( + Messages.LedgerInsertActionInvestmentPlanLegacySettersNotSupported)); + + PortfolioTransaction importedPortfolioTransaction = importedEntry.getPortfolioTransaction(); + + switch (existingPortfolioTransaction.getType()) + { + case BUY: + updateGeneratedBuy(existingPortfolioTransaction, importedPortfolioTransaction); + break; + case DELIVERY_INBOUND: + updateGeneratedInboundDelivery(existingPortfolioTransaction, importedPortfolioTransaction); + break; + case SELL, DELIVERY_OUTBOUND, TRANSFER_IN, TRANSFER_OUT: + throw new UnsupportedOperationException( + LedgerDiagnosticCode.LEDGER_UI_004.message(MessageFormat.format( + Messages.LedgerInsertActionUnsupportedInvestmentPlanTransactionUpdateType, + existingPortfolioTransaction.getType()))); + default: + throw new UnsupportedOperationException( + LedgerDiagnosticCode.LEDGER_UI_005.message(MessageFormat.format( + Messages.LedgerInsertActionUnsupportedInvestmentPlanTransactionUpdateType, + existingPortfolioTransaction.getType()))); + } + } + + private void updateGeneratedBuy(PortfolioTransaction existingPortfolioTransaction, + PortfolioTransaction importedPortfolioTransaction) + { + if (importedPortfolioTransaction.getType() != PortfolioTransaction.Type.BUY) + throw new UnsupportedOperationException( + LedgerDiagnosticCode.LEDGER_UI_006.message( + Messages.LedgerInsertActionGeneratedBuyTypeMismatch)); + + if (!(existingPortfolioTransaction.getCrossEntry() instanceof BuySellEntry existingEntry)) + throw new UnsupportedOperationException( + LedgerDiagnosticCode.LEDGER_UI_007 + .message(Messages.LedgerInsertActionGeneratedBuyMissingBuySellEntry)); + + new LedgerBuySellTransactionCreator(client).update(existingEntry, existingEntry.getPortfolio(), + existingEntry.getAccount(), PortfolioTransaction.Type.BUY, + importedPortfolioTransaction.getDateTime(), importedPortfolioTransaction.getAmount(), + importedPortfolioTransaction.getCurrencyCode(), importedPortfolioTransaction.getSecurity(), + importedPortfolioTransaction.getShares(), importedPortfolioTransaction.getUnits().toList(), + importedPortfolioTransaction.getNote(), importedPortfolioTransaction.getSource()); + } + + private void updateGeneratedInboundDelivery(PortfolioTransaction existingPortfolioTransaction, + PortfolioTransaction importedPortfolioTransaction) + { + if (importedPortfolioTransaction.getType() != PortfolioTransaction.Type.BUY) + throw new UnsupportedOperationException( + LedgerDiagnosticCode.LEDGER_UI_008.message( + Messages.LedgerInsertActionGeneratedDeliveryTypeMismatch)); + + new LedgerDeliveryTransactionCreator(client).update(existingPortfolioTransaction, + ownerOf(existingPortfolioTransaction), PortfolioTransaction.Type.DELIVERY_INBOUND, + importedPortfolioTransaction.getDateTime(), importedPortfolioTransaction.getAmount(), + importedPortfolioTransaction.getCurrencyCode(), importedPortfolioTransaction.getSecurity(), + importedPortfolioTransaction.getShares(), null, null, + importedPortfolioTransaction.getUnits().toList(), importedPortfolioTransaction.getNote(), + importedPortfolioTransaction.getSource()); + } + + private Portfolio ownerOf(PortfolioTransaction transaction) + { + return client.getPortfolios().stream().filter(portfolio -> portfolio.getTransactions().contains(transaction)) + .findFirst() + .orElseThrow(() -> new IllegalStateException( + Messages.LedgerInsertActionGeneratedDeliveryOwnerNotFound)); + } +} From 36a7eda3aa228a68b56f38b3563149f249055788 Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:44:08 +0200 Subject: [PATCH 31/68] Wrap Ledger dialog commit support Move Ledger-specific dialog commit decisions out of transaction models into LedgerDialogCommitSupport. This keeps dialog validation, calculation, and legacy apply flow close to the original shape while preserving no-UUID Ledger-backed create and update behavior at the commit boundary. Context menu routing, keyboard routing, inline editing, converter actions, import actions, persistence, migration diagnostics, InvestmentPlan linkage, Ledger model semantics, and Corporate Action behavior are intentionally unchanged. --- .../transactions/AccountTransactionModel.java | 58 +----- .../transactions/AccountTransferModel.java | 17 +- .../ui/dialogs/transactions/BuySellModel.java | 16 +- .../LedgerDialogCommitSupport.java | 179 ++++++++++++++++++ .../transactions/SecurityDeliveryModel.java | 25 +-- .../transactions/SecurityTransferModel.java | 17 +- 6 files changed, 200 insertions(+), 112 deletions(-) create mode 100644 name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/LedgerDialogCommitSupport.java diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/AccountTransactionModel.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/AccountTransactionModel.java index 40e1a2254d..abeabd0599 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/AccountTransactionModel.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/AccountTransactionModel.java @@ -19,8 +19,6 @@ import name.abuchen.portfolio.model.Portfolio; import name.abuchen.portfolio.model.Security; import name.abuchen.portfolio.model.Transaction; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountOnlyTransactionCreator; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerDividendTransactionCreator; import name.abuchen.portfolio.money.CurrencyConverter; import name.abuchen.portfolio.money.CurrencyConverterImpl; import name.abuchen.portfolio.money.ExchangeRate; @@ -114,44 +112,13 @@ public void applyChanges() if (exDate != null && (security == null || EMPTY_SECURITY.equals(security))) throw new UnsupportedOperationException(Messages.MsgExDateNotAllowed); - var ledgerAccountOnlyCreator = new LedgerAccountOnlyTransactionCreator(client); - var ledgerDividendCreator = new LedgerDividendTransactionCreator(client); - - if (exDate != null && isLedgerAccountOnlyType() - && (sourceTransaction == null || ledgerAccountOnlyCreator.canUpdate(sourceTransaction))) - throw new UnsupportedOperationException(Messages.MsgExDateNotAllowed); - - if (sourceTransaction != null && ledgerDividendCreator.canUpdate(sourceTransaction)) - { - ledgerDividendCreator.update(sourceTransaction, account, type, LocalDateTime.of(date, time), total, - getAccountCurrencyCode(), security, shares, exDate, getDividendCashForex(), - getDividendCashForex() != null ? exchangeRate : null, buildUnits(), note, - sourceTransaction.getSource()); + var dateTime = LocalDateTime.of(date, time); + var units = buildUnits(); + var ledgerCommit = new LedgerDialogCommitSupport(client); + if (ledgerCommit.commitAccountTransaction(sourceTransaction, account, type, dateTime, total, + getAccountCurrencyCode(), !EMPTY_SECURITY.equals(security) ? security : null, shares, exDate, + getDividendCashForex(), exchangeRate, units, note)) return; - } - - if (sourceTransaction != null && ledgerAccountOnlyCreator.canUpdate(sourceTransaction)) - { - ledgerAccountOnlyCreator.update(sourceTransaction, account, type, LocalDateTime.of(date, time), total, - getAccountCurrencyCode(), !EMPTY_SECURITY.equals(security) ? security : null, buildUnits(), - note, sourceTransaction.getSource()); - return; - } - - if (sourceTransaction == null && isLedgerAccountOnlyType()) - { - ledgerAccountOnlyCreator.create(account, type, LocalDateTime.of(date, time), total, getAccountCurrencyCode(), - !EMPTY_SECURITY.equals(security) ? security : null, buildUnits(), note, null); - return; - } - - if (sourceTransaction == null && type == AccountTransaction.Type.DIVIDENDS) - { - ledgerDividendCreator.create(account, LocalDateTime.of(date, time), total, getAccountCurrencyCode(), - security, shares, exDate, getDividendCashForex(), - getDividendCashForex() != null ? exchangeRate : null, buildUnits(), note, null); - return; - } AccountTransaction t; @@ -178,7 +145,7 @@ security, shares, exDate, getDividendCashForex(), } } - t.setDateTime(LocalDateTime.of(date, time)); + t.setDateTime(dateTime); t.setExDate(exDate); t.setSecurity(!EMPTY_SECURITY.equals(security) ? security : null); t.setShares(supportsShares() ? shares : 0); @@ -188,7 +155,7 @@ security, shares, exDate, getDividendCashForex(), t.clearUnits(); - buildUnits().forEach(t::addUnit); + units.forEach(t::addUnit); } private List buildUnits() @@ -226,15 +193,6 @@ private List buildUnits() return units; } - private boolean isLedgerAccountOnlyType() - { - return switch (type) - { - case DEPOSIT, REMOVAL, INTEREST, INTEREST_CHARGE, FEES, FEES_REFUND, TAXES, TAX_REFUND -> true; - case BUY, SELL, TRANSFER_IN, TRANSFER_OUT, DIVIDENDS -> false; - }; - } - private Money getDividendCashForex() { if (type != AccountTransaction.Type.DIVIDENDS || security == null || EMPTY_SECURITY.equals(security) diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/AccountTransferModel.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/AccountTransferModel.java index c4cb3a3171..71680aaa25 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/AccountTransferModel.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/AccountTransferModel.java @@ -67,28 +67,15 @@ public void applyChanges() if (targetAccount == null) throw new UnsupportedOperationException(Messages.MsgAccountToMissing); - var ledgerTransferCreator = new LedgerAccountTransferTransactionCreator(client); var dateTime = LocalDateTime.of(date, time); var sourceAmount = sourceAccount.getCurrencyCode().equals(targetAccount.getCurrencyCode()) ? amount : fxAmount; var sourceForex = sourceAccount.getCurrencyCode().equals(targetAccount.getCurrencyCode()) ? null : Money.of(targetAccount.getCurrencyCode(), amount); var sourceExchangeRate = sourceForex != null ? getInverseExchangeRate() : null; - if (source != null && ledgerTransferCreator.isLedgerBacked(source)) - { - ledgerTransferCreator.update(source, sourceAccount, targetAccount, dateTime, sourceAmount, - sourceAccount.getCurrencyCode(), amount, targetAccount.getCurrencyCode(), sourceForex, - sourceExchangeRate, note, source.getSource()); - return; - } - - if (source == null) - { - ledgerTransferCreator.create(sourceAccount, targetAccount, dateTime, sourceAmount, - sourceAccount.getCurrencyCode(), amount, targetAccount.getCurrencyCode(), sourceForex, - sourceExchangeRate, note, null); + if (new LedgerDialogCommitSupport(client).commitAccountTransfer(source, sourceAccount, targetAccount, dateTime, + sourceAmount, amount, sourceForex, sourceExchangeRate, note)) return; - } AccountTransferEntry t; diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/BuySellModel.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/BuySellModel.java index d12832a8ac..c7af13d99a 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/BuySellModel.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/BuySellModel.java @@ -129,23 +129,11 @@ public void applyChanges() if (account == null) throw new UnsupportedOperationException(Messages.MsgMissingReferenceAccount); - var ledgerCreator = new LedgerBuySellTransactionCreator(client); var dateTime = LocalDateTime.of(date, time); var units = buildUnits(); - - if (source != null && ledgerCreator.isLedgerBacked(source)) - { - ledgerCreator.update(source, portfolio, account, type, dateTime, total, account.getCurrencyCode(), security, - shares, units, note, source.getSource()); + if (new LedgerDialogCommitSupport(client).commitBuySell(source, portfolio, account, type, dateTime, total, + security, shares, units, note)) return; - } - - if (source == null) - { - ledgerCreator.create(portfolio, account, type, dateTime, total, account.getCurrencyCode(), security, shares, - units, note, null); - return; - } BuySellEntry entry; diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/LedgerDialogCommitSupport.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/LedgerDialogCommitSupport.java new file mode 100644 index 0000000000..9b32649f8e --- /dev/null +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/LedgerDialogCommitSupport.java @@ -0,0 +1,179 @@ +package name.abuchen.portfolio.ui.dialogs.transactions; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.AccountTransferEntry; +import name.abuchen.portfolio.model.BuySellEntry; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.PortfolioTransaction; +import name.abuchen.portfolio.model.PortfolioTransferEntry; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.Transaction; +import name.abuchen.portfolio.model.TransactionPair; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountOnlyTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountTransferTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerBuySellTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerDeliveryTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerDividendTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferTransactionCreator; +import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.ui.Messages; + +final class LedgerDialogCommitSupport +{ + private final Client client; + + LedgerDialogCommitSupport(Client client) + { + this.client = client; + } + + boolean commitAccountTransaction(AccountTransaction sourceTransaction, Account account, AccountTransaction.Type type, + LocalDateTime dateTime, long total, String currencyCode, Security security, long shares, + LocalDateTime exDate, Money dividendCashForex, BigDecimal exchangeRate, + List units, String note) + { + var accountOnlyCreator = new LedgerAccountOnlyTransactionCreator(client); + var dividendCreator = new LedgerDividendTransactionCreator(client); + + if (exDate != null && isLedgerAccountOnlyType(type) + && (sourceTransaction == null || accountOnlyCreator.canUpdate(sourceTransaction))) + throw new UnsupportedOperationException(Messages.MsgExDateNotAllowed); + + if (sourceTransaction != null && dividendCreator.canUpdate(sourceTransaction)) + { + dividendCreator.update(sourceTransaction, account, type, dateTime, total, currencyCode, security, shares, + exDate, dividendCashForex, dividendCashForex != null ? exchangeRate : null, units, note, + sourceTransaction.getSource()); + return true; + } + + if (sourceTransaction != null && accountOnlyCreator.canUpdate(sourceTransaction)) + { + accountOnlyCreator.update(sourceTransaction, account, type, dateTime, total, currencyCode, security, units, + note, sourceTransaction.getSource()); + return true; + } + + if (sourceTransaction == null && isLedgerAccountOnlyType(type)) + { + accountOnlyCreator.create(account, type, dateTime, total, currencyCode, security, units, note, null); + return true; + } + + if (sourceTransaction == null && type == AccountTransaction.Type.DIVIDENDS) + { + dividendCreator.create(account, dateTime, total, currencyCode, security, shares, exDate, dividendCashForex, + dividendCashForex != null ? exchangeRate : null, units, note, null); + return true; + } + + return false; + } + + boolean commitBuySell(BuySellEntry source, Portfolio portfolio, Account account, PortfolioTransaction.Type type, + LocalDateTime dateTime, long total, Security security, long shares, List units, + String note) + { + var creator = new LedgerBuySellTransactionCreator(client); + + if (source != null && creator.isLedgerBacked(source)) + { + creator.update(source, portfolio, account, type, dateTime, total, account.getCurrencyCode(), security, + shares, units, note, source.getSource()); + return true; + } + + if (source == null) + { + creator.create(portfolio, account, type, dateTime, total, account.getCurrencyCode(), security, shares, + units, note, null); + return true; + } + + return false; + } + + boolean commitAccountTransfer(AccountTransferEntry source, Account sourceAccount, Account targetAccount, + LocalDateTime dateTime, long sourceAmount, long targetAmount, Money sourceForex, + BigDecimal sourceExchangeRate, String note) + { + var creator = new LedgerAccountTransferTransactionCreator(client); + + if (source != null && creator.isLedgerBacked(source)) + { + creator.update(source, sourceAccount, targetAccount, dateTime, sourceAmount, sourceAccount.getCurrencyCode(), + targetAmount, targetAccount.getCurrencyCode(), sourceForex, sourceExchangeRate, note, + source.getSource()); + return true; + } + + if (source == null) + { + creator.create(sourceAccount, targetAccount, dateTime, sourceAmount, sourceAccount.getCurrencyCode(), + targetAmount, targetAccount.getCurrencyCode(), sourceForex, sourceExchangeRate, note, null); + return true; + } + + return false; + } + + boolean commitDelivery(TransactionPair source, Portfolio portfolio, + PortfolioTransaction.Type type, LocalDateTime dateTime, long total, String currencyCode, + Security security, long shares, List units, String note) + { + var creator = new LedgerDeliveryTransactionCreator(client); + + if (source != null && creator.canUpdate(source.getTransaction())) + { + creator.update(source.getTransaction(), portfolio, type, dateTime, total, currencyCode, security, shares, + null, null, units, note, source.getTransaction().getSource()); + return true; + } + + if (source == null) + { + creator.create(portfolio, type, dateTime, total, currencyCode, security, shares, null, null, units, note, + null); + return true; + } + + return false; + } + + boolean commitSecurityTransfer(PortfolioTransferEntry source, Portfolio sourcePortfolio, Portfolio targetPortfolio, + Security security, LocalDateTime dateTime, long shares, long amount, String note) + { + var creator = new LedgerPortfolioTransferTransactionCreator(client); + + if (source != null && creator.isLedgerBacked(source)) + { + creator.update(source, sourcePortfolio, targetPortfolio, security, dateTime, shares, amount, + security.getCurrencyCode(), note, source.getSource()); + return true; + } + + if (source == null) + { + creator.create(sourcePortfolio, targetPortfolio, security, dateTime, shares, amount, + security.getCurrencyCode(), note, null); + return true; + } + + return false; + } + + private boolean isLedgerAccountOnlyType(AccountTransaction.Type type) + { + return switch (type) + { + case DEPOSIT, REMOVAL, INTEREST, INTEREST_CHARGE, FEES, FEES_REFUND, TAXES, TAX_REFUND -> true; + case BUY, SELL, TRANSFER_IN, TRANSFER_OUT, DIVIDENDS -> false; + }; + } +} diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/SecurityDeliveryModel.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/SecurityDeliveryModel.java index 11afdda523..eb0d4094eb 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/SecurityDeliveryModel.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/SecurityDeliveryModel.java @@ -7,7 +7,6 @@ import name.abuchen.portfolio.model.PortfolioTransaction; import name.abuchen.portfolio.model.PortfolioTransaction.Type; import name.abuchen.portfolio.model.TransactionPair; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerDeliveryTransactionCreator; import name.abuchen.portfolio.money.CurrencyUnit; import name.abuchen.portfolio.ui.Messages; @@ -69,22 +68,11 @@ public void applyChanges() if (portfolio.getReferenceAccount() == null) throw new UnsupportedOperationException(Messages.MsgMissingReferenceAccount); - var ledgerDeliveryCreator = new LedgerDeliveryTransactionCreator(client); - - if (source != null && ledgerDeliveryCreator.canUpdate(source.getTransaction())) - { - ledgerDeliveryCreator.update(source.getTransaction(), portfolio, type, LocalDateTime.of(date, time), total, - getTransactionCurrencyCode(), security, shares, null, null, buildUnits(), note, - source.getTransaction().getSource()); + var dateTime = LocalDateTime.of(date, time); + var units = buildUnits(); + if (new LedgerDialogCommitSupport(client).commitDelivery(source, portfolio, type, dateTime, total, + getTransactionCurrencyCode(), security, shares, units, note)) return; - } - - if (source == null) - { - ledgerDeliveryCreator.create(portfolio, type, LocalDateTime.of(date, time), total, - getTransactionCurrencyCode(), security, shares, null, null, buildUnits(), note, null); - return; - } TransactionPair entry; @@ -110,7 +98,7 @@ public void applyChanges() PortfolioTransaction transaction = entry.getTransaction(); - transaction.setDateTime(LocalDateTime.of(date, time)); + transaction.setDateTime(dateTime); transaction.setCurrencyCode(getTransactionCurrencyCode()); transaction.setSecurity(security); transaction.setShares(shares); @@ -118,7 +106,8 @@ public void applyChanges() transaction.setType(type); transaction.setNote(note); - writeToTransaction(transaction); + transaction.clearUnits(); + units.forEach(transaction::addUnit); } @Override diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/SecurityTransferModel.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/SecurityTransferModel.java index 564e90f98d..92b84fc896 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/SecurityTransferModel.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/SecurityTransferModel.java @@ -15,7 +15,6 @@ import name.abuchen.portfolio.model.Security; import name.abuchen.portfolio.model.Transaction; import name.abuchen.portfolio.model.TransactionOwner; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferTransactionCreator; import name.abuchen.portfolio.money.CurrencyConverter; import name.abuchen.portfolio.money.CurrencyConverterImpl; import name.abuchen.portfolio.money.Values; @@ -69,23 +68,11 @@ public void applyChanges() if (targetPortfolio == null) throw new UnsupportedOperationException(Messages.MsgPortfolioToMissing); - var ledgerCreator = new LedgerPortfolioTransferTransactionCreator(client); var dateTime = LocalDateTime.of(date, time); - var sourceText = source != null ? source.getSource() : null; - if (source != null && ledgerCreator.isLedgerBacked(source)) - { - ledgerCreator.update(source, sourcePortfolio, targetPortfolio, security, dateTime, shares, amount, - security.getCurrencyCode(), note, sourceText); - return; - } - - if (source == null) - { - ledgerCreator.create(sourcePortfolio, targetPortfolio, security, dateTime, shares, amount, - security.getCurrencyCode(), note, null); + if (new LedgerDialogCommitSupport(client).commitSecurityTransfer(source, sourcePortfolio, targetPortfolio, + security, dateTime, shares, amount, note)) return; - } PortfolioTransferEntry t; From c2bbbd45d1e7f14835fde0922ed4e6e6cee8eb1c Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:00:06 +0200 Subject: [PATCH 32/68] Wrap Ledger transaction UI routing Move Ledger-specific transaction UI routing, native-target checks, duplicate-safe deletion, conversion eligibility, and all-transactions list/filter helpers into LedgerTransactionUiSupport. This keeps view and context-menu classes focused on UI assembly while preserving existing Ledger-backed routing behavior behind a narrow helper. Dialog apply paths, inline editing rules, converter actions, check/repair paths, import actions, persistence, migration diagnostics, InvestmentPlan linkage, Ledger model semantics, and Corporate Action behavior are intentionally unchanged. --- .../views/LedgerTransactionRoutingTest.java | 50 +++--- .../ui/views/TransactionContextMenuTest.java | 28 ++-- .../ui/views/AllTransactionsView.java | 49 +----- .../ui/views/LedgerTransactionUiSupport.java | 148 ++++++++++++++++++ .../ui/views/TransactionContextMenu.java | 93 ++--------- .../views/panes/AccountTransactionsPane.java | 31 ++-- 6 files changed, 212 insertions(+), 187 deletions(-) create mode 100644 name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/LedgerTransactionUiSupport.java diff --git a/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/views/LedgerTransactionRoutingTest.java b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/views/LedgerTransactionRoutingTest.java index 65ff42c52c..7af9e85deb 100644 --- a/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/views/LedgerTransactionRoutingTest.java +++ b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/views/LedgerTransactionRoutingTest.java @@ -215,16 +215,18 @@ public void testForbiddenLedgerBackedActionRoutes() var transfer = ledgerPortfolioTransferFixture(); var transferPair = new TransactionPair<>(transfer.source(), transfer.source().getTransactions().get(0)); - assertThat(TransactionContextMenu.supportsBuySellToDeliveryAction(List.of(transferPair)), is(false)); - assertThat(TransactionContextMenu.supportsDeliveryToBuySellAction(List.of(transferPair)), is(false)); + assertThat(LedgerTransactionUiSupport.supportsBuySellToDeliveryAction(List.of(transferPair)), is(false)); + assertThat(LedgerTransactionUiSupport.supportsDeliveryToBuySellAction(List.of(transferPair)), is(false)); var buySell = ledgerBuySellFixture(PortfolioTransaction.Type.BUY); var buyPair = new TransactionPair<>(buySell.portfolio(), buySell.portfolio().getTransactions().get(0)); var delivery = ledgerDeliveryFixture(PortfolioTransaction.Type.DELIVERY_INBOUND); var deliveryPair = new TransactionPair<>(delivery.portfolio(), delivery.portfolio().getTransactions().get(0)); - assertThat(TransactionContextMenu.supportsBuySellToDeliveryAction(List.of(buyPair, deliveryPair)), is(false)); - assertThat(TransactionContextMenu.supportsDeliveryToBuySellAction(List.of(buyPair, deliveryPair)), is(false)); + assertThat(LedgerTransactionUiSupport.supportsBuySellToDeliveryAction(List.of(buyPair, deliveryPair)), + is(false)); + assertThat(LedgerTransactionUiSupport.supportsDeliveryToBuySellAction(List.of(buyPair, deliveryPair)), + is(false)); } /** @@ -666,7 +668,7 @@ public void testAllTransactionsTransferDeduplicationMatchesLegacyAndLedgerBacked public void testAllTransactionsViewExpandsTransfersWithoutChangingClientDeduplication() throws Exception { var legacyAccountTransfer = legacyAccountTransferFixture(); - var legacyAccountTransactions = AllTransactionsView.getTransactionsForView(legacyAccountTransfer.client()); + var legacyAccountTransactions = LedgerTransactionUiSupport.transactionsForView(legacyAccountTransfer.client()); assertThat(legacyAccountTransactions.size(), is(2)); assertSame(legacyAccountTransfer.source(), legacyAccountTransactions.get(0).getOwner()); @@ -678,7 +680,7 @@ public void testAllTransactionsViewExpandsTransfersWithoutChangingClientDeduplic assertThat(legacyAccountTransfer.client().getAllTransactions().size(), is(1)); var ledgerAccountTransfer = ledgerAccountTransferFixture(); - var ledgerAccountTransactions = AllTransactionsView.getTransactionsForView(ledgerAccountTransfer.client()); + var ledgerAccountTransactions = LedgerTransactionUiSupport.transactionsForView(ledgerAccountTransfer.client()); assertThat(ledgerAccountTransactions.size(), is(2)); assertSame(ledgerAccountTransfer.source(), ledgerAccountTransactions.get(0).getOwner()); @@ -690,14 +692,15 @@ public void testAllTransactionsViewExpandsTransfersWithoutChangingClientDeduplic assertSame(ledgerEntry(ledgerAccountTransactions.get(0).getTransaction()), ledgerEntry(ledgerAccountTransactions.get(1).getTransaction())); assertThat(ledgerAccountTransfer.client().getAllTransactions().size(), is(1)); - assertThat(AllTransactionsView.matchesClientFilter( + assertThat(LedgerTransactionUiSupport.matchesClientFilter( new PortfolioClientFilter(List.of(), List.of(ledgerAccountTransfer.source())), ledgerAccountTransactions.get(0)), is(true)); - assertThat(AllTransactionsView.matchesClientFilter( + assertThat(LedgerTransactionUiSupport.matchesClientFilter( new PortfolioClientFilter(List.of(), List.of(ledgerAccountTransfer.source())), ledgerAccountTransactions.get(1)), is(false)); - var reloadedAccountTransfer = AllTransactionsView.getTransactionsForView(reloadXml(ledgerAccountTransfer.client())); + var reloadedAccountTransfer = LedgerTransactionUiSupport + .transactionsForView(reloadXml(ledgerAccountTransfer.client())); assertThat(reloadedAccountTransfer.size(), is(2)); assertThat(((AccountTransaction) reloadedAccountTransfer.get(0).getTransaction()).getType(), is(AccountTransaction.Type.TRANSFER_OUT)); @@ -707,7 +710,8 @@ public void testAllTransactionsViewExpandsTransfersWithoutChangingClientDeduplic ledgerEntry(reloadedAccountTransfer.get(1).getTransaction())); var legacyPortfolioTransfer = legacyPortfolioTransferFixture(); - var legacyPortfolioTransactions = AllTransactionsView.getTransactionsForView(legacyPortfolioTransfer.client()); + var legacyPortfolioTransactions = LedgerTransactionUiSupport + .transactionsForView(legacyPortfolioTransfer.client()); assertThat(legacyPortfolioTransactions.size(), is(2)); assertSame(legacyPortfolioTransfer.source(), legacyPortfolioTransactions.get(0).getOwner()); @@ -719,7 +723,8 @@ public void testAllTransactionsViewExpandsTransfersWithoutChangingClientDeduplic assertThat(legacyPortfolioTransfer.client().getAllTransactions().size(), is(1)); var ledgerPortfolioTransfer = ledgerPortfolioTransferFixture(); - var ledgerPortfolioTransactions = AllTransactionsView.getTransactionsForView(ledgerPortfolioTransfer.client()); + var ledgerPortfolioTransactions = LedgerTransactionUiSupport + .transactionsForView(ledgerPortfolioTransfer.client()); assertThat(ledgerPortfolioTransactions.size(), is(2)); assertSame(ledgerPortfolioTransfer.source(), ledgerPortfolioTransactions.get(0).getOwner()); @@ -731,15 +736,15 @@ public void testAllTransactionsViewExpandsTransfersWithoutChangingClientDeduplic assertSame(ledgerEntry(ledgerPortfolioTransactions.get(0).getTransaction()), ledgerEntry(ledgerPortfolioTransactions.get(1).getTransaction())); assertThat(ledgerPortfolioTransfer.client().getAllTransactions().size(), is(1)); - assertThat(AllTransactionsView.matchesClientFilter( + assertThat(LedgerTransactionUiSupport.matchesClientFilter( new PortfolioClientFilter(List.of(ledgerPortfolioTransfer.source()), List.of()), ledgerPortfolioTransactions.get(0)), is(true)); - assertThat(AllTransactionsView.matchesClientFilter( + assertThat(LedgerTransactionUiSupport.matchesClientFilter( new PortfolioClientFilter(List.of(ledgerPortfolioTransfer.source()), List.of()), ledgerPortfolioTransactions.get(1)), is(false)); - var reloadedPortfolioTransfer = AllTransactionsView - .getTransactionsForView(reloadXml(ledgerPortfolioTransfer.client())); + var reloadedPortfolioTransfer = LedgerTransactionUiSupport + .transactionsForView(reloadXml(ledgerPortfolioTransfer.client())); assertThat(reloadedPortfolioTransfer.size(), is(2)); assertThat(((PortfolioTransaction) reloadedPortfolioTransfer.get(0).getTransaction()).getType(), is(PortfolioTransaction.Type.TRANSFER_OUT)); @@ -748,12 +753,13 @@ public void testAllTransactionsViewExpandsTransfersWithoutChangingClientDeduplic assertSame(ledgerEntry(reloadedPortfolioTransfer.get(0).getTransaction()), ledgerEntry(reloadedPortfolioTransfer.get(1).getTransaction())); - assertThat(AllTransactionsView.getTransactionsForView(legacyBuySellFixture(PortfolioTransaction.Type.BUY).client()) + assertThat(LedgerTransactionUiSupport + .transactionsForView(legacyBuySellFixture(PortfolioTransaction.Type.BUY).client()) .size(), is(1)); var ledgerBuySell = ledgerBuySellFixture(PortfolioTransaction.Type.BUY); - var ledgerBuySellTransactions = AllTransactionsView.getTransactionsForView(ledgerBuySell.client()); + var ledgerBuySellTransactions = LedgerTransactionUiSupport.transactionsForView(ledgerBuySell.client()); assertThat(ledgerBuySellTransactions.size(), is(1)); - assertThat(AllTransactionsView.matchesClientFilter( + assertThat(LedgerTransactionUiSupport.matchesClientFilter( new PortfolioClientFilter(List.of(), List.of(ledgerBuySell.account())), ledgerBuySellTransactions.get(0)), is(true)); } @@ -1073,16 +1079,16 @@ private void assertSupportedBuySellToDeliveryContextMenuRoute(Portfolio owner, P { var pair = new TransactionPair<>(owner, transaction); - assertThat(TransactionContextMenu.supportsBuySellToDeliveryAction(List.of(pair)), is(true)); - assertThat(TransactionContextMenu.supportsDeliveryToBuySellAction(List.of(pair)), is(false)); + assertThat(LedgerTransactionUiSupport.supportsBuySellToDeliveryAction(List.of(pair)), is(true)); + assertThat(LedgerTransactionUiSupport.supportsDeliveryToBuySellAction(List.of(pair)), is(false)); } private void assertSupportedDeliveryToBuySellContextMenuRoute(Portfolio owner, PortfolioTransaction transaction) { var pair = new TransactionPair<>(owner, transaction); - assertThat(TransactionContextMenu.supportsDeliveryToBuySellAction(List.of(pair)), is(true)); - assertThat(TransactionContextMenu.supportsBuySellToDeliveryAction(List.of(pair)), is(false)); + assertThat(LedgerTransactionUiSupport.supportsDeliveryToBuySellAction(List.of(pair)), is(true)); + assertThat(LedgerTransactionUiSupport.supportsBuySellToDeliveryAction(List.of(pair)), is(false)); } private void assertSupportedAccountEditRoute(Client client, Account owner, AccountTransaction transaction, diff --git a/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/views/TransactionContextMenuTest.java b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/views/TransactionContextMenuTest.java index 207be23c00..3a3d548614 100644 --- a/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/views/TransactionContextMenuTest.java +++ b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/views/TransactionContextMenuTest.java @@ -58,9 +58,9 @@ public void testLedgerBackedBuySellSelectionSupportsConvertToDeliveryMenuAction( var transaction = fixture.portfolio().getTransactions().get(0); - assertThat(TransactionContextMenu.supportsBuySellToDeliveryAction( + assertThat(LedgerTransactionUiSupport.supportsBuySellToDeliveryAction( List.of(new TransactionPair<>(fixture.portfolio(), transaction))), is(true)); - assertThat(TransactionContextMenu.supportsDeliveryToBuySellAction( + assertThat(LedgerTransactionUiSupport.supportsDeliveryToBuySellAction( List.of(new TransactionPair<>(fixture.portfolio(), transaction))), is(false)); } @@ -81,9 +81,9 @@ public void testLedgerBackedDeliverySelectionSupportsConvertToBuySellMenuAction( var transaction = fixture.portfolio().getTransactions().get(0); - assertThat(TransactionContextMenu.supportsDeliveryToBuySellAction( + assertThat(LedgerTransactionUiSupport.supportsDeliveryToBuySellAction( List.of(new TransactionPair<>(fixture.portfolio(), transaction))), is(true)); - assertThat(TransactionContextMenu.supportsBuySellToDeliveryAction( + assertThat(LedgerTransactionUiSupport.supportsBuySellToDeliveryAction( List.of(new TransactionPair<>(fixture.portfolio(), transaction))), is(false)); } @@ -109,8 +109,8 @@ public void testMixedSecuritySelectionDoesNotExposeConversionMenuActions() .map(transaction -> new TransactionPair<>(fixture.portfolio(), transaction)) // .toList(); - assertThat(TransactionContextMenu.supportsBuySellToDeliveryAction(pairs), is(false)); - assertThat(TransactionContextMenu.supportsDeliveryToBuySellAction(pairs), is(false)); + assertThat(LedgerTransactionUiSupport.supportsBuySellToDeliveryAction(pairs), is(false)); + assertThat(LedgerTransactionUiSupport.supportsDeliveryToBuySellAction(pairs), is(false)); } /** @@ -131,7 +131,7 @@ public void testLedgerBackedBuySellSelectionDeleteRemovesLedgerEntryOnce() throw assertThat(portfolioPair.getTransaction().getUUID().equals(accountPair.getTransaction().getUUID()), is(false)); assertThat(portfolioPair.getLedgerEntryUUID(), is(accountPair.getLedgerEntryUUID())); - TransactionContextMenu.deleteTransactions(fixture.client(), new Object[] { portfolioPair, accountPair }); + LedgerTransactionUiSupport.deleteTransactions(fixture.client(), new Object[] { portfolioPair, accountPair }); assertTrue(fixture.account().getTransactions().isEmpty()); assertTrue(fixture.portfolio().getTransactions().isEmpty()); @@ -156,7 +156,7 @@ public void testLedgerBackedAccountTransferSelectionDeleteRemovesLedgerEntryOnce assertThat(sourcePair.getTransaction().getUUID().equals(targetPair.getTransaction().getUUID()), is(false)); assertThat(sourcePair.getLedgerEntryUUID(), is(targetPair.getLedgerEntryUUID())); - TransactionContextMenu.deleteTransactions(fixture.client(), new Object[] { sourcePair, targetPair }); + LedgerTransactionUiSupport.deleteTransactions(fixture.client(), new Object[] { sourcePair, targetPair }); assertTrue(fixture.source().getTransactions().isEmpty()); assertTrue(fixture.target().getTransactions().isEmpty()); @@ -181,7 +181,7 @@ public void testLedgerBackedPortfolioTransferSelectionDeleteRemovesLedgerEntryOn assertThat(sourcePair.getTransaction().getUUID().equals(targetPair.getTransaction().getUUID()), is(false)); assertThat(sourcePair.getLedgerEntryUUID(), is(targetPair.getLedgerEntryUUID())); - TransactionContextMenu.deleteTransactions(fixture.client(), new Object[] { sourcePair, targetPair }); + LedgerTransactionUiSupport.deleteTransactions(fixture.client(), new Object[] { sourcePair, targetPair }); assertTrue(fixture.source().getTransactions().isEmpty()); assertTrue(fixture.target().getTransactions().isEmpty()); @@ -213,7 +213,7 @@ public void testMixedSelectionDeduplicatesLedgerEntriesAndDeletesLegacyTransacti legacyTransaction.setCurrencyCode(CurrencyUnit.EUR); fixture.account().addTransaction(legacyTransaction); - TransactionContextMenu.deleteTransactions(fixture.client(), + LedgerTransactionUiSupport.deleteTransactions(fixture.client(), new Object[] { new TransactionPair<>(fixture.portfolio(), firstLedgerEntry.getPortfolioTransaction()), new TransactionPair<>(fixture.account(), @@ -240,7 +240,7 @@ public void testLedgerBackedAccountOnlySelectionDeleteRemovesLedgerEntry() throw AccountTransaction.Type.FEES, DATE_TIME, Values.Amount.factorize(123), CurrencyUnit.EUR, null, List.of(), "note", "source"); - TransactionContextMenu.deleteTransactions(fixture.client(), + LedgerTransactionUiSupport.deleteTransactions(fixture.client(), new Object[] { new TransactionPair<>(fixture.account(), transaction) }); assertTrue(fixture.account().getTransactions().isEmpty()); @@ -286,7 +286,7 @@ public void testLegacyCrossEntrySelectionDeleteRemainsUnchanged() entry.setCurrencyCode(CurrencyUnit.EUR); entry.insert(); - TransactionContextMenu.deleteTransactions(fixture.client(), + LedgerTransactionUiSupport.deleteTransactions(fixture.client(), new Object[] { new TransactionPair<>(fixture.portfolio(), entry.getPortfolioTransaction()), new TransactionPair<>(fixture.account(), entry.getAccountTransaction()) }); @@ -310,7 +310,7 @@ public void testLegacyAccountTransferSelectionDeleteRemainsUnchanged() transfer.setCurrencyCode(CurrencyUnit.EUR); transfer.insert(); - TransactionContextMenu.deleteTransactions(fixture.client(), + LedgerTransactionUiSupport.deleteTransactions(fixture.client(), new Object[] { new TransactionPair<>(fixture.source(), transfer.getSourceTransaction()), new TransactionPair<>(fixture.target(), transfer.getTargetTransaction()) }); @@ -336,7 +336,7 @@ public void testLegacyPortfolioTransferSelectionDeleteRemainsUnchanged() transfer.setCurrencyCode(CurrencyUnit.EUR); transfer.insert(); - TransactionContextMenu.deleteTransactions(fixture.client(), + LedgerTransactionUiSupport.deleteTransactions(fixture.client(), new Object[] { new TransactionPair<>(fixture.source(), transfer.getSourceTransaction()), new TransactionPair<>(fixture.target(), transfer.getTargetTransaction()) }); diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/AllTransactionsView.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/AllTransactionsView.java index a4e53c13f5..85b5e49b94 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/AllTransactionsView.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/AllTransactionsView.java @@ -7,7 +7,6 @@ import java.io.Writer; import java.nio.charset.StandardCharsets; import java.text.MessageFormat; -import java.util.ArrayList; import java.util.List; import jakarta.inject.Inject; @@ -26,9 +25,6 @@ import org.eclipse.swt.widgets.FileDialog; import name.abuchen.portfolio.json.JClient; -import name.abuchen.portfolio.model.AccountTransaction; -import name.abuchen.portfolio.model.Client; -import name.abuchen.portfolio.model.PortfolioTransaction; import name.abuchen.portfolio.model.TransactionPair; import name.abuchen.portfolio.snapshot.filter.PortfolioClientFilter; import name.abuchen.portfolio.ui.Images; @@ -65,7 +61,7 @@ public AllTransactionsView(IPreferenceStore preferenceStore) @Override public void notifyModelUpdated() { - var allTransactions = getTransactionsForView(getClient()); + var allTransactions = LedgerTransactionUiSupport.transactionsForView(getClient()); table.setInput(allTransactions); updateTitle(Messages.LabelAllTransactions + " (" //$NON-NLS-1$ @@ -157,7 +153,7 @@ public boolean select(Viewer viewer, Object parentElement, Object element) if (clientFilter == null) return true; TransactionPair tx = (TransactionPair) element; - return matchesClientFilter(clientFilter, tx); + return LedgerTransactionUiSupport.matchesClientFilter(clientFilter, tx); } }); @@ -169,47 +165,6 @@ public boolean select(Viewer viewer, Object parentElement, Object element) return table.getControl(); } - static List> getTransactionsForView(Client client) - { - List> transactions = new ArrayList<>(); - - for (var portfolio : client.getPortfolios()) - portfolio.getTransactions().stream().map(t -> new TransactionPair<>(portfolio, t)) - .forEach(transactions::add); - - for (var account : client.getAccounts()) - account.getTransactions().stream() - .filter(t -> t.getType() != AccountTransaction.Type.BUY - && t.getType() != AccountTransaction.Type.SELL) - .map(t -> new TransactionPair<>(account, t)).forEach(transactions::add); - - return transactions; - } - - static boolean matchesClientFilter(PortfolioClientFilter clientFilter, TransactionPair tx) - { - if (isTransfer(tx)) - return clientFilter.hasElement(tx.getOwner()); - - // check owner and cross owner - return clientFilter.hasElement(tx.getOwner()) || (tx.getTransaction().getCrossEntry() != null - && clientFilter.hasElement(tx.getTransaction().getCrossEntry() - .getCrossOwner(tx.getTransaction()))); - } - - private static boolean isTransfer(TransactionPair tx) - { - var transaction = tx.getTransaction(); - - return transaction instanceof AccountTransaction accountTransaction - && (accountTransaction.getType() == AccountTransaction.Type.TRANSFER_IN - || accountTransaction.getType() == AccountTransaction.Type.TRANSFER_OUT) - || transaction instanceof PortfolioTransaction portfolioTransaction - && (portfolioTransaction.getType() == PortfolioTransaction.Type.TRANSFER_IN - || portfolioTransaction - .getType() == PortfolioTransaction.Type.TRANSFER_OUT); - } - @Override protected void addPanePages(List pages) { diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/LedgerTransactionUiSupport.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/LedgerTransactionUiSupport.java new file mode 100644 index 0000000000..6202a318b8 --- /dev/null +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/LedgerTransactionUiSupport.java @@ -0,0 +1,148 @@ +package name.abuchen.portfolio.ui.views; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.eclipse.jface.viewers.IStructuredSelection; + +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.CrossEntry; +import name.abuchen.portfolio.model.PortfolioTransaction; +import name.abuchen.portfolio.model.Transaction; +import name.abuchen.portfolio.model.TransactionPair; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerNativeComponentInspectorModel; +import name.abuchen.portfolio.snapshot.filter.PortfolioClientFilter; + +public final class LedgerTransactionUiSupport +{ + private LedgerTransactionUiSupport() + { + } + + public static List> transactionsForView(Client client) + { + List> transactions = new ArrayList<>(); + + for (var portfolio : client.getPortfolios()) + portfolio.getTransactions().stream().map(t -> new TransactionPair<>(portfolio, t)) + .forEach(transactions::add); + + for (var account : client.getAccounts()) + account.getTransactions().stream() + .filter(t -> t.getType() != AccountTransaction.Type.BUY + && t.getType() != AccountTransaction.Type.SELL) + .map(t -> new TransactionPair<>(account, t)).forEach(transactions::add); + + return transactions; + } + + public static boolean matchesClientFilter(PortfolioClientFilter clientFilter, TransactionPair tx) + { + if (isTransfer(tx)) + return clientFilter.hasElement(tx.getOwner()); + + return clientFilter.hasElement(tx.getOwner()) || (tx.getTransaction().getCrossEntry() != null + && clientFilter.hasElement(tx.getTransaction().getCrossEntry() + .getCrossOwner(tx.getTransaction()))); + } + + public static void deleteTransactions(Client client, Object[] selectedTransactions) + { + Set deletedLedgerEntryUUIDs = new HashSet<>(); + Set deletedTransactionUUIDs = new HashSet<>(); + + for (Object item : selectedTransactions) + { + TransactionPair pair = (TransactionPair) item; + var transaction = pair.getTransaction(); + var ledgerEntryUUID = pair.getLedgerEntryUUID(); + + if (ledgerEntryUUID.isPresent()) + { + if (!deletedLedgerEntryUUIDs.add(ledgerEntryUUID.get())) + continue; + + pair.deleteTransaction(client); + continue; + } + + if (!deletedTransactionUUIDs.add(transaction.getUUID())) + continue; + + CrossEntry crossEntry = transaction.getCrossEntry(); + if (crossEntry != null) + { + var crossTransaction = crossEntry.getCrossTransaction(transaction); + + if (crossTransaction != null) + deletedTransactionUUIDs.add(crossTransaction.getUUID()); + } + + pair.deleteTransaction(client); + } + } + + public static boolean supportsBuySellToDeliveryAction( + Collection> txCollection) + { + return !txCollection.isEmpty() && txCollection.stream().noneMatch(LedgerTransactionUiSupport::isNativeTargetedProjection) + && txCollection.stream().allMatch(tx -> { + var type = tx.getTransaction().getType(); + return type == PortfolioTransaction.Type.BUY || type == PortfolioTransaction.Type.SELL; + }); + } + + public static boolean supportsDeliveryToBuySellAction( + Collection> txCollection) + { + return !txCollection.isEmpty() && txCollection.stream().noneMatch(LedgerTransactionUiSupport::isNativeTargetedProjection) + && txCollection.stream().allMatch(tx -> { + var type = tx.getTransaction().getType(); + return type == PortfolioTransaction.Type.DELIVERY_INBOUND + || type == PortfolioTransaction.Type.DELIVERY_OUTBOUND; + }); + } + + public static boolean containsNativeTargetedProjection(IStructuredSelection selection) + { + return selection.stream() // + .filter(TransactionPair.class::isInstance) // + .map(TransactionPair.class::cast) // + .anyMatch(LedgerTransactionUiSupport::isNativeTargetedProjection); + } + + public static boolean containsNativeTargetedAccountTransaction(IStructuredSelection selection) + { + return selection.stream() // + .filter(AccountTransaction.class::isInstance) // + .map(AccountTransaction.class::cast) // + .anyMatch(LedgerTransactionUiSupport::isNativeTargetedProjection); + } + + public static boolean isNativeTargetedProjection(TransactionPair tx) + { + return isNativeTargetedProjection(tx.getTransaction()); + } + + public static boolean isNativeTargetedProjection(Transaction transaction) + { + return LedgerNativeComponentInspectorModel.isLedgerNativeTargetedProjection(transaction); + } + + private static boolean isTransfer(TransactionPair tx) + { + var transaction = tx.getTransaction(); + + return transaction instanceof AccountTransaction accountTransaction + && (accountTransaction.getType() == AccountTransaction.Type.TRANSFER_IN + || accountTransaction.getType() == AccountTransaction.Type.TRANSFER_OUT) + || transaction instanceof PortfolioTransaction portfolioTransaction + && (portfolioTransaction.getType() == PortfolioTransaction.Type.TRANSFER_IN + || portfolioTransaction + .getType() == PortfolioTransaction.Type.TRANSFER_OUT); + } +} diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/TransactionContextMenu.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/TransactionContextMenu.java index 1d8ef7aa7d..7bf72dfb2e 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/TransactionContextMenu.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/TransactionContextMenu.java @@ -2,10 +2,8 @@ import java.util.ArrayList; import java.util.Collection; -import java.util.HashSet; import java.util.Iterator; import java.util.List; -import java.util.Set; import org.eclipse.jface.action.Action; import org.eclipse.jface.action.IMenuManager; @@ -18,13 +16,10 @@ import name.abuchen.portfolio.model.AccountTransaction; import name.abuchen.portfolio.model.AccountTransferEntry; import name.abuchen.portfolio.model.BuySellEntry; -import name.abuchen.portfolio.model.Client; -import name.abuchen.portfolio.model.CrossEntry; import name.abuchen.portfolio.model.Portfolio; import name.abuchen.portfolio.model.PortfolioTransaction; import name.abuchen.portfolio.model.PortfolioTransferEntry; import name.abuchen.portfolio.model.TransactionPair; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerNativeComponentInspectorModel; import name.abuchen.portfolio.ui.Messages; import name.abuchen.portfolio.ui.dialogs.transactions.AccountTransactionDialog; import name.abuchen.portfolio.ui.dialogs.transactions.AccountTransferDialog; @@ -76,10 +71,10 @@ public void menuAboutToShow(IMenuManager manager, boolean fullContextMenu, IStru manager.add(new Separator()); - if (!containsLedgerNativeTargetedProjection(selection)) + if (!LedgerTransactionUiSupport.containsNativeTargetedProjection(selection)) { manager.add(new SimpleAction(Messages.MenuTransactionDelete, a -> { - deleteTransactions(owner.getClient(), selection.toArray()); + LedgerTransactionUiSupport.deleteTransactions(owner.getClient(), selection.toArray()); owner.markDirty(); })); @@ -87,42 +82,6 @@ public void menuAboutToShow(IMenuManager manager, boolean fullContextMenu, IStru } } - static void deleteTransactions(Client client, Object[] selectedTransactions) - { - Set deletedLedgerEntryUUIDs = new HashSet<>(); - Set deletedTransactionUUIDs = new HashSet<>(); - - for (Object item : selectedTransactions) - { - TransactionPair pair = (TransactionPair) item; - var transaction = pair.getTransaction(); - var ledgerEntryUUID = pair.getLedgerEntryUUID(); - - if (ledgerEntryUUID.isPresent()) - { - if (!deletedLedgerEntryUUIDs.add(ledgerEntryUUID.get())) - continue; - - pair.deleteTransaction(client); - continue; - } - - if (!deletedTransactionUUIDs.add(transaction.getUUID())) - continue; - - CrossEntry crossEntry = transaction.getCrossEntry(); - if (crossEntry != null) - { - var crossTransaction = crossEntry.getCrossTransaction(transaction); - - if (crossTransaction != null) - deletedTransactionUUIDs.add(crossTransaction.getUUID()); - } - - pair.deleteTransaction(client); - } - } - public void handleEditKey(KeyEvent e, IStructuredSelection selection) { if (e.keyCode == 'e' && e.stateMask == SWT.MOD1) @@ -131,7 +90,7 @@ public void handleEditKey(KeyEvent e, IStructuredSelection selection) return; TransactionPair tx = (TransactionPair) selection.getFirstElement(); - if (isLedgerNativeTargetedProjection(tx)) + if (LedgerTransactionUiSupport.isNativeTargetedProjection(tx)) return; tx.withAccountTransaction().ifPresent(t -> createEditAccountTransactionAction(t).run()); @@ -143,7 +102,7 @@ public void handleEditKey(KeyEvent e, IStructuredSelection selection) return; TransactionPair tx = (TransactionPair) selection.getFirstElement(); - if (isLedgerNativeTargetedProjection(tx)) + if (LedgerTransactionUiSupport.isNativeTargetedProjection(tx)) return; tx.withAccountTransaction().ifPresent(t -> createCopyAccountTransactionAction(t).run()); @@ -162,7 +121,7 @@ private void fillContextMenuAccountTxList(IMenuManager manager, IStructuredSelec if (accountTxPairs.size() != selection.size()) return; - if (accountTxPairs.stream().anyMatch(TransactionContextMenu::isLedgerNativeTargetedProjection)) + if (accountTxPairs.stream().anyMatch(LedgerTransactionUiSupport::isNativeTargetedProjection)) return; var transfers = accountTxPairs.stream() @@ -197,45 +156,26 @@ private void fillContextMenuPortfolioTxList(IMenuManager manager, IStructuredSel if (txCollection.size() != selection.size()) return; - if (txCollection.stream().anyMatch(TransactionContextMenu::isLedgerNativeTargetedProjection)) + if (txCollection.stream().anyMatch(LedgerTransactionUiSupport::isNativeTargetedProjection)) return; - if (supportsBuySellToDeliveryAction(txCollection)) + if (LedgerTransactionUiSupport.supportsBuySellToDeliveryAction(txCollection)) { manager.add(new ConvertBuySellToDeliveryAction(owner.getClient(), txCollection)); } - if (supportsDeliveryToBuySellAction(txCollection)) + if (LedgerTransactionUiSupport.supportsDeliveryToBuySellAction(txCollection)) { manager.add(new ConvertDeliveryToBuySellAction(owner.getClient(), txCollection)); } } - static boolean supportsBuySellToDeliveryAction(Collection> txCollection) - { - return !txCollection.isEmpty() && txCollection.stream().noneMatch(TransactionContextMenu::isLedgerNativeTargetedProjection) - && txCollection.stream().allMatch(tx -> { - var type = tx.getTransaction().getType(); - return type == PortfolioTransaction.Type.BUY || type == PortfolioTransaction.Type.SELL; - }); - } - - static boolean supportsDeliveryToBuySellAction(Collection> txCollection) - { - return !txCollection.isEmpty() && txCollection.stream().noneMatch(TransactionContextMenu::isLedgerNativeTargetedProjection) - && txCollection.stream().allMatch(tx -> { - var type = tx.getTransaction().getType(); - return type == PortfolioTransaction.Type.DELIVERY_INBOUND - || type == PortfolioTransaction.Type.DELIVERY_OUTBOUND; - }); - } - private void fillContextMenuAccountTx(IMenuManager manager, boolean fullContextMenu, TransactionPair tx) { LedgerNativeComponentInspectorAction.create(owner, tx.getTransaction()).ifPresent(manager::add); - if (isLedgerNativeTargetedProjection(tx)) + if (LedgerTransactionUiSupport.isNativeTargetedProjection(tx)) return; Action action = createEditAccountTransactionAction(tx); @@ -261,7 +201,7 @@ private void fillContextMenuPortfolioTx(IMenuManager manager, boolean fullContex LedgerNativeComponentInspectorAction.create(owner, tx.getTransaction()).ifPresent(manager::add); - if (isLedgerNativeTargetedProjection(tx)) + if (LedgerTransactionUiSupport.isNativeTargetedProjection(tx)) return; Action editAction = createEditPortfolioTransactionAction(tx); @@ -371,17 +311,4 @@ else if (tx.getTransaction().getCrossEntry() instanceof PortfolioTransferEntry e } } - static boolean containsLedgerNativeTargetedProjection(IStructuredSelection selection) - { - return selection.stream() // - .filter(TransactionPair.class::isInstance) // - .map(TransactionPair.class::cast) // - .anyMatch(TransactionContextMenu::isLedgerNativeTargetedProjection); - } - - static boolean isLedgerNativeTargetedProjection(TransactionPair tx) - { - return LedgerNativeComponentInspectorModel.isLedgerNativeTargetedProjection(tx.getTransaction()); - } - } diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/panes/AccountTransactionsPane.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/panes/AccountTransactionsPane.java index 43663c9708..5f51585874 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/panes/AccountTransactionsPane.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/panes/AccountTransactionsPane.java @@ -49,7 +49,6 @@ import name.abuchen.portfolio.model.ledger.compatibility.LedgerDividendTransactionCreator; import name.abuchen.portfolio.model.ledger.compatibility.LedgerInlineEditingField; import name.abuchen.portfolio.model.ledger.compatibility.LedgerInlineEditingPolicy; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerNativeComponentInspectorModel; import name.abuchen.portfolio.money.Money; import name.abuchen.portfolio.money.MutableMoney; import name.abuchen.portfolio.money.Quote; @@ -87,6 +86,7 @@ import name.abuchen.portfolio.ui.util.viewers.ValueEditingSupport; import name.abuchen.portfolio.ui.views.AccountContextMenu; import name.abuchen.portfolio.ui.views.AccountListView; +import name.abuchen.portfolio.ui.views.LedgerTransactionUiSupport; import name.abuchen.portfolio.ui.views.actions.ConvertTransferToDepositRemovalAction; import name.abuchen.portfolio.ui.views.actions.CreateRemovalForDividendAction; import name.abuchen.portfolio.ui.views.actions.LedgerNativeComponentInspectorAction; @@ -110,7 +110,7 @@ private final class LedgerAwareDividendSharesEditingSupport extends ValueEditing @Override public boolean canEdit(Object element) { - return !isLedgerNativeTargetedProjection((AccountTransaction) element) + return !LedgerTransactionUiSupport.isNativeTargetedProjection((AccountTransaction) element) && LedgerInlineEditingPolicy.isEditable(element, LedgerInlineEditingField.SHARES) && ((AccountTransaction) element).getType() == AccountTransaction.Type.DIVIDENDS; } @@ -593,7 +593,8 @@ public void keyPressed(KeyEvent e) AccountTransaction transaction = (AccountTransaction) ((IStructuredSelection) transactions .getSelection()).getFirstElement(); - if (account != null && transaction != null && !isLedgerNativeTargetedProjection(transaction)) + if (account != null && transaction != null + && !LedgerTransactionUiSupport.isNativeTargetedProjection(transaction)) createEditAction(account, transaction).run(); } if (e.keyCode == 'd' && e.stateMask == SWT.MOD1) @@ -601,7 +602,8 @@ public void keyPressed(KeyEvent e) AccountTransaction transaction = (AccountTransaction) ((IStructuredSelection) transactions .getSelection()).getFirstElement(); - if (account != null && transaction != null && !isLedgerNativeTargetedProjection(transaction)) + if (account != null && transaction != null + && !LedgerTransactionUiSupport.isNativeTargetedProjection(transaction)) createCopyAction(account, transaction).run(); } } @@ -620,7 +622,7 @@ private void fillTransactionsContextMenu(IMenuManager manager) // NOSONAR { LedgerNativeComponentInspectorAction.create(view, transaction).ifPresent(manager::add); - if (isLedgerNativeTargetedProjection(transaction)) + if (LedgerTransactionUiSupport.isNativeTargetedProjection(transaction)) return; Action action = createEditAction(account, transaction); @@ -642,7 +644,7 @@ private void fillTransactionsContextMenu(IMenuManager manager) // NOSONAR fillCreateRemovalForDividendAction(manager, selection); } - if (transaction != null && !containsLedgerNativeTargetedProjection(selection)) + if (transaction != null && !LedgerTransactionUiSupport.containsNativeTargetedAccountTransaction(selection)) { manager.add(new Separator()); @@ -669,7 +671,7 @@ public void run() private void fillConvertTransferToDepositRemovalAction(IMenuManager manager, IStructuredSelection selection) { - if (containsLedgerNativeTargetedProjection(selection)) + if (LedgerTransactionUiSupport.containsNativeTargetedAccountTransaction(selection)) return; // create collection with all selected transactions @@ -699,7 +701,7 @@ private void fillConvertTransferToDepositRemovalAction(IMenuManager manager, ISt private void fillCreateRemovalForDividendAction(IMenuManager manager, IStructuredSelection selection) { - if (containsLedgerNativeTargetedProjection(selection)) + if (LedgerTransactionUiSupport.containsNativeTargetedAccountTransaction(selection)) return; // create collection with all selected transactions @@ -715,19 +717,6 @@ private void fillCreateRemovalForDividendAction(IMenuManager manager, IStructure } } - private static boolean containsLedgerNativeTargetedProjection(IStructuredSelection selection) - { - return selection.stream() // - .filter(AccountTransaction.class::isInstance) // - .map(AccountTransaction.class::cast) // - .anyMatch(AccountTransactionsPane::isLedgerNativeTargetedProjection); - } - - private static boolean isLedgerNativeTargetedProjection(AccountTransaction transaction) - { - return LedgerNativeComponentInspectorModel.isLedgerNativeTargetedProjection(transaction); - } - private Action createEditAction(Account account, AccountTransaction transaction) { // buy / sell From 7604777cdcbb3420c35e3e260c76e686e9623fff Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:18:58 +0200 Subject: [PATCH 33/68] Wrap Ledger inline editing policy Move Ledger-specific inline editability checks, type-transition safety, owner patch routing, native-target checks, and Ledger-backed share/ex-date updates into LedgerInlineEditingPolicy. This keeps table editing support classes focused on editor mechanics while preserving existing supported and refused Ledger inline-edit paths. Dialog commit paths, context menu routing, converter actions, check/repair paths, import actions, persistence, migration diagnostics, InvestmentPlan linkage, Ledger model semantics, and Corporate Action behavior are intentionally unchanged. --- .../views/LedgerTransactionRoutingTest.java | 9 +- .../ui/util/viewers/ColumnEditingSupport.java | 12 +- .../ui/util/viewers/ExDateEditingSupport.java | 55 +-- .../TransactionOwnerListEditingSupport.java | 121 +----- .../TransactionTypeEditingSupport.java | 182 +-------- .../ui/views/TransactionsViewer.java | 80 +--- .../views/panes/AccountTransactionsPane.java | 10 +- .../LedgerInlineEditingPolicy.java | 379 ++++++++++++++++++ 8 files changed, 408 insertions(+), 440 deletions(-) diff --git a/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/views/LedgerTransactionRoutingTest.java b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/views/LedgerTransactionRoutingTest.java index 7af9e85deb..13df13455b 100644 --- a/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/views/LedgerTransactionRoutingTest.java +++ b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/views/LedgerTransactionRoutingTest.java @@ -46,6 +46,7 @@ import name.abuchen.portfolio.model.ledger.compatibility.LedgerDeliveryTransactionCreator; import name.abuchen.portfolio.model.ledger.compatibility.LedgerDividendTransactionCreator; import name.abuchen.portfolio.model.ledger.compatibility.LedgerInlineEditingField; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerInlineEditingPolicy; import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferTransactionCreator; import name.abuchen.portfolio.money.CurrencyUnit; import name.abuchen.portfolio.money.Values; @@ -507,7 +508,7 @@ public void testLedgerBackedSharesInlineEditPolicy() throws Exception assertThat(TransactionsViewer.canEditShares(new TransactionPair<>(buy.portfolio(), portfolioTransaction)), is(false)); - assertThat(TransactionsViewer.updateLedgerBackedShares(buy.client(), + assertThat(LedgerInlineEditingPolicy.updateShares(buy.client(), new TransactionPair<>(buy.portfolio(), portfolioTransaction), Values.Share.factorize(6)), is(false)); assertThat(buy.portfolio().getTransactions().get(0).getShares(), is(Values.Share.factorize(5))); @@ -519,7 +520,7 @@ public void testLedgerBackedSharesInlineEditPolicy() throws Exception var deliveryTransaction = delivery.portfolio().getTransactions().get(0); assertThat(TransactionsViewer.canEditShares(new TransactionPair<>(delivery.portfolio(), deliveryTransaction)), is(false)); - assertThat(TransactionsViewer.updateLedgerBackedShares(delivery.client(), + assertThat(LedgerInlineEditingPolicy.updateShares(delivery.client(), new TransactionPair<>(delivery.portfolio(), deliveryTransaction), Values.Share.factorize(7)), is(false)); assertThat(delivery.portfolio().getTransactions().get(0).getShares(), is(Values.Share.factorize(5))); @@ -530,7 +531,7 @@ public void testLedgerBackedSharesInlineEditPolicy() throws Exception var sourceTransaction = transfer.source().getTransactions().get(0); assertThat(TransactionsViewer.canEditShares(new TransactionPair<>(transfer.source(), sourceTransaction)), is(false)); - assertThat(TransactionsViewer.updateLedgerBackedShares(transfer.client(), + assertThat(LedgerInlineEditingPolicy.updateShares(transfer.client(), new TransactionPair<>(transfer.source(), sourceTransaction), Values.Share.factorize(8)), is(false)); assertThat(transfer.source().getTransactions().get(0).getShares(), is(Values.Share.factorize(5))); @@ -543,7 +544,7 @@ public void testLedgerBackedSharesInlineEditPolicy() throws Exception var dividendTransaction = dividend.source().getTransactions().get(0); assertThat(TransactionsViewer.canEditShares(new TransactionPair<>(dividend.source(), dividendTransaction)), is(true)); - assertThat(TransactionsViewer.updateLedgerBackedShares(dividend.client(), + assertThat(LedgerInlineEditingPolicy.updateShares(dividend.client(), new TransactionPair<>(dividend.source(), dividendTransaction), Values.Share.factorize(9)), is(true)); assertThat(dividend.source().getTransactions().get(0).getShares(), is(Values.Share.factorize(9))); diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/ColumnEditingSupport.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/ColumnEditingSupport.java index cf500518ea..831ea0cc90 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/ColumnEditingSupport.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/ColumnEditingSupport.java @@ -18,9 +18,7 @@ import org.eclipse.swt.widgets.Composite; import name.abuchen.portfolio.model.Client; -import name.abuchen.portfolio.model.Transaction; -import name.abuchen.portfolio.model.TransactionPair; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerNativeComponentInspectorModel; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerInlineEditingPolicy; import name.abuchen.portfolio.ui.PortfolioPlugin; import name.abuchen.portfolio.ui.UIConstants; import name.abuchen.portfolio.ui.editor.EditorActivationState; @@ -79,13 +77,7 @@ public boolean canEdit(Object element) protected final boolean isLedgerNativeTargetedProjection(Object element) { - if (element instanceof TransactionPair pair) - return LedgerNativeComponentInspectorModel.isLedgerNativeTargetedProjection(pair.getTransaction()); - - if (element instanceof Transaction) - return LedgerNativeComponentInspectorModel.isLedgerNativeTargetedProjection(element); - - return false; + return LedgerInlineEditingPolicy.isNativeTargetedProjection(element); } /** diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/ExDateEditingSupport.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/ExDateEditingSupport.java index 0d5ec84196..472aedd8ec 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/ExDateEditingSupport.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/ExDateEditingSupport.java @@ -14,18 +14,11 @@ import name.abuchen.portfolio.model.Account; import name.abuchen.portfolio.model.AccountTransaction; -import name.abuchen.portfolio.model.AccountTransferEntry; import name.abuchen.portfolio.model.Adaptor; -import name.abuchen.portfolio.model.BuySellEntry; import name.abuchen.portfolio.model.Client; import name.abuchen.portfolio.model.LedgerDiagnosticCode; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountOnlyTransactionCreator; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountTransferTransactionCreator; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerBuySellTransactionCreator; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerDividendTransactionCreator; import name.abuchen.portfolio.model.ledger.compatibility.LedgerInlineEditingField; import name.abuchen.portfolio.model.ledger.compatibility.LedgerInlineEditingPolicy; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerUnitPostingPatch; import name.abuchen.portfolio.money.Values; import name.abuchen.portfolio.ui.Messages; @@ -54,7 +47,7 @@ public ExDateEditingSupport(Client client) @Override public boolean canEdit(Object element) { - if (isLedgerNativeTargetedProjection(element)) + if (LedgerInlineEditingPolicy.isNativeTargetedProjection(element)) return false; if (!LedgerInlineEditingPolicy.isEditable(element, LedgerInlineEditingField.EX_DATE)) return false; @@ -63,7 +56,8 @@ public boolean canEdit(Object element) if (tx == null || tx.getSecurity() == null) return false; - return canUpdateLedgerBackedDividend(tx) || !isLedgerBackedAccountTransaction(tx); + return LedgerInlineEditingPolicy.canUpdateDividendExDate(client, tx) + || !LedgerInlineEditingPolicy.isLedgerBackedAccountTransaction(client, tx); } @Override @@ -128,10 +122,13 @@ private void updateExDate(AccountTransaction transaction, LocalDateTime exDate) LedgerDiagnosticCode.LEDGER_UI_009 .message(Messages.LedgerExDateEditingSupportNoSafeEditorPolicyBlocked)); - if (updateLedgerBackedDividend(transaction, exDate)) + if (LedgerInlineEditingPolicy.canUpdateDividendExDate(client, transaction)) + { + LedgerInlineEditingPolicy.updateDividendExDate(client, ownerOf(transaction), transaction, exDate); return; + } - if (isLedgerBackedAccountTransaction(transaction)) + if (LedgerInlineEditingPolicy.isLedgerBackedAccountTransaction(client, transaction)) throw new UnsupportedOperationException( LedgerDiagnosticCode.LEDGER_UI_010 .message(Messages.LedgerExDateEditingSupportNoSafeEditorLedgerBacked)); @@ -139,26 +136,6 @@ private void updateExDate(AccountTransaction transaction, LocalDateTime exDate) transaction.setExDate(exDate); } - private boolean updateLedgerBackedDividend(AccountTransaction transaction, LocalDateTime exDate) - { - if (client == null) - return false; - - if (!canUpdateLedgerBackedDividend(transaction)) - return false; - - new LedgerDividendTransactionCreator(client).update(transaction, ownerOf(transaction), transaction.getType(), - transaction.getDateTime(), transaction.getAmount(), transaction.getCurrencyCode(), - transaction.getSecurity(), transaction.getShares(), exDate, null, - LedgerUnitPostingPatch.none(), transaction.getNote(), transaction.getSource()); - return true; - } - - private boolean canUpdateLedgerBackedDividend(AccountTransaction transaction) - { - return client != null && new LedgerDividendTransactionCreator(client).canUpdate(transaction); - } - private Account ownerOf(AccountTransaction transaction) { return client.getAccounts().stream().filter(account -> account.getTransactions().contains(transaction)) @@ -167,22 +144,6 @@ private Account ownerOf(AccountTransaction transaction) Messages.LedgerExDateEditingSupportDividendOwnerNotFound)); } - private boolean isLedgerBackedAccountTransaction(AccountTransaction transaction) - { - if (client == null) - return false; - - if (new LedgerAccountOnlyTransactionCreator(client).canUpdate(transaction)) - return true; - - if (transaction.getCrossEntry() instanceof BuySellEntry entry - && new LedgerBuySellTransactionCreator(client).isLedgerBacked(entry)) - return true; - - return transaction.getCrossEntry() instanceof AccountTransferEntry entry - && new LedgerAccountTransferTransactionCreator(client).isLedgerBacked(entry); - } - @Override public CellEditor createEditor(Composite composite) { diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/TransactionOwnerListEditingSupport.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/TransactionOwnerListEditingSupport.java index eefdc039e1..30ad33436a 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/TransactionOwnerListEditingSupport.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/TransactionOwnerListEditingSupport.java @@ -11,22 +11,14 @@ import org.eclipse.swt.widgets.Composite; import name.abuchen.portfolio.model.Account; -import name.abuchen.portfolio.model.AccountTransaction; -import name.abuchen.portfolio.model.AccountTransferEntry; -import name.abuchen.portfolio.model.BuySellEntry; import name.abuchen.portfolio.model.Client; import name.abuchen.portfolio.model.CrossEntry; import name.abuchen.portfolio.model.LedgerDiagnosticCode; import name.abuchen.portfolio.model.Portfolio; -import name.abuchen.portfolio.model.PortfolioTransaction; -import name.abuchen.portfolio.model.PortfolioTransferEntry; import name.abuchen.portfolio.model.Transaction; import name.abuchen.portfolio.model.TransactionOwner; import name.abuchen.portfolio.model.TransactionPair; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountTransferTransactionCreator; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerBuySellTransactionCreator; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerOwnerPatchHelper; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerInlineEditingPolicy; import name.abuchen.portfolio.ui.Messages; /** @@ -92,45 +84,10 @@ else if (element instanceof TransactionPair pair) public boolean canEdit(Object element) { Transaction transaction = getTransaction(element); - if (isLedgerNativeTargetedProjection(transaction)) + if (LedgerInlineEditingPolicy.isNativeTargetedProjection(transaction)) return false; - return transaction != null && transaction.getCrossEntry() != null && canEdit(transaction); - } - - private boolean isLedgerBacked(Transaction transaction) - { - if (transaction instanceof AccountTransaction - && transaction.getCrossEntry() instanceof AccountTransferEntry entry) - return new LedgerAccountTransferTransactionCreator(client).isLedgerBacked(entry); - - if (transaction instanceof PortfolioTransaction - && transaction.getCrossEntry() instanceof PortfolioTransferEntry entry) - return new LedgerPortfolioTransferTransactionCreator(client).isLedgerBacked(entry); - - if (transaction.getCrossEntry() instanceof BuySellEntry entry) - return new LedgerBuySellTransactionCreator(client).isLedgerBacked(entry); - - return false; - } - - private boolean canEdit(Transaction transaction) - { - CrossEntry crossEntry = transaction.getCrossEntry(); - - if (crossEntry instanceof AccountTransferEntry entry) - return !new LedgerAccountTransferTransactionCreator(client).isLedgerBacked(entry) - || new LedgerAccountTransferTransactionCreator(client).canUpdate(entry); - - if (crossEntry instanceof PortfolioTransferEntry entry) - return !new LedgerPortfolioTransferTransactionCreator(client).isLedgerBacked(entry) - || new LedgerPortfolioTransferTransactionCreator(client).canUpdate(entry); - - if (crossEntry instanceof BuySellEntry entry) - return !new LedgerBuySellTransactionCreator(client).isLedgerBacked(entry) - || new LedgerBuySellTransactionCreator(client).canUpdate(entry); - - return true; + return LedgerInlineEditingPolicy.canEditOwner(client, transaction); } @Override @@ -240,9 +197,10 @@ public final void setValue(Object element, Object value) throws Exception validateOwnerChange(crossEntry, transaction, newValue); - if (isLedgerBacked(transaction)) + if (LedgerInlineEditingPolicy.isLedgerBacked(client, transaction)) { - setLedgerBackedValue(crossEntry, transaction, newValue); + if (!LedgerInlineEditingPolicy.updateOwner(client, transaction, oldValue, newValue)) + throw unsupportedLedgerOwnerEdit(crossEntry); notify(element, newValue, oldValue); return; } @@ -282,74 +240,11 @@ private void validateOwnerChange(CrossEntry crossEntry, Transaction transaction, } } - private void setLedgerBackedValue(CrossEntry crossEntry, Transaction transaction, TransactionOwner newValue) + private UnsupportedOperationException unsupportedLedgerOwnerEdit(CrossEntry crossEntry) { - if (crossEntry instanceof BuySellEntry entry) - { - updateBuySell(entry, newValue); - return; - } - - if (crossEntry instanceof AccountTransferEntry entry && newValue instanceof Account account) - { - updateAccountTransfer(entry, editMode.getOwner(crossEntry, transaction), account); - return; - } - - if (crossEntry instanceof PortfolioTransferEntry entry && newValue instanceof Portfolio portfolio) - { - updatePortfolioTransfer(entry, editMode.getOwner(crossEntry, transaction), portfolio); - return; - } - - throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_UI_013 + return new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_UI_013 .message(MessageFormat.format( Messages.LedgerTransactionOwnerListEditingSupportUnsupportedOwnerEdit, crossEntry))); } - - private void updateBuySell(BuySellEntry entry, TransactionOwner newValue) - { - var helper = new LedgerOwnerPatchHelper(client); - if (newValue instanceof Account newAccount) - helper.moveBuySellAccountSide(entry, newAccount); - else if (newValue instanceof Portfolio newPortfolio) - helper.moveBuySellPortfolioSide(entry, newPortfolio); - else - throw new IllegalArgumentException( - LedgerDiagnosticCode.LEDGER_UI_014.message(MessageFormat.format( - Messages.LedgerTransactionOwnerListEditingSupportUnsupportedOwnerType, - newValue))); - } - - private void updateAccountTransfer(AccountTransferEntry entry, TransactionOwner oldValue, Account newAccount) - { - var helper = new LedgerOwnerPatchHelper(client); - - if (oldValue.equals(entry.getSourceAccount())) - helper.moveAccountTransferSource(entry, newAccount); - else if (oldValue.equals(entry.getTargetAccount())) - helper.moveAccountTransferTarget(entry, newAccount); - else - throw new IllegalArgumentException( - LedgerDiagnosticCode.LEDGER_UI_015.message(MessageFormat.format( - Messages.LedgerTransactionOwnerListEditingSupportAccountTransferOwnerNotFound, - oldValue))); - } - - private void updatePortfolioTransfer(PortfolioTransferEntry entry, TransactionOwner oldValue, - Portfolio newPortfolio) - { - var helper = new LedgerOwnerPatchHelper(client); - - if (oldValue.equals(entry.getSourcePortfolio())) - helper.movePortfolioTransferSource(entry, newPortfolio); - else if (oldValue.equals(entry.getTargetPortfolio())) - helper.movePortfolioTransferTarget(entry, newPortfolio); - else - throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_UI_016 - .message(MessageFormat.format( - Messages.LedgerTransactionOwnerListEditingSupportPortfolioTransferOwnerNotFound, - oldValue))); - } } diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/TransactionTypeEditingSupport.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/TransactionTypeEditingSupport.java index d523700e92..83d83f5d5e 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/TransactionTypeEditingSupport.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/viewers/TransactionTypeEditingSupport.java @@ -12,29 +12,14 @@ import name.abuchen.portfolio.model.Account; import name.abuchen.portfolio.model.AccountTransaction; -import name.abuchen.portfolio.model.AccountTransferEntry; -import name.abuchen.portfolio.model.BuySellEntry; import name.abuchen.portfolio.model.Client; -import name.abuchen.portfolio.model.LedgerAccountTypeToggleConverter; -import name.abuchen.portfolio.model.LedgerBuySellDeliveryConverter; -import name.abuchen.portfolio.model.LedgerBuySellReversalConverter; -import name.abuchen.portfolio.model.LedgerDeliveryDirectionConverter; import name.abuchen.portfolio.model.LedgerDiagnosticCode; -import name.abuchen.portfolio.model.LedgerPortfolioCompositeTypeConverter; -import name.abuchen.portfolio.model.LedgerTransferDirectionConverter; import name.abuchen.portfolio.model.Portfolio; import name.abuchen.portfolio.model.PortfolioTransaction; -import name.abuchen.portfolio.model.PortfolioTransferEntry; import name.abuchen.portfolio.model.Transaction; import name.abuchen.portfolio.model.TransactionPair; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountOnlyTransactionCreator; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountTransferTransactionCreator; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerBuySellTransactionCreator; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerDeliveryTransactionCreator; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerDividendTransactionCreator; import name.abuchen.portfolio.model.ledger.compatibility.LedgerInlineEditingField; import name.abuchen.portfolio.model.ledger.compatibility.LedgerInlineEditingPolicy; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferTransactionCreator; import name.abuchen.portfolio.ui.Messages; import name.abuchen.portfolio.ui.views.actions.ConvertBuySellToDeliveryAction; import name.abuchen.portfolio.ui.views.actions.ConvertDeliveryToBuySellAction; @@ -191,7 +176,7 @@ else if (t instanceof PortfolioTransaction pt) public boolean canEdit(Object element) { Transaction t = getTransaction(element); - if (isLedgerNativeTargetedProjection(t)) + if (LedgerInlineEditingPolicy.isNativeTargetedProjection(t)) return false; if (!LedgerInlineEditingPolicy.isEditable(t, LedgerInlineEditingField.TYPE)) return false; @@ -289,169 +274,8 @@ private Class[] getTransition(Enum fromValue, Enum toValue) private boolean supportsTransition(Transaction transaction, Enum fromValue, Enum toValue) { - if (!isLedgerBacked(transaction)) - return true; - - if (isLedgerBackedAccountTransfer(transaction)) - return (fromValue == AccountTransaction.Type.TRANSFER_IN && toValue == AccountTransaction.Type.TRANSFER_OUT - || fromValue == AccountTransaction.Type.TRANSFER_OUT - && toValue == AccountTransaction.Type.TRANSFER_IN) - && new LedgerTransferDirectionConverter(client) - .canReverseSafely((AccountTransferEntry) transaction.getCrossEntry()); - - if (isLedgerBackedPortfolioTransfer(transaction)) - return (fromValue == PortfolioTransaction.Type.TRANSFER_IN - && toValue == PortfolioTransaction.Type.TRANSFER_OUT - || fromValue == PortfolioTransaction.Type.TRANSFER_OUT - && toValue == PortfolioTransaction.Type.TRANSFER_IN) - && new LedgerTransferDirectionConverter(client) - .canReverseSafely((PortfolioTransferEntry) transaction.getCrossEntry()); - - if (isLedgerBackedBuySell(transaction)) - { - if (fromValue == AccountTransaction.Type.BUY) - return toValue == AccountTransaction.Type.SELL - && new LedgerBuySellReversalConverter(client) - .canReverseSafely((BuySellEntry) transaction.getCrossEntry()); - if (fromValue == AccountTransaction.Type.SELL) - return toValue == AccountTransaction.Type.BUY - && new LedgerBuySellReversalConverter(client) - .canReverseSafely((BuySellEntry) transaction.getCrossEntry()); - if (fromValue == PortfolioTransaction.Type.BUY) - return toValue == PortfolioTransaction.Type.SELL - && new LedgerBuySellReversalConverter(client) - .canReverseSafely((BuySellEntry) transaction.getCrossEntry()) - || toValue == PortfolioTransaction.Type.DELIVERY_INBOUND - && new LedgerBuySellDeliveryConverter(client) - .canConvertSafely(portfolioPair(transaction)) - || toValue == PortfolioTransaction.Type.DELIVERY_OUTBOUND - && new LedgerPortfolioCompositeTypeConverter(client) - .canConvertSafely(portfolioPair(transaction)); - if (fromValue == PortfolioTransaction.Type.SELL) - return toValue == PortfolioTransaction.Type.BUY - && new LedgerBuySellReversalConverter(client) - .canReverseSafely((BuySellEntry) transaction.getCrossEntry()) - || toValue == PortfolioTransaction.Type.DELIVERY_OUTBOUND - && new LedgerBuySellDeliveryConverter(client) - .canConvertSafely(portfolioPair(transaction)) - || toValue == PortfolioTransaction.Type.DELIVERY_INBOUND - && new LedgerPortfolioCompositeTypeConverter(client) - .canConvertSafely(portfolioPair(transaction)); - } - - if (isLedgerBackedDelivery(transaction)) - { - if (fromValue == PortfolioTransaction.Type.DELIVERY_INBOUND) - return toValue == PortfolioTransaction.Type.DELIVERY_OUTBOUND - && new LedgerDeliveryDirectionConverter(client) - .canReverseSafely(portfolioPair(transaction)) - || toValue == PortfolioTransaction.Type.BUY - && new LedgerBuySellDeliveryConverter(client) - .canConvertDeliveryToBuySellSafely( - portfolioPair(transaction)) - || toValue == PortfolioTransaction.Type.SELL - && new LedgerPortfolioCompositeTypeConverter(client) - .canConvertSafely(portfolioPair(transaction)); - if (fromValue == PortfolioTransaction.Type.DELIVERY_OUTBOUND) - return toValue == PortfolioTransaction.Type.DELIVERY_INBOUND - && new LedgerDeliveryDirectionConverter(client) - .canReverseSafely(portfolioPair(transaction)) - || toValue == PortfolioTransaction.Type.SELL - && new LedgerBuySellDeliveryConverter(client) - .canConvertDeliveryToBuySellSafely( - portfolioPair(transaction)) - || toValue == PortfolioTransaction.Type.BUY - && new LedgerPortfolioCompositeTypeConverter(client) - .canConvertSafely(portfolioPair(transaction)); - } - - if (isLedgerBackedAccountOnly(transaction)) - { - if (fromValue == AccountTransaction.Type.DEPOSIT) - return toValue == AccountTransaction.Type.REMOVAL && new LedgerAccountTypeToggleConverter(client) - .canToggleSafely(accountPair(transaction)); - if (fromValue == AccountTransaction.Type.REMOVAL) - return toValue == AccountTransaction.Type.DEPOSIT && new LedgerAccountTypeToggleConverter(client) - .canToggleSafely(accountPair(transaction)); - if (fromValue == AccountTransaction.Type.INTEREST) - return toValue == AccountTransaction.Type.INTEREST_CHARGE - && new LedgerAccountTypeToggleConverter(client).canToggleSafely( - accountPair(transaction)); - if (fromValue == AccountTransaction.Type.INTEREST_CHARGE) - return toValue == AccountTransaction.Type.INTEREST && new LedgerAccountTypeToggleConverter(client) - .canToggleSafely(accountPair(transaction)); - if (fromValue == AccountTransaction.Type.FEES) - return toValue == AccountTransaction.Type.FEES_REFUND && new LedgerAccountTypeToggleConverter(client) - .canToggleSafely(accountPair(transaction)); - if (fromValue == AccountTransaction.Type.FEES_REFUND) - return toValue == AccountTransaction.Type.FEES && new LedgerAccountTypeToggleConverter(client) - .canToggleSafely(accountPair(transaction)); - if (fromValue == AccountTransaction.Type.TAXES) - return toValue == AccountTransaction.Type.TAX_REFUND && new LedgerAccountTypeToggleConverter(client) - .canToggleSafely(accountPair(transaction)); - if (fromValue == AccountTransaction.Type.TAX_REFUND) - return toValue == AccountTransaction.Type.TAXES && new LedgerAccountTypeToggleConverter(client) - .canToggleSafely(accountPair(transaction)); - } - - return false; - } - - @SuppressWarnings("unchecked") - private TransactionPair accountPair(Transaction transaction) - { - return (TransactionPair) getTransactionPair(transaction); - } - - @SuppressWarnings("unchecked") - private TransactionPair portfolioPair(Transaction transaction) - { - return (TransactionPair) getTransactionPair(transaction); - } - - private boolean isLedgerBacked(Transaction transaction) - { - return isLedgerBackedAccountTransfer(transaction) || isLedgerBackedPortfolioTransfer(transaction) - || isLedgerBackedBuySell(transaction) || isLedgerBackedDelivery(transaction) - || isLedgerBackedAccountOnly(transaction) || isLedgerBackedDividend(transaction); - } - - private boolean isLedgerBackedAccountTransfer(Transaction transaction) - { - return transaction instanceof AccountTransaction - && transaction.getCrossEntry() instanceof AccountTransferEntry entry - && new LedgerAccountTransferTransactionCreator(client).isLedgerBacked(entry); - } - - private boolean isLedgerBackedPortfolioTransfer(Transaction transaction) - { - return transaction instanceof PortfolioTransaction - && transaction.getCrossEntry() instanceof PortfolioTransferEntry entry - && new LedgerPortfolioTransferTransactionCreator(client).isLedgerBacked(entry); - } - - private boolean isLedgerBackedBuySell(Transaction transaction) - { - return transaction.getCrossEntry() instanceof BuySellEntry entry - && new LedgerBuySellTransactionCreator(client).isLedgerBacked(entry); - } - - private boolean isLedgerBackedDelivery(Transaction transaction) - { - return transaction instanceof PortfolioTransaction portfolioTransaction - && new LedgerDeliveryTransactionCreator(client).canUpdate(portfolioTransaction); - } - - private boolean isLedgerBackedAccountOnly(Transaction transaction) - { - return transaction instanceof AccountTransaction accountTransaction - && new LedgerAccountOnlyTransactionCreator(client).canUpdate(accountTransaction); - } - - private boolean isLedgerBackedDividend(Transaction transaction) - { - return transaction instanceof AccountTransaction accountTransaction - && new LedgerDividendTransactionCreator(client).canUpdate(accountTransaction); + return LedgerInlineEditingPolicy.supportsTypeTransition(client, getTransactionPair(transaction), fromValue, + toValue); } } diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/TransactionsViewer.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/TransactionsViewer.java index 593b19e03b..6284ca3587 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/TransactionsViewer.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/TransactionsViewer.java @@ -29,23 +29,14 @@ import org.eclipse.swt.widgets.Control; import org.eclipse.swt.widgets.Menu; -import name.abuchen.portfolio.model.Account; import name.abuchen.portfolio.model.AccountTransaction; -import name.abuchen.portfolio.model.BuySellEntry; -import name.abuchen.portfolio.model.Client; import name.abuchen.portfolio.model.LedgerDiagnosticCode; -import name.abuchen.portfolio.model.Portfolio; import name.abuchen.portfolio.model.PortfolioTransaction; -import name.abuchen.portfolio.model.PortfolioTransferEntry; import name.abuchen.portfolio.model.Security; import name.abuchen.portfolio.model.Transaction; import name.abuchen.portfolio.model.TransactionPair; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerBuySellTransactionCreator; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerDeliveryTransactionCreator; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerDividendTransactionCreator; import name.abuchen.portfolio.model.ledger.compatibility.LedgerInlineEditingField; import name.abuchen.portfolio.model.ledger.compatibility.LedgerInlineEditingPolicy; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferTransactionCreator; import name.abuchen.portfolio.money.Money; import name.abuchen.portfolio.money.Values; import name.abuchen.portfolio.ui.Images; @@ -103,7 +94,7 @@ public void setValue(Object element, Object value) throws Exception if (newValue.equals(oldValue)) return; - if (updateLedgerBackedShares(owner.getClient(), pair, newValue.longValue())) + if (LedgerInlineEditingPolicy.updateShares(owner.getClient(), pair, newValue.longValue())) { notify(element, newValue, oldValue); return; @@ -546,75 +537,6 @@ static boolean canEditShares(Object element) && accountTransaction.getType() == AccountTransaction.Type.DIVIDENDS; } - static boolean updateLedgerBackedShares(Client client, TransactionPair pair, long shares) - { - if (!LedgerInlineEditingPolicy.isEditable(pair, LedgerInlineEditingField.SHARES)) - return false; - - var transaction = pair.getTransaction(); - - if (transaction.getCrossEntry() instanceof BuySellEntry buySellEntry) - { - var creator = new LedgerBuySellTransactionCreator(client); - if (!creator.canUpdate(buySellEntry)) - return false; - - var portfolioTransaction = buySellEntry.getPortfolioTransaction(); - creator.update(buySellEntry, buySellEntry.getPortfolio(), buySellEntry.getAccount(), - portfolioTransaction.getType(), portfolioTransaction.getDateTime(), - portfolioTransaction.getAmount(), portfolioTransaction.getCurrencyCode(), - portfolioTransaction.getSecurity(), shares, portfolioTransaction.getUnits().toList(), - portfolioTransaction.getNote(), portfolioTransaction.getSource()); - return true; - } - - if (transaction.getCrossEntry() instanceof PortfolioTransferEntry transferEntry) - { - var creator = new LedgerPortfolioTransferTransactionCreator(client); - if (!creator.canUpdate(transferEntry)) - return false; - - var sourceTransaction = transferEntry.getSourceTransaction(); - creator.update(transferEntry, transferEntry.getSourcePortfolio(), transferEntry.getTargetPortfolio(), - sourceTransaction.getSecurity(), sourceTransaction.getDateTime(), shares, - sourceTransaction.getAmount(), sourceTransaction.getCurrencyCode(), - sourceTransaction.getNote(), sourceTransaction.getSource()); - return true; - } - - if (transaction instanceof PortfolioTransaction portfolioTransaction) - { - var creator = new LedgerDeliveryTransactionCreator(client); - if (!creator.canUpdate(portfolioTransaction)) - return false; - - creator.update(portfolioTransaction, (Portfolio) pair.getOwner(), - portfolioTransaction.getType(), portfolioTransaction.getDateTime(), - portfolioTransaction.getAmount(), portfolioTransaction.getCurrencyCode(), - portfolioTransaction.getSecurity(), shares, null, null, - portfolioTransaction.getUnits().toList(), portfolioTransaction.getNote(), - portfolioTransaction.getSource()); - return true; - } - - if (transaction instanceof AccountTransaction accountTransaction) - { - var creator = new LedgerDividendTransactionCreator(client); - if (!creator.canUpdate(accountTransaction)) - return false; - - creator.update(accountTransaction, (Account) pair.getOwner(), - accountTransaction.getType(), accountTransaction.getDateTime(), - accountTransaction.getAmount(), accountTransaction.getCurrencyCode(), - accountTransaction.getSecurity(), shares, accountTransaction.getExDate(), null, null, - accountTransaction.getUnits().toList(), accountTransaction.getNote(), - accountTransaction.getSource()); - return true; - } - - return false; - } - private void hookContextMenu(Composite parent) { MenuManager menuMgr = new MenuManager("#PopupMenu"); //$NON-NLS-1$ diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/panes/AccountTransactionsPane.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/panes/AccountTransactionsPane.java index 5f51585874..52659516f5 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/panes/AccountTransactionsPane.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/panes/AccountTransactionsPane.java @@ -46,7 +46,6 @@ import name.abuchen.portfolio.model.Transaction; import name.abuchen.portfolio.model.Transaction.Unit; import name.abuchen.portfolio.model.TransactionPair; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerDividendTransactionCreator; import name.abuchen.portfolio.model.ledger.compatibility.LedgerInlineEditingField; import name.abuchen.portfolio.model.ledger.compatibility.LedgerInlineEditingPolicy; import name.abuchen.portfolio.money.Money; @@ -110,7 +109,7 @@ private final class LedgerAwareDividendSharesEditingSupport extends ValueEditing @Override public boolean canEdit(Object element) { - return !LedgerTransactionUiSupport.isNativeTargetedProjection((AccountTransaction) element) + return !LedgerInlineEditingPolicy.isNativeTargetedProjection(element) && LedgerInlineEditingPolicy.isEditable(element, LedgerInlineEditingField.SHARES) && ((AccountTransaction) element).getType() == AccountTransaction.Type.DIVIDENDS; } @@ -129,13 +128,8 @@ public void setValue(Object element, Object value) throws Exception if (newValue.equals(oldValue)) return; - var creator = new LedgerDividendTransactionCreator(client); - if (creator.canUpdate(transaction)) + if (LedgerInlineEditingPolicy.updateDividendShares(client, account, transaction, newValue.longValue())) { - creator.update(transaction, account, transaction.getType(), transaction.getDateTime(), - transaction.getAmount(), transaction.getCurrencyCode(), transaction.getSecurity(), - newValue.longValue(), transaction.getExDate(), null, null, - transaction.getUnits().toList(), transaction.getNote(), transaction.getSource()); notify(element, newValue, oldValue); return; } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerInlineEditingPolicy.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerInlineEditingPolicy.java index b0d1604114..210b3fd5fd 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerInlineEditingPolicy.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerInlineEditingPolicy.java @@ -1,11 +1,27 @@ package name.abuchen.portfolio.model.ledger.compatibility; +import java.time.LocalDateTime; import java.util.EnumMap; import java.util.EnumSet; import java.util.Map; import java.util.Set; +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.AccountTransferEntry; +import name.abuchen.portfolio.model.BuySellEntry; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.LedgerAccountTypeToggleConverter; +import name.abuchen.portfolio.model.LedgerBuySellDeliveryConverter; +import name.abuchen.portfolio.model.LedgerBuySellReversalConverter; +import name.abuchen.portfolio.model.LedgerDeliveryDirectionConverter; +import name.abuchen.portfolio.model.LedgerPortfolioCompositeTypeConverter; +import name.abuchen.portfolio.model.LedgerTransferDirectionConverter; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.PortfolioTransaction; +import name.abuchen.portfolio.model.PortfolioTransferEntry; import name.abuchen.portfolio.model.Transaction; +import name.abuchen.portfolio.model.TransactionOwner; import name.abuchen.portfolio.model.TransactionPair; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; @@ -46,6 +62,230 @@ public static boolean isEditable(LedgerEntryType type, LedgerProjectionRole role return byType.getOrDefault(type, Set.of()).contains(field); } + public static boolean isNativeTargetedProjection(Object element) + { + var tx = transaction(element); + return tx != null && LedgerNativeComponentInspectorModel.isLedgerNativeTargetedProjection(tx); + } + + public static boolean canEditOwner(Client client, Transaction transaction) + { + if (transaction == null || transaction.getCrossEntry() == null) + return false; + + if (!isLedgerBacked(client, transaction)) + return true; + + return canUpdateOwner(client, transaction); + } + + public static boolean canUpdateOwner(Client client, Transaction transaction) + { + var crossEntry = transaction.getCrossEntry(); + + if (crossEntry instanceof AccountTransferEntry entry) + return new LedgerAccountTransferTransactionCreator(client).canUpdate(entry); + + if (crossEntry instanceof PortfolioTransferEntry entry) + return new LedgerPortfolioTransferTransactionCreator(client).canUpdate(entry); + + if (crossEntry instanceof BuySellEntry entry) + return new LedgerBuySellTransactionCreator(client).canUpdate(entry); + + return false; + } + + public static boolean updateOwner(Client client, Transaction transaction, TransactionOwner oldValue, + TransactionOwner newValue) + { + var crossEntry = transaction.getCrossEntry(); + + if (crossEntry instanceof BuySellEntry entry) + { + var helper = new LedgerOwnerPatchHelper(client); + if (newValue instanceof Account newAccount) + helper.moveBuySellAccountSide(entry, newAccount); + else if (newValue instanceof Portfolio newPortfolio) + helper.moveBuySellPortfolioSide(entry, newPortfolio); + else + return false; + return true; + } + + if (crossEntry instanceof AccountTransferEntry entry && newValue instanceof Account account) + { + var helper = new LedgerOwnerPatchHelper(client); + + if (oldValue.equals(entry.getSourceAccount())) + helper.moveAccountTransferSource(entry, account); + else if (oldValue.equals(entry.getTargetAccount())) + helper.moveAccountTransferTarget(entry, account); + else + return false; + return true; + } + + if (crossEntry instanceof PortfolioTransferEntry entry && newValue instanceof Portfolio portfolio) + { + var helper = new LedgerOwnerPatchHelper(client); + + if (oldValue.equals(entry.getSourcePortfolio())) + helper.movePortfolioTransferSource(entry, portfolio); + else if (oldValue.equals(entry.getTargetPortfolio())) + helper.movePortfolioTransferTarget(entry, portfolio); + else + return false; + return true; + } + + return false; + } + + public static boolean supportsTypeTransition(Client client, TransactionPair pair, Enum fromValue, + Enum toValue) + { + var transaction = pair.getTransaction(); + + if (!isLedgerBacked(client, transaction)) + return true; + + if (isLedgerBackedAccountTransfer(client, transaction)) + return (fromValue == AccountTransaction.Type.TRANSFER_IN && toValue == AccountTransaction.Type.TRANSFER_OUT + || fromValue == AccountTransaction.Type.TRANSFER_OUT + && toValue == AccountTransaction.Type.TRANSFER_IN) + && new LedgerTransferDirectionConverter(client) + .canReverseSafely((AccountTransferEntry) transaction.getCrossEntry()); + + if (isLedgerBackedPortfolioTransfer(client, transaction)) + return (fromValue == PortfolioTransaction.Type.TRANSFER_IN + && toValue == PortfolioTransaction.Type.TRANSFER_OUT + || fromValue == PortfolioTransaction.Type.TRANSFER_OUT + && toValue == PortfolioTransaction.Type.TRANSFER_IN) + && new LedgerTransferDirectionConverter(client) + .canReverseSafely((PortfolioTransferEntry) transaction.getCrossEntry()); + + if (isLedgerBackedBuySell(client, transaction)) + return supportsBuySellTypeTransition(client, pair, fromValue, toValue); + + if (isLedgerBackedDelivery(client, transaction)) + return supportsDeliveryTypeTransition(client, pair, fromValue, toValue); + + if (isLedgerBackedAccountOnly(client, transaction)) + return supportsAccountOnlyTypeTransition(client, pair, fromValue, toValue); + + return false; + } + + public static boolean canUpdateDividendExDate(Client client, AccountTransaction transaction) + { + return client != null && new LedgerDividendTransactionCreator(client).canUpdate(transaction); + } + + public static boolean updateDividendExDate(Client client, Account owner, AccountTransaction transaction, + LocalDateTime exDate) + { + if (!canUpdateDividendExDate(client, transaction)) + return false; + + new LedgerDividendTransactionCreator(client).update(transaction, owner, transaction.getType(), + transaction.getDateTime(), transaction.getAmount(), transaction.getCurrencyCode(), + transaction.getSecurity(), transaction.getShares(), exDate, null, + LedgerUnitPostingPatch.none(), transaction.getNote(), transaction.getSource()); + return true; + } + + public static boolean updateDividendShares(Client client, Account owner, AccountTransaction transaction, + long shares) + { + var creator = new LedgerDividendTransactionCreator(client); + if (!creator.canUpdate(transaction)) + return false; + + creator.update(transaction, owner, transaction.getType(), transaction.getDateTime(), transaction.getAmount(), + transaction.getCurrencyCode(), transaction.getSecurity(), shares, transaction.getExDate(), + null, null, transaction.getUnits().toList(), transaction.getNote(), transaction.getSource()); + return true; + } + + public static boolean updateShares(Client client, TransactionPair pair, long shares) + { + if (!isEditable(pair, LedgerInlineEditingField.SHARES)) + return false; + + var transaction = pair.getTransaction(); + + if (transaction.getCrossEntry() instanceof BuySellEntry buySellEntry) + { + var creator = new LedgerBuySellTransactionCreator(client); + if (!creator.canUpdate(buySellEntry)) + return false; + + var portfolioTransaction = buySellEntry.getPortfolioTransaction(); + creator.update(buySellEntry, buySellEntry.getPortfolio(), buySellEntry.getAccount(), + portfolioTransaction.getType(), portfolioTransaction.getDateTime(), + portfolioTransaction.getAmount(), portfolioTransaction.getCurrencyCode(), + portfolioTransaction.getSecurity(), shares, portfolioTransaction.getUnits().toList(), + portfolioTransaction.getNote(), portfolioTransaction.getSource()); + return true; + } + + if (transaction.getCrossEntry() instanceof PortfolioTransferEntry transferEntry) + { + var creator = new LedgerPortfolioTransferTransactionCreator(client); + if (!creator.canUpdate(transferEntry)) + return false; + + var sourceTransaction = transferEntry.getSourceTransaction(); + creator.update(transferEntry, transferEntry.getSourcePortfolio(), transferEntry.getTargetPortfolio(), + sourceTransaction.getSecurity(), sourceTransaction.getDateTime(), shares, + sourceTransaction.getAmount(), sourceTransaction.getCurrencyCode(), + sourceTransaction.getNote(), sourceTransaction.getSource()); + return true; + } + + if (transaction instanceof PortfolioTransaction portfolioTransaction) + { + var creator = new LedgerDeliveryTransactionCreator(client); + if (!creator.canUpdate(portfolioTransaction)) + return false; + + creator.update(portfolioTransaction, (Portfolio) pair.getOwner(), portfolioTransaction.getType(), + portfolioTransaction.getDateTime(), portfolioTransaction.getAmount(), + portfolioTransaction.getCurrencyCode(), portfolioTransaction.getSecurity(), shares, null, + null, portfolioTransaction.getUnits().toList(), portfolioTransaction.getNote(), + portfolioTransaction.getSource()); + return true; + } + + if (transaction instanceof AccountTransaction accountTransaction) + return updateDividendShares(client, (Account) pair.getOwner(), accountTransaction, shares); + + return false; + } + + public static boolean isLedgerBackedAccountTransaction(Client client, AccountTransaction transaction) + { + if (client == null) + return false; + + if (new LedgerAccountOnlyTransactionCreator(client).canUpdate(transaction)) + return true; + + if (transaction.getCrossEntry() instanceof BuySellEntry entry + && new LedgerBuySellTransactionCreator(client).isLedgerBacked(entry)) + return true; + + return transaction.getCrossEntry() instanceof AccountTransferEntry entry + && new LedgerAccountTransferTransactionCreator(client).isLedgerBacked(entry); + } + + public static boolean isLedgerBacked(Client client, Transaction transaction) + { + return isLedgerBackedAccountTransfer(client, transaction) || isLedgerBackedPortfolioTransfer(client, transaction) + || isLedgerBackedBuySell(client, transaction) || isLedgerBackedDelivery(client, transaction) + || isLedgerBackedAccountOnly(client, transaction) || isLedgerBackedDividend(client, transaction); + } + private static Transaction transaction(Object element) { if (element instanceof TransactionPair pair) @@ -57,6 +297,145 @@ private static Transaction transaction(Object element) return null; } + @SuppressWarnings("unchecked") + private static TransactionPair accountPair(TransactionPair pair) + { + return (TransactionPair) pair; + } + + @SuppressWarnings("unchecked") + private static TransactionPair portfolioPair(TransactionPair pair) + { + return (TransactionPair) pair; + } + + private static boolean supportsBuySellTypeTransition(Client client, TransactionPair pair, Enum fromValue, + Enum toValue) + { + var transaction = pair.getTransaction(); + + if (fromValue == AccountTransaction.Type.BUY) + return toValue == AccountTransaction.Type.SELL && new LedgerBuySellReversalConverter(client) + .canReverseSafely((BuySellEntry) transaction.getCrossEntry()); + if (fromValue == AccountTransaction.Type.SELL) + return toValue == AccountTransaction.Type.BUY && new LedgerBuySellReversalConverter(client) + .canReverseSafely((BuySellEntry) transaction.getCrossEntry()); + if (fromValue == PortfolioTransaction.Type.BUY) + return toValue == PortfolioTransaction.Type.SELL && new LedgerBuySellReversalConverter(client) + .canReverseSafely((BuySellEntry) transaction.getCrossEntry()) + || toValue == PortfolioTransaction.Type.DELIVERY_INBOUND + && new LedgerBuySellDeliveryConverter(client) + .canConvertSafely(portfolioPair(pair)) + || toValue == PortfolioTransaction.Type.DELIVERY_OUTBOUND + && new LedgerPortfolioCompositeTypeConverter(client) + .canConvertSafely(portfolioPair(pair)); + if (fromValue == PortfolioTransaction.Type.SELL) + return toValue == PortfolioTransaction.Type.BUY && new LedgerBuySellReversalConverter(client) + .canReverseSafely((BuySellEntry) transaction.getCrossEntry()) + || toValue == PortfolioTransaction.Type.DELIVERY_OUTBOUND + && new LedgerBuySellDeliveryConverter(client) + .canConvertSafely(portfolioPair(pair)) + || toValue == PortfolioTransaction.Type.DELIVERY_INBOUND + && new LedgerPortfolioCompositeTypeConverter(client) + .canConvertSafely(portfolioPair(pair)); + + return false; + } + + private static boolean supportsDeliveryTypeTransition(Client client, TransactionPair pair, Enum fromValue, + Enum toValue) + { + if (fromValue == PortfolioTransaction.Type.DELIVERY_INBOUND) + return toValue == PortfolioTransaction.Type.DELIVERY_OUTBOUND + && new LedgerDeliveryDirectionConverter(client).canReverseSafely(portfolioPair(pair)) + || toValue == PortfolioTransaction.Type.BUY + && new LedgerBuySellDeliveryConverter(client) + .canConvertDeliveryToBuySellSafely(portfolioPair(pair)) + || toValue == PortfolioTransaction.Type.SELL + && new LedgerPortfolioCompositeTypeConverter(client) + .canConvertSafely(portfolioPair(pair)); + if (fromValue == PortfolioTransaction.Type.DELIVERY_OUTBOUND) + return toValue == PortfolioTransaction.Type.DELIVERY_INBOUND + && new LedgerDeliveryDirectionConverter(client).canReverseSafely(portfolioPair(pair)) + || toValue == PortfolioTransaction.Type.SELL + && new LedgerBuySellDeliveryConverter(client) + .canConvertDeliveryToBuySellSafely(portfolioPair(pair)) + || toValue == PortfolioTransaction.Type.BUY + && new LedgerPortfolioCompositeTypeConverter(client) + .canConvertSafely(portfolioPair(pair)); + + return false; + } + + private static boolean supportsAccountOnlyTypeTransition(Client client, TransactionPair pair, Enum fromValue, + Enum toValue) + { + if (fromValue == AccountTransaction.Type.DEPOSIT) + return toValue == AccountTransaction.Type.REMOVAL + && new LedgerAccountTypeToggleConverter(client).canToggleSafely(accountPair(pair)); + if (fromValue == AccountTransaction.Type.REMOVAL) + return toValue == AccountTransaction.Type.DEPOSIT + && new LedgerAccountTypeToggleConverter(client).canToggleSafely(accountPair(pair)); + if (fromValue == AccountTransaction.Type.INTEREST) + return toValue == AccountTransaction.Type.INTEREST_CHARGE + && new LedgerAccountTypeToggleConverter(client).canToggleSafely(accountPair(pair)); + if (fromValue == AccountTransaction.Type.INTEREST_CHARGE) + return toValue == AccountTransaction.Type.INTEREST + && new LedgerAccountTypeToggleConverter(client).canToggleSafely(accountPair(pair)); + if (fromValue == AccountTransaction.Type.FEES) + return toValue == AccountTransaction.Type.FEES_REFUND + && new LedgerAccountTypeToggleConverter(client).canToggleSafely(accountPair(pair)); + if (fromValue == AccountTransaction.Type.FEES_REFUND) + return toValue == AccountTransaction.Type.FEES + && new LedgerAccountTypeToggleConverter(client).canToggleSafely(accountPair(pair)); + if (fromValue == AccountTransaction.Type.TAXES) + return toValue == AccountTransaction.Type.TAX_REFUND + && new LedgerAccountTypeToggleConverter(client).canToggleSafely(accountPair(pair)); + if (fromValue == AccountTransaction.Type.TAX_REFUND) + return toValue == AccountTransaction.Type.TAXES + && new LedgerAccountTypeToggleConverter(client).canToggleSafely(accountPair(pair)); + + return false; + } + + private static boolean isLedgerBackedAccountTransfer(Client client, Transaction transaction) + { + return transaction instanceof AccountTransaction + && transaction.getCrossEntry() instanceof AccountTransferEntry entry + && new LedgerAccountTransferTransactionCreator(client).isLedgerBacked(entry); + } + + private static boolean isLedgerBackedPortfolioTransfer(Client client, Transaction transaction) + { + return transaction instanceof PortfolioTransaction + && transaction.getCrossEntry() instanceof PortfolioTransferEntry entry + && new LedgerPortfolioTransferTransactionCreator(client).isLedgerBacked(entry); + } + + private static boolean isLedgerBackedBuySell(Client client, Transaction transaction) + { + return transaction.getCrossEntry() instanceof BuySellEntry entry + && new LedgerBuySellTransactionCreator(client).isLedgerBacked(entry); + } + + private static boolean isLedgerBackedDelivery(Client client, Transaction transaction) + { + return transaction instanceof PortfolioTransaction portfolioTransaction + && new LedgerDeliveryTransactionCreator(client).canUpdate(portfolioTransaction); + } + + private static boolean isLedgerBackedAccountOnly(Client client, Transaction transaction) + { + return transaction instanceof AccountTransaction accountTransaction + && new LedgerAccountOnlyTransactionCreator(client).canUpdate(accountTransaction); + } + + private static boolean isLedgerBackedDividend(Client client, Transaction transaction) + { + return transaction instanceof AccountTransaction accountTransaction + && new LedgerDividendTransactionCreator(client).canUpdate(accountTransaction); + } + private static Map>> matrix() { var matrix = new EnumMap>>( From 99e9365a01a02513231856de622db20c1469a893 Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:31:37 +0200 Subject: [PATCH 34/68] Wrap Ledger conversion action support Move Ledger-specific conversion, revert, transfer split, and unsupported Ledger-backed conversion decisions into LedgerConversionSupport while keeping existing model-layer converter boundaries. This keeps action classes focused on legacy control flow and delegates Ledger routing at the conversion decision point without changing supported or refused behavior. Dialog commit paths, context menu routing, inline editing, import actions, persistence, schemas, migration diagnostics, InvestmentPlan linkage, Ledger model semantics, and Corporate Action behavior are intentionally unchanged. --- .../ConvertBuySellToDeliveryAction.java | 8 +- .../ConvertDeliveryToBuySellAction.java | 8 +- .../ConvertPortfolioCompositeTypeAction.java | 14 +- ...ConvertTransferToDepositRemovalAction.java | 36 +--- .../actions/LedgerConversionSupport.java | 162 ++++++++++++++++++ .../ui/views/actions/RevertBuySellAction.java | 9 +- .../views/actions/RevertDeliveryAction.java | 9 +- .../actions/RevertDepositRemovalAction.java | 8 +- .../ui/views/actions/RevertFeeTaxAction.java | 8 +- .../views/actions/RevertInterestAction.java | 8 +- .../views/actions/RevertTransferAction.java | 15 +- 11 files changed, 175 insertions(+), 110 deletions(-) create mode 100644 name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/LedgerConversionSupport.java diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/ConvertBuySellToDeliveryAction.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/ConvertBuySellToDeliveryAction.java index 4fde55d49c..458c224ee8 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/ConvertBuySellToDeliveryAction.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/ConvertBuySellToDeliveryAction.java @@ -6,7 +6,6 @@ import org.eclipse.jface.action.Action; import name.abuchen.portfolio.model.Client; -import name.abuchen.portfolio.model.LedgerBuySellDeliveryConverter; import name.abuchen.portfolio.model.PortfolioTransaction; import name.abuchen.portfolio.model.PortfolioTransaction.Type; import name.abuchen.portfolio.model.TransactionPair; @@ -54,15 +53,10 @@ else if (allSell) @Override public void run() { - var converter = new LedgerBuySellDeliveryConverter(client); - for (TransactionPair transaction : transactionList) { - if (converter.canConvert(transaction)) - { - converter.convertBuySellToDelivery(transaction); + if (LedgerConversionSupport.convertBuySellToDelivery(client, transaction)) continue; - } // delete existing transaction PortfolioTransaction buySellTransaction = transaction.getTransaction(); diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/ConvertDeliveryToBuySellAction.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/ConvertDeliveryToBuySellAction.java index 17f71a17da..697e6d46b2 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/ConvertDeliveryToBuySellAction.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/ConvertDeliveryToBuySellAction.java @@ -9,7 +9,6 @@ import name.abuchen.portfolio.model.Account; import name.abuchen.portfolio.model.BuySellEntry; import name.abuchen.portfolio.model.Client; -import name.abuchen.portfolio.model.LedgerBuySellDeliveryConverter; import name.abuchen.portfolio.model.Portfolio; import name.abuchen.portfolio.model.PortfolioTransaction; import name.abuchen.portfolio.model.PortfolioTransaction.Type; @@ -57,15 +56,10 @@ else if (allOutbound) @Override public void run() { - var converter = new LedgerBuySellDeliveryConverter(client); - for (TransactionPair transaction : transactionList) { - if (converter.canConvertDeliveryToBuySell(transaction)) - { - converter.convertDeliveryToBuySell(transaction); + if (LedgerConversionSupport.convertDeliveryToBuySell(client, transaction)) continue; - } Portfolio portfolio = (Portfolio) transaction.getOwner(); Account account = portfolio.getReferenceAccount(); diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/ConvertPortfolioCompositeTypeAction.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/ConvertPortfolioCompositeTypeAction.java index a8fef25e57..8bcc1f286f 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/ConvertPortfolioCompositeTypeAction.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/ConvertPortfolioCompositeTypeAction.java @@ -3,10 +3,8 @@ import org.eclipse.jface.action.Action; import name.abuchen.portfolio.model.Client; -import name.abuchen.portfolio.model.LedgerPortfolioCompositeTypeConverter; import name.abuchen.portfolio.model.PortfolioTransaction; import name.abuchen.portfolio.model.TransactionPair; -import name.abuchen.portfolio.ui.Messages; public class ConvertPortfolioCompositeTypeAction extends Action { @@ -29,18 +27,8 @@ public ConvertPortfolioCompositeTypeAction(Client client, TransactionPair()); - - for (AccountTransaction transaction : transactionList) - { - if (!(transaction.getCrossEntry() instanceof AccountTransferEntry entry)) - throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_UI_018 - .message(MessageFormat.format( - Messages.LedgerConvertTransferToDepositRemovalActionUnsupportedTransferEntry, - transaction))); - - if (ledgerTransferCreator.isLedgerBacked(entry)) - { - if (!ledgerSplitConverter.canSplit(entry)) - throw new UnsupportedOperationException( - LedgerDiagnosticCode.LEDGER_UI_019 - .message(Messages.LedgerConvertTransferToDepositRemovalActionCannotConvertLedgerBackedTransfer)); - - ledgerEntries.add(entry); - } - } - - var convertedLedgerEntries = new HashSet<>(ledgerEntries); - - ledgerEntries.forEach(ledgerSplitConverter::split); + var convertedLedgerEntries = LedgerConversionSupport.convertLedgerTransfersToDepositRemoval(client, + transactionList); for (AccountTransaction transaction : transactionList) { AccountTransferEntry entry = (AccountTransferEntry) transaction.getCrossEntry(); - if (ledgerTransferCreator.isLedgerBacked(entry) || convertedLedgerEntries.contains(entry)) + if (convertedLedgerEntries.contains(entry)) continue; Account accountFrom = entry.getSourceAccount(); diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/LedgerConversionSupport.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/LedgerConversionSupport.java new file mode 100644 index 0000000000..517f993d03 --- /dev/null +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/LedgerConversionSupport.java @@ -0,0 +1,162 @@ +package name.abuchen.portfolio.ui.views.actions; + +import java.text.MessageFormat; +import java.util.Collection; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Set; + +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.AccountTransferEntry; +import name.abuchen.portfolio.model.BuySellEntry; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.CrossEntry; +import name.abuchen.portfolio.model.LedgerAccountTransferToDepositRemovalConverter; +import name.abuchen.portfolio.model.LedgerAccountTypeToggleConverter; +import name.abuchen.portfolio.model.LedgerBuySellDeliveryConverter; +import name.abuchen.portfolio.model.LedgerBuySellReversalConverter; +import name.abuchen.portfolio.model.LedgerDeliveryDirectionConverter; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.LedgerPortfolioCompositeTypeConverter; +import name.abuchen.portfolio.model.LedgerTransferDirectionConverter; +import name.abuchen.portfolio.model.PortfolioTransaction; +import name.abuchen.portfolio.model.PortfolioTransferEntry; +import name.abuchen.portfolio.model.TransactionPair; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountTransferTransactionCreator; +import name.abuchen.portfolio.ui.Messages; + +final class LedgerConversionSupport +{ + private LedgerConversionSupport() + { + } + + static boolean convertBuySellToDelivery(Client client, TransactionPair transaction) + { + var converter = new LedgerBuySellDeliveryConverter(client); + if (!converter.canConvert(transaction)) + return false; + + converter.convertBuySellToDelivery(transaction); + return true; + } + + static boolean convertDeliveryToBuySell(Client client, TransactionPair transaction) + { + var converter = new LedgerBuySellDeliveryConverter(client); + if (!converter.canConvertDeliveryToBuySell(transaction)) + return false; + + converter.convertDeliveryToBuySell(transaction); + return true; + } + + static boolean convertPortfolioCompositeType(Client client, TransactionPair transaction) + { + var converter = new LedgerPortfolioCompositeTypeConverter(client); + + if (converter.canConvertSafely(transaction)) + { + converter.convert(transaction); + client.markDirty(); + return true; + } + + if (converter.isLedgerBacked(transaction)) + throw new UnsupportedOperationException( + Messages.LedgerConvertPortfolioCompositeTypeActionUnsupportedLedgerBackedTransition); + + return false; + } + + static Set convertLedgerTransfersToDepositRemoval(Client client, + Collection transactionList) + { + var ledgerTransferCreator = new LedgerAccountTransferTransactionCreator(client); + var ledgerSplitConverter = new LedgerAccountTransferToDepositRemovalConverter(client); + var ledgerEntries = Collections.newSetFromMap(new IdentityHashMap()); + + for (AccountTransaction transaction : transactionList) + { + if (!(transaction.getCrossEntry() instanceof AccountTransferEntry entry)) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_UI_018 + .message(MessageFormat.format( + Messages.LedgerConvertTransferToDepositRemovalActionUnsupportedTransferEntry, + transaction))); + + if (ledgerTransferCreator.isLedgerBacked(entry)) + { + if (!ledgerSplitConverter.canSplit(entry)) + throw new UnsupportedOperationException( + LedgerDiagnosticCode.LEDGER_UI_019 + .message(Messages.LedgerConvertTransferToDepositRemovalActionCannotConvertLedgerBackedTransfer)); + + ledgerEntries.add(entry); + } + } + + ledgerEntries.forEach(ledgerSplitConverter::split); + + return Set.copyOf(ledgerEntries); + } + + static boolean reverseBuySell(Client client, BuySellEntry entry) + { + var converter = new LedgerBuySellReversalConverter(client); + if (!converter.canReverse(entry)) + return false; + + converter.reverse(entry); + client.markDirty(); + return true; + } + + static boolean reverseDelivery(Client client, TransactionPair transaction) + { + var converter = new LedgerDeliveryDirectionConverter(client); + if (!converter.canReverse(transaction)) + return false; + + converter.reverse(transaction); + client.markDirty(); + return true; + } + + static boolean reverseTransfer(Client client, CrossEntry entry) + { + var converter = new LedgerTransferDirectionConverter(client); + + if (entry instanceof PortfolioTransferEntry portfolioTransfer) + { + if (!converter.canReverse(portfolioTransfer)) + return false; + + converter.reverse(portfolioTransfer); + client.markDirty(); + return true; + } + + if (entry instanceof AccountTransferEntry accountTransfer) + { + if (!converter.canReverse(accountTransfer)) + return false; + + converter.reverse(accountTransfer); + client.markDirty(); + return true; + } + + return false; + } + + static boolean toggleAccountType(Client client, TransactionPair transaction) + { + var converter = new LedgerAccountTypeToggleConverter(client); + if (!converter.canToggle(transaction)) + return false; + + converter.toggle(transaction); + client.markDirty(); + return true; + } +} diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/RevertBuySellAction.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/RevertBuySellAction.java index ad566bf77b..21b84759a4 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/RevertBuySellAction.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/RevertBuySellAction.java @@ -1,12 +1,10 @@ package name.abuchen.portfolio.ui.views.actions; - import org.eclipse.jface.action.Action; import name.abuchen.portfolio.model.AccountTransaction; import name.abuchen.portfolio.model.BuySellEntry; import name.abuchen.portfolio.model.Client; -import name.abuchen.portfolio.model.LedgerBuySellReversalConverter; import name.abuchen.portfolio.model.PortfolioTransaction; import name.abuchen.portfolio.model.PortfolioTransaction.Type; import name.abuchen.portfolio.model.Transaction; @@ -51,14 +49,9 @@ else if (tx instanceof AccountTransaction) public void run() { BuySellEntry buysell = (BuySellEntry) transaction.getTransaction().getCrossEntry(); - var converter = new LedgerBuySellReversalConverter(client); - if (converter.canReverse(buysell)) - { - converter.reverse(buysell); - client.markDirty(); + if (LedgerConversionSupport.reverseBuySell(client, buysell)) return; - } PortfolioTransaction tx = buysell.getPortfolioTransaction(); diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/RevertDeliveryAction.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/RevertDeliveryAction.java index 8812674fda..3be681694d 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/RevertDeliveryAction.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/RevertDeliveryAction.java @@ -1,10 +1,8 @@ package name.abuchen.portfolio.ui.views.actions; - import org.eclipse.jface.action.Action; import name.abuchen.portfolio.model.Client; -import name.abuchen.portfolio.model.LedgerDeliveryDirectionConverter; import name.abuchen.portfolio.model.PortfolioTransaction; import name.abuchen.portfolio.model.PortfolioTransaction.Type; import name.abuchen.portfolio.model.Transaction.Unit; @@ -35,14 +33,9 @@ public RevertDeliveryAction(Client client, TransactionPair public void run() { PortfolioTransaction tx = transaction.getTransaction(); - var converter = new LedgerDeliveryDirectionConverter(client); - if (converter.canReverse(transaction)) - { - converter.reverse(transaction); - client.markDirty(); + if (LedgerConversionSupport.reverseDelivery(client, transaction)) return; - } // when converting between inbound and outbound deliveries, we keep the // price of the security the same, but add or subtract fees and taxes diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/RevertDepositRemovalAction.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/RevertDepositRemovalAction.java index 9ad5b0437c..23c75eef6f 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/RevertDepositRemovalAction.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/RevertDepositRemovalAction.java @@ -5,7 +5,6 @@ import name.abuchen.portfolio.model.AccountTransaction; import name.abuchen.portfolio.model.Client; -import name.abuchen.portfolio.model.LedgerAccountTypeToggleConverter; import name.abuchen.portfolio.model.TransactionPair; public class RevertDepositRemovalAction extends Action @@ -27,14 +26,9 @@ public RevertDepositRemovalAction(Client client, TransactionPair tra public void run() { AccountTransaction tx = transaction.getTransaction(); - var converter = new LedgerAccountTypeToggleConverter(client); - if (converter.canToggle(transaction)) - { - converter.toggle(transaction); - client.markDirty(); + if (LedgerConversionSupport.toggleAccountType(client, transaction)) return; - } Type type = tx.getType(); if (type == Type.FEES) diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/RevertInterestAction.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/RevertInterestAction.java index a7e2add851..033dcd51ef 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/RevertInterestAction.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/RevertInterestAction.java @@ -6,7 +6,6 @@ import name.abuchen.portfolio.model.AccountTransaction; import name.abuchen.portfolio.model.AccountTransaction.Type; import name.abuchen.portfolio.model.Client; -import name.abuchen.portfolio.model.LedgerAccountTypeToggleConverter; import name.abuchen.portfolio.model.TransactionPair; public class RevertInterestAction extends Action @@ -30,14 +29,9 @@ public RevertInterestAction(Client client, TransactionPair t public void run() { AccountTransaction tx = transaction.getTransaction(); - var converter = new LedgerAccountTypeToggleConverter(client); - if (converter.canToggle(transaction)) - { - converter.toggle(transaction); - client.markDirty(); + if (LedgerConversionSupport.toggleAccountType(client, transaction)) return; - } Type type = tx.getType(); if (AccountTransaction.Type.INTEREST.equals(type)) diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/RevertTransferAction.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/RevertTransferAction.java index 03af44b408..479d30662c 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/RevertTransferAction.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/RevertTransferAction.java @@ -1,6 +1,5 @@ package name.abuchen.portfolio.ui.views.actions; - import org.eclipse.jface.action.Action; import name.abuchen.portfolio.model.Account; @@ -8,7 +7,6 @@ import name.abuchen.portfolio.model.AccountTransferEntry; import name.abuchen.portfolio.model.Client; import name.abuchen.portfolio.model.CrossEntry; -import name.abuchen.portfolio.model.LedgerTransferDirectionConverter; import name.abuchen.portfolio.model.Portfolio; import name.abuchen.portfolio.model.PortfolioTransaction; import name.abuchen.portfolio.model.PortfolioTransferEntry; @@ -51,18 +49,13 @@ public void run() { Transaction tx = transaction.getTransaction(); CrossEntry e = tx.getCrossEntry(); - var converter = new LedgerTransferDirectionConverter(client); if (e instanceof PortfolioTransferEntry) { PortfolioTransferEntry entry = (PortfolioTransferEntry) transaction.getTransaction().getCrossEntry(); - if (converter.canReverse(entry)) - { - converter.reverse(entry); - client.markDirty(); + if (LedgerConversionSupport.reverseTransfer(client, entry)) return; - } Portfolio oldSource = entry.getSourcePortfolio(); entry.setSourcePortfolio((Portfolio) entry.getTargetPortfolio()); @@ -78,12 +71,8 @@ else if (e instanceof AccountTransferEntry) { AccountTransferEntry entry = (AccountTransferEntry) transaction.getTransaction().getCrossEntry(); - if (converter.canReverse(entry)) - { - converter.reverse(entry); - client.markDirty(); + if (LedgerConversionSupport.reverseTransfer(client, entry)) return; - } Account oldSource = entry.getSourceAccount(); entry.setSourceAccount(entry.getTargetAccount()); From a28949d10de828d2c25230364ef0c43d82c64f9c Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:59:33 +0200 Subject: [PATCH 35/68] Store Ledger entry type as protobuf code Native PLedgerEntry now stores LedgerEntryType by stable string typeCode, and LedgerEntryType exposes code/fromCode instead of numeric protobuf IDs. This keeps native Ledger type identity out of legacy protobuf transaction enum and numbering while preserving field number 9 for the unreleased Ledger protobuf extension. Legacy PTransaction.Type mapping, XML persistence, UI/import/check code, InvestmentPlan linkage, migration diagnostics, and Ledger runtime behavior are intentionally unchanged. --- .../model/LedgerProtobufPersistenceTest.java | 22 +- .../model/ledger/LedgerModelTest.java | 20 +- .../model/proto/v1/ClientProtos.java | 330 +++++++++--------- .../model/proto/v1/PLedgerEntry.java | 157 ++++++--- .../model/proto/v1/PLedgerEntryOrBuilder.java | 18 +- .../LedgerProtobufPersistenceSupport.java | 4 +- .../name/abuchen/portfolio/model/client.proto | 2 +- .../ledger/configuration/LedgerEntryType.java | 74 ++-- 8 files changed, 341 insertions(+), 286 deletions(-) diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceTest.java index 9789477f66..d1d7378c27 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceTest.java @@ -556,8 +556,8 @@ public void testLedgerConfigIdentifiersAreWritten() throws IOException .filter(candidate -> candidate.getTypeCode().equals(LedgerParameterType.EX_DATE.getCode())) .findFirst().orElseThrow(); - assertTrue(entry.hasTypeId()); - assertThat(entry.getTypeId(), is(LedgerEntryType.DIVIDENDS.getProtobufId())); + assertTrue(entry.hasTypeCode()); + assertThat(entry.getTypeCode(), is(LedgerEntryType.DIVIDENDS.getCode())); assertTrue(posting.hasTypeCode()); assertThat(posting.getTypeCode(), is(LedgerPostingType.CASH.getCode())); assertTrue(parameter.hasTypeCode()); @@ -590,18 +590,18 @@ public void testEntryLevelParametersAreWrittenUnderLedgerEntry() throws IOExcept } /** - * Verifies that an unknown ledger entry type id fails with a clear protobuf load error. + * Verifies that an unknown ledger entry type code fails with a clear protobuf load error. * Unsupported persisted ledger vocabulary must not be interpreted silently. */ @Test - public void testUnknownLedgerEntryTypeIdFailsClearly() throws IOException + public void testUnknownLedgerEntryTypeCodeFailsClearly() throws IOException { var fixture = fixtureWithDividendAndExDate(); var proto = saveProto(fixture.client()).toBuilder(); - proto.getLedgerBuilder().getEntriesBuilder(0).setTypeId(999999); + proto.getLedgerBuilder().getEntriesBuilder(0).setTypeCode("UNKNOWN_ENTRY_TYPE"); - assertUnknownTypeIdFailure(proto.build(), "LedgerEntryType", 999999); + assertUnknownTypeCodeFailure(proto.build(), "LedgerEntryType", "UNKNOWN_ENTRY_TYPE"); } /** @@ -715,7 +715,7 @@ public void testNativeCorporateActionsRoundtripAsSemanticLedgerTruth() throws IO assertNoLedgerUuidTruth(proto); assertThat(proto.getTransactionsCount(), is(0)); - assertThat(proto.getLedger().getEntries(0).getTypeId(), is(entryType.getProtobufId())); + assertThat(proto.getLedger().getEntries(0).getTypeCode(), is(entryType.getCode())); assertTrue(proto.getLedger().getEntries(0).getPostingsList().stream() .anyMatch(posting -> posting.hasCorporateActionLeg())); @@ -1267,14 +1267,6 @@ private void assertLogContains(IStatus status, String... fragments) assertTrue(status.getMessage(), status.getMessage().contains(fragment)); } - private void assertUnknownTypeIdFailure(PClient client, String typeName, int id) throws IOException - { - var exception = assertThrows(IllegalArgumentException.class, () -> load(wrap(client))); - - assertTrue(exception.getMessage(), exception.getMessage().contains(typeName)); - assertTrue(exception.getMessage(), exception.getMessage().contains(Integer.toString(id))); - } - private void assertUnknownTypeCodeFailure(PClient client, String typeName, String code) throws IOException { var exception = assertThrows(IllegalArgumentException.class, () -> load(wrap(client))); 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 index 0df4b4d1d9..ce18ef61fb 100644 --- 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 @@ -644,24 +644,24 @@ public void testEnumSkeletonContainsRequiredPostingAndProjectionShapes() } /** - * Checks the Ledger-V6 scenario: configurable ledger entry type protobuf ids are stable. + * 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 testConfigurableLedgerEntryTypeProtobufIdsAreStable() + public void testConfigurableLedgerEntryTypeCodesAreStable() { - assertStableProtobufIds(Arrays.stream(LedgerEntryType.values()).mapToInt(LedgerEntryType::getProtobufId) - .toArray()); + assertStableCodes(Arrays.stream(LedgerEntryType.values()).map(LedgerEntryType::getCode) + .toArray(String[]::new)); for (LedgerEntryType type : LedgerEntryType.values()) - assertThat(LedgerEntryType.fromProtobufId(type.getProtobufId()), is(type)); + assertThat(LedgerEntryType.fromCode(type.getCode()), is(type)); - var exception = assertThrows(IllegalArgumentException.class, () -> LedgerEntryType.fromProtobufId(0)); + 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("0")); + assertTrue(exception.getMessage(), exception.getMessage().contains("UNKNOWN_CODE")); } /** @@ -731,12 +731,6 @@ private void assertValueKindPolicy(ValueKind valueKind, Class valueType) assertFalse(valueKind.supportsValue(new Object())); } - private void assertStableProtobufIds(int[] protobufIds) - { - assertThat(Arrays.stream(protobufIds).filter(id -> id == 0).count(), is(0L)); - assertThat(Arrays.stream(protobufIds).distinct().count(), is((long) protobufIds.length)); - } - private void assertStableCodes(String[] codes) { assertThat(Arrays.stream(codes).filter(String::isBlank).count(), is(0L)); diff --git a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/ClientProtos.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/ClientProtos.java index 95dcb0119c..84e3f6b15a 100644 --- a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/ClientProtos.java +++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/ClientProtos.java @@ -331,176 +331,176 @@ public static void registerAllExtensions( "rojectionRoleH\001\210\001\001B\021\n\017_projectionUUIDB\021\n" + "\017_projectionRole\"@\n\007PLedger\0225\n\007entries\030\001" + " \003(\0132$.name.abuchen.portfolio.PLedgerEnt" + - "ry\"\203\005\n\014PLedgerEntry\022\020\n\004uuid\030\001 \001(\tB\002\030\001\022,\n" + + "ry\"\207\005\n\014PLedgerEntry\022\020\n\004uuid\030\001 \001(\tB\002\030\001\022,\n" + "\010dateTime\030\003 \001(\0132\032.google.protobuf.Timest" + "amp\022\021\n\004note\030\004 \001(\tH\000\210\001\001\022\023\n\006source\030\005 \001(\tH\001" + "\210\001\001\022-\n\tupdatedAt\030\006 \001(\0132\032.google.protobuf" + ".Timestamp\0228\n\010postings\030\007 \003(\0132&.name.abuc" + "hen.portfolio.PLedgerPosting\022H\n\016projecti" + "onRefs\030\010 \003(\0132,.name.abuchen.portfolio.PL" + - "edgerProjectionRefB\002\030\001\022\023\n\006typeId\030\t \001(\rH\002" + - "\210\001\001\022<\n\nparameters\030\n \003(\0132(.name.abuchen.p" + - "ortfolio.PLedgerParameter\022\037\n\022generatedBy" + - "PlanKey\030\013 \001(\tH\003\210\001\001\022\036\n\021planExecutionDate\030" + - "\014 \001(\003H\004\210\001\001\022\"\n\025planExecutionSequence\030\r \001(" + - "\005H\005\210\001\001\022\036\n\021preferredViewKind\030\016 \001(\tH\006\210\001\001B\007" + - "\n\005_noteB\t\n\007_sourceB\t\n\007_typeIdB\025\n\023_genera" + - "tedByPlanKeyB\024\n\022_planExecutionDateB\030\n\026_p" + - "lanExecutionSequenceB\024\n\022_preferredViewKi" + - "ndJ\004\010\002\020\003\"\333\005\n\016PLedgerPosting\022\020\n\004uuid\030\001 \001(" + - "\tB\002\030\001\022\016\n\006amount\030\003 \001(\003\022\025\n\010currency\030\004 \001(\tH" + - "\000\210\001\001\022\030\n\013forexAmount\030\005 \001(\003H\001\210\001\001\022\032\n\rforexC" + - "urrency\030\006 \001(\tH\002\210\001\001\022@\n\014exchangeRate\030\007 \001(\013" + - "2%.name.abuchen.portfolio.PDecimalValueH" + - "\003\210\001\001\022\025\n\010security\030\010 \001(\tH\004\210\001\001\022\016\n\006shares\030\t " + - "\001(\003\022\024\n\007account\030\n \001(\tH\005\210\001\001\022\026\n\tportfolio\030\013" + - " \001(\tH\006\210\001\001\022<\n\nparameters\030\014 \003(\0132(.name.abu" + - "chen.portfolio.PLedgerParameter\022\025\n\010typeC" + - "ode\030\r \001(\tH\007\210\001\001\022\031\n\014semanticRole\030\016 \001(\tH\010\210\001" + - "\001\022\026\n\tdirection\030\017 \001(\tH\t\210\001\001\022\037\n\022corporateAc" + - "tionLeg\030\020 \001(\tH\n\210\001\001\022\025\n\010unitRole\030\021 \001(\tH\013\210\001" + - "\001\022\025\n\010groupKey\030\022 \001(\tH\014\210\001\001\022\025\n\010localKey\030\023 \001" + - "(\tH\r\210\001\001B\013\n\t_currencyB\016\n\014_forexAmountB\020\n\016" + - "_forexCurrencyB\017\n\r_exchangeRateB\013\n\t_secu" + - "rityB\n\n\010_accountB\014\n\n_portfolioB\013\n\t_typeC" + - "odeB\017\n\r_semanticRoleB\014\n\n_directionB\025\n\023_c" + - "orporateActionLegB\013\n\t_unitRoleB\013\n\t_group" + - "KeyB\013\n\t_localKeyJ\004\010\002\020\003\"\207\002\n\024PLedgerProjec" + - "tionRef\022\020\n\004uuid\030\001 \001(\tB\002\030\001\022?\n\004role\030\002 \001(\0162" + - "-.name.abuchen.portfolio.PLedgerProjecti" + - "onRoleB\002\030\001\022\030\n\007account\030\003 \001(\tB\002\030\001H\000\210\001\001\022\032\n\t" + - "portfolio\030\004 \001(\tB\002\030\001H\001\210\001\001\022L\n\013memberships\030" + - "\005 \003(\01323.name.abuchen.portfolio.PLedgerPr" + - "ojectionMembershipB\002\030\001B\n\n\010_accountB\014\n\n_p" + - "ortfolio\"\201\001\n\033PLedgerProjectionMembership" + - "\022\027\n\013postingUUID\030\001 \001(\tB\002\030\001\022I\n\004role\030\002 \001(\0162" + - "7.name.abuchen.portfolio.PLedgerProjecti" + - "onMembershipRoleB\002\030\001\"\266\005\n\020PLedgerParamete" + - "r\022D\n\tvalueKind\030\002 \001(\01621.name.abuchen.port" + - "folio.PLedgerParameterValueKind\022\030\n\013strin" + - "gValue\030\003 \001(\tH\000\210\001\001\022@\n\014decimalValue\030\004 \001(\0132" + - "%.name.abuchen.portfolio.PDecimalValueH\001" + - "\210\001\001\022\026\n\tlongValue\030\005 \001(\003H\002\210\001\001\022\030\n\013moneyAmou" + - "nt\030\006 \001(\003H\003\210\001\001\022\032\n\rmoneyCurrency\030\007 \001(\tH\004\210\001" + - "\001\022\025\n\010security\030\010 \001(\tH\005\210\001\001\022\024\n\007account\030\t \001(" + - "\tH\006\210\001\001\022\026\n\tportfolio\030\n \001(\tH\007\210\001\001\022G\n\022localD" + - "ateTimeValue\030\014 \001(\0132&.name.abuchen.portfo" + - "lio.PLocalDateTimeH\010\210\001\001\022\025\n\010typeCode\030\r \001(" + - "\tH\t\210\001\001\022\031\n\014booleanValue\030\016 \001(\010H\n\210\001\001\022\033\n\016loc" + - "alDateValue\030\017 \001(\003H\013\210\001\001B\016\n\014_stringValueB\017" + - "\n\r_decimalValueB\014\n\n_longValueB\016\n\014_moneyA" + - "mountB\020\n\016_moneyCurrencyB\013\n\t_securityB\n\n\010" + - "_accountB\014\n\n_portfolioB\025\n\023_localDateTime" + - "ValueB\013\n\t_typeCodeB\017\n\r_booleanValueB\021\n\017_" + - "localDateValueJ\004\010\001\020\002J\004\010\013\020\014R\tenumValue\"\252\004" + - "\n\tPTaxonomy\022\n\n\002id\030\001 \001(\t\022\014\n\004name\030\002 \001(\t\022\023\n" + - "\006source\030\003 \001(\tH\000\210\001\001\022\022\n\ndimensions\030\004 \003(\t\022I" + - "\n\017classifications\030\005 \003(\01320.name.abuchen.p" + - "ortfolio.PTaxonomy.Classification\032v\n\nAss" + - "ignment\022\031\n\021investmentVehicle\030\001 \001(\t\022\016\n\006we" + - "ight\030\002 \001(\005\022\014\n\004rank\030\003 \001(\005\022/\n\004data\030\004 \003(\0132!" + - ".name.abuchen.portfolio.PKeyValue\032\213\002\n\016Cl" + - "assification\022\n\n\002id\030\001 \001(\t\022\025\n\010parentId\030\002 \001" + - "(\tH\000\210\001\001\022\014\n\004name\030\003 \001(\t\022\021\n\004note\030\004 \001(\tH\001\210\001\001" + - "\022\r\n\005color\030\005 \001(\t\022\016\n\006weight\030\006 \001(\005\022\014\n\004rank\030" + - "\007 \001(\005\022/\n\004data\030\010 \003(\0132!.name.abuchen.portf" + - "olio.PKeyValue\022A\n\013assignments\030\t \003(\0132,.na" + - "me.abuchen.portfolio.PTaxonomy.Assignmen" + - "tB\013\n\t_parentIdB\007\n\005_noteB\t\n\007_source\"\357\003\n\nP" + - "Dashboard\022\014\n\004name\030\001 \001(\t\022L\n\rconfiguration" + - "\030\002 \003(\01325.name.abuchen.portfolio.PDashboa" + - "rd.ConfigurationEntry\022:\n\007columns\030\003 \003(\0132)" + - ".name.abuchen.portfolio.PDashboard.Colum" + - "n\022\n\n\002id\030\004 \001(\t\032\260\001\n\006Widget\022\014\n\004type\030\001 \001(\t\022\r" + - "\n\005label\030\002 \001(\t\022S\n\rconfiguration\030\003 \003(\0132<.n" + - "ame.abuchen.portfolio.PDashboard.Widget." + - "ConfigurationEntry\0324\n\022ConfigurationEntry" + - "\022\013\n\003key\030\001 \001(\t\022\r\n\005value\030\002 \001(\t:\0028\001\032T\n\006Colu" + - "mn\022\016\n\006weight\030\001 \001(\005\022:\n\007widgets\030\002 \003(\0132).na" + - "me.abuchen.portfolio.PDashboard.Widget\0324" + - "\n\022ConfigurationEntry\022\013\n\003key\030\001 \001(\t\022\r\n\005val" + - "ue\030\002 \001(\t:\0028\001\"+\n\tPBookmark\022\r\n\005label\030\001 \001(\t" + - "\022\017\n\007pattern\030\002 \001(\t\"\307\001\n\016PAttributeType\022\n\n\002" + - "id\030\001 \001(\t\022\014\n\004name\030\002 \001(\t\022\023\n\013columnLabel\030\003 " + - "\001(\t\022\023\n\006source\030\004 \001(\tH\000\210\001\001\022\016\n\006target\030\005 \001(\t" + - "\022\014\n\004type\030\006 \001(\t\022\026\n\016converterClass\030\007 \001(\t\0220" + - "\n\nproperties\030\010 \001(\0132\034.name.abuchen.portfo" + - "lio.PMapB\t\n\007_source\"J\n\021PConfigurationSet" + - "\022\013\n\003key\030\001 \001(\t\022\014\n\004uuid\030\002 \001(\t\022\014\n\004name\030\003 \001(" + - "\t\022\014\n\004data\030\004 \001(\t\"\307\001\n\tPSettings\0224\n\tbookmar" + - "ks\030\001 \003(\0132!.name.abuchen.portfolio.PBookm" + - "ark\022>\n\016attributeTypes\030\002 \003(\0132&.name.abuch" + - "en.portfolio.PAttributeType\022D\n\021configura" + - "tionSets\030\003 \003(\0132).name.abuchen.portfolio." + - "PConfigurationSet\"\366\005\n\007PClient\022\017\n\007version" + - "\030\001 \001(\005\0225\n\nsecurities\030\002 \003(\0132!.name.abuche" + - "n.portfolio.PSecurity\0222\n\010accounts\030\003 \003(\0132" + - " .name.abuchen.portfolio.PAccount\0226\n\npor" + - "tfolios\030\004 \003(\0132\".name.abuchen.portfolio.P" + - "Portfolio\022:\n\014transactions\030\005 \003(\0132$.name.a" + - "buchen.portfolio.PTransaction\0226\n\005plans\030\006" + - " \003(\0132\'.name.abuchen.portfolio.PInvestmen" + - "tPlan\0226\n\nwatchlists\030\007 \003(\0132\".name.abuchen" + - ".portfolio.PWatchlist\0225\n\ntaxonomies\030\010 \003(" + - "\0132!.name.abuchen.portfolio.PTaxonomy\0226\n\n" + - "dashboards\030\t \003(\0132\".name.abuchen.portfoli" + - "o.PDashboard\022C\n\nproperties\030\n \003(\0132/.name." + - "abuchen.portfolio.PClient.PropertiesEntr" + - "y\0223\n\010settings\030\013 \001(\0132!.name.abuchen.portf" + - "olio.PSettings\022\024\n\014baseCurrency\030\014 \001(\t\022/\n\006" + - "ledger\030\r \001(\0132\037.name.abuchen.portfolio.PL" + - "edger\022(\n\nextensions\030c \003(\0132\024.google.proto" + - "buf.Any\0321\n\017PropertiesEntry\022\013\n\003key\030\001 \001(\t\022" + - "\r\n\005value\030\002 \001(\t:\0028\001\"S\n\rPExchangeRate\022\014\n\004d" + - "ate\030\001 \001(\003\0224\n\005value\030\002 \001(\0132%.name.abuchen." + - "portfolio.PDecimalValue\"\203\001\n\027PExchangeRat" + - "eTimeSeries\022\024\n\014baseCurrency\030\001 \001(\t\022\024\n\014ter" + - "mCurrency\030\002 \001(\t\022<\n\rexchangeRates\030\003 \003(\0132%" + - ".name.abuchen.portfolio.PExchangeRate\"a\n" + - "\010PECBData\022\024\n\014lastModified\030\001 \001(\003\022?\n\006serie" + - "s\030\002 \003(\0132/.name.abuchen.portfolio.PExchan" + - "geRateTimeSeries*\301\004\n\025PLedgerProjectionRo" + - "le\022&\n\"LEDGER_PROJECTION_ROLE_UNSPECIFIED" + - "\020\000\022\"\n\036LEDGER_PROJECTION_ROLE_ACCOUNT\020\001\022$" + - "\n LEDGER_PROJECTION_ROLE_PORTFOLIO\020\002\022)\n%" + - "LEDGER_PROJECTION_ROLE_SOURCE_ACCOUNT\020\003\022" + - ")\n%LEDGER_PROJECTION_ROLE_TARGET_ACCOUNT" + - "\020\004\022+\n\'LEDGER_PROJECTION_ROLE_SOURCE_PORT" + - "FOLIO\020\005\022+\n\'LEDGER_PROJECTION_ROLE_TARGET" + - "_PORTFOLIO\020\006\022#\n\037LEDGER_PROJECTION_ROLE_D" + - "ELIVERY\020\007\022+\n\'LEDGER_PROJECTION_ROLE_DELI" + - "VERY_INBOUND\020\010\022,\n(LEDGER_PROJECTION_ROLE" + - "_DELIVERY_OUTBOUND\020\t\022,\n(LEDGER_PROJECTIO" + - "N_ROLE_CASH_COMPENSATION\020\n\022+\n\'LEDGER_PRO" + - "JECTION_ROLE_OLD_SECURITY_LEG\020\013\022+\n\'LEDGE" + - "R_PROJECTION_ROLE_NEW_SECURITY_LEG\020\014*\204\003\n" + - "\037PLedgerProjectionMembershipRole\0221\n-LEDG" + - "ER_PROJECTION_MEMBERSHIP_ROLE_UNSPECIFIE" + - "D\020\000\022-\n)LEDGER_PROJECTION_MEMBERSHIP_ROLE" + - "_PRIMARY\020\001\0222\n.LEDGER_PROJECTION_MEMBERSH" + - "IP_ROLE_GROUP_ANCHOR\020\002\022.\n*LEDGER_PROJECT" + - "ION_MEMBERSHIP_ROLE_FEE_UNIT\020\003\022.\n*LEDGER" + - "_PROJECTION_MEMBERSHIP_ROLE_TAX_UNIT\020\004\0226" + - "\n2LEDGER_PROJECTION_MEMBERSHIP_ROLE_GROS" + - "S_VALUE_UNIT\020\005\0223\n/LEDGER_PROJECTION_MEMB" + - "ERSHIP_ROLE_FOREX_CONTEXT\020\006*\327\004\n\031PLedgerP" + - "arameterValueKind\022+\n\'LEDGER_PARAMETER_VA" + - "LUE_KIND_UNSPECIFIED\020\000\022&\n\"LEDGER_PARAMET" + - "ER_VALUE_KIND_STRING\020\001\022\'\n#LEDGER_PARAMET" + - "ER_VALUE_KIND_DECIMAL\020\002\022$\n LEDGER_PARAME" + - "TER_VALUE_KIND_LONG\020\003\022%\n!LEDGER_PARAMETE" + - "R_VALUE_KIND_MONEY\020\004\022(\n$LEDGER_PARAMETER" + - "_VALUE_KIND_SECURITY\020\005\022\'\n#LEDGER_PARAMET" + - "ER_VALUE_KIND_ACCOUNT\020\006\022)\n%LEDGER_PARAME" + - "TER_VALUE_KIND_PORTFOLIO\020\007\022/\n+LEDGER_PAR" + - "AMETER_VALUE_KIND_LOCAL_DATE_TIME\020\n\022\'\n#L" + - "EDGER_PARAMETER_VALUE_KIND_BOOLEAN\020\013\022*\n&" + - "LEDGER_PARAMETER_VALUE_KIND_LOCAL_DATE\020\014" + - "\"\004\010\010\020\010\"\004\010\t\020\t*0LEDGER_PARAMETER_VALUE_KIN" + - "D_CORPORATE_ACTION_LEG*-LEDGER_PARAMETER" + - "_VALUE_KIND_COMPENSATION_KINDB7\n%name.ab" + - "uchen.portfolio.model.proto.v1B\014ClientPr" + - "otosP\001b\006proto3" + "edgerProjectionRefB\002\030\001\022\025\n\010typeCode\030\t \001(\t" + + "H\002\210\001\001\022<\n\nparameters\030\n \003(\0132(.name.abuchen" + + ".portfolio.PLedgerParameter\022\037\n\022generated" + + "ByPlanKey\030\013 \001(\tH\003\210\001\001\022\036\n\021planExecutionDat" + + "e\030\014 \001(\003H\004\210\001\001\022\"\n\025planExecutionSequence\030\r " + + "\001(\005H\005\210\001\001\022\036\n\021preferredViewKind\030\016 \001(\tH\006\210\001\001" + + "B\007\n\005_noteB\t\n\007_sourceB\013\n\t_typeCodeB\025\n\023_ge" + + "neratedByPlanKeyB\024\n\022_planExecutionDateB\030" + + "\n\026_planExecutionSequenceB\024\n\022_preferredVi" + + "ewKindJ\004\010\002\020\003\"\333\005\n\016PLedgerPosting\022\020\n\004uuid\030" + + "\001 \001(\tB\002\030\001\022\016\n\006amount\030\003 \001(\003\022\025\n\010currency\030\004 " + + "\001(\tH\000\210\001\001\022\030\n\013forexAmount\030\005 \001(\003H\001\210\001\001\022\032\n\rfo" + + "rexCurrency\030\006 \001(\tH\002\210\001\001\022@\n\014exchangeRate\030\007" + + " \001(\0132%.name.abuchen.portfolio.PDecimalVa" + + "lueH\003\210\001\001\022\025\n\010security\030\010 \001(\tH\004\210\001\001\022\016\n\006share" + + "s\030\t \001(\003\022\024\n\007account\030\n \001(\tH\005\210\001\001\022\026\n\tportfol" + + "io\030\013 \001(\tH\006\210\001\001\022<\n\nparameters\030\014 \003(\0132(.name" + + ".abuchen.portfolio.PLedgerParameter\022\025\n\010t" + + "ypeCode\030\r \001(\tH\007\210\001\001\022\031\n\014semanticRole\030\016 \001(\t" + + "H\010\210\001\001\022\026\n\tdirection\030\017 \001(\tH\t\210\001\001\022\037\n\022corpora" + + "teActionLeg\030\020 \001(\tH\n\210\001\001\022\025\n\010unitRole\030\021 \001(\t" + + "H\013\210\001\001\022\025\n\010groupKey\030\022 \001(\tH\014\210\001\001\022\025\n\010localKey" + + "\030\023 \001(\tH\r\210\001\001B\013\n\t_currencyB\016\n\014_forexAmount" + + "B\020\n\016_forexCurrencyB\017\n\r_exchangeRateB\013\n\t_" + + "securityB\n\n\010_accountB\014\n\n_portfolioB\013\n\t_t" + + "ypeCodeB\017\n\r_semanticRoleB\014\n\n_directionB\025" + + "\n\023_corporateActionLegB\013\n\t_unitRoleB\013\n\t_g" + + "roupKeyB\013\n\t_localKeyJ\004\010\002\020\003\"\207\002\n\024PLedgerPr" + + "ojectionRef\022\020\n\004uuid\030\001 \001(\tB\002\030\001\022?\n\004role\030\002 " + + "\001(\0162-.name.abuchen.portfolio.PLedgerProj" + + "ectionRoleB\002\030\001\022\030\n\007account\030\003 \001(\tB\002\030\001H\000\210\001\001" + + "\022\032\n\tportfolio\030\004 \001(\tB\002\030\001H\001\210\001\001\022L\n\013membersh" + + "ips\030\005 \003(\01323.name.abuchen.portfolio.PLedg" + + "erProjectionMembershipB\002\030\001B\n\n\010_accountB\014" + + "\n\n_portfolio\"\201\001\n\033PLedgerProjectionMember" + + "ship\022\027\n\013postingUUID\030\001 \001(\tB\002\030\001\022I\n\004role\030\002 " + + "\001(\01627.name.abuchen.portfolio.PLedgerProj" + + "ectionMembershipRoleB\002\030\001\"\266\005\n\020PLedgerPara" + + "meter\022D\n\tvalueKind\030\002 \001(\01621.name.abuchen." + + "portfolio.PLedgerParameterValueKind\022\030\n\013s" + + "tringValue\030\003 \001(\tH\000\210\001\001\022@\n\014decimalValue\030\004 " + + "\001(\0132%.name.abuchen.portfolio.PDecimalVal" + + "ueH\001\210\001\001\022\026\n\tlongValue\030\005 \001(\003H\002\210\001\001\022\030\n\013money" + + "Amount\030\006 \001(\003H\003\210\001\001\022\032\n\rmoneyCurrency\030\007 \001(\t" + + "H\004\210\001\001\022\025\n\010security\030\010 \001(\tH\005\210\001\001\022\024\n\007account\030" + + "\t \001(\tH\006\210\001\001\022\026\n\tportfolio\030\n \001(\tH\007\210\001\001\022G\n\022lo" + + "calDateTimeValue\030\014 \001(\0132&.name.abuchen.po" + + "rtfolio.PLocalDateTimeH\010\210\001\001\022\025\n\010typeCode\030" + + "\r \001(\tH\t\210\001\001\022\031\n\014booleanValue\030\016 \001(\010H\n\210\001\001\022\033\n" + + "\016localDateValue\030\017 \001(\003H\013\210\001\001B\016\n\014_stringVal" + + "ueB\017\n\r_decimalValueB\014\n\n_longValueB\016\n\014_mo" + + "neyAmountB\020\n\016_moneyCurrencyB\013\n\t_security" + + "B\n\n\010_accountB\014\n\n_portfolioB\025\n\023_localDate" + + "TimeValueB\013\n\t_typeCodeB\017\n\r_booleanValueB" + + "\021\n\017_localDateValueJ\004\010\001\020\002J\004\010\013\020\014R\tenumValu" + + "e\"\252\004\n\tPTaxonomy\022\n\n\002id\030\001 \001(\t\022\014\n\004name\030\002 \001(" + + "\t\022\023\n\006source\030\003 \001(\tH\000\210\001\001\022\022\n\ndimensions\030\004 \003" + + "(\t\022I\n\017classifications\030\005 \003(\01320.name.abuch" + + "en.portfolio.PTaxonomy.Classification\032v\n" + + "\nAssignment\022\031\n\021investmentVehicle\030\001 \001(\t\022\016" + + "\n\006weight\030\002 \001(\005\022\014\n\004rank\030\003 \001(\005\022/\n\004data\030\004 \003" + + "(\0132!.name.abuchen.portfolio.PKeyValue\032\213\002" + + "\n\016Classification\022\n\n\002id\030\001 \001(\t\022\025\n\010parentId" + + "\030\002 \001(\tH\000\210\001\001\022\014\n\004name\030\003 \001(\t\022\021\n\004note\030\004 \001(\tH" + + "\001\210\001\001\022\r\n\005color\030\005 \001(\t\022\016\n\006weight\030\006 \001(\005\022\014\n\004r" + + "ank\030\007 \001(\005\022/\n\004data\030\010 \003(\0132!.name.abuchen.p" + + "ortfolio.PKeyValue\022A\n\013assignments\030\t \003(\0132" + + ",.name.abuchen.portfolio.PTaxonomy.Assig" + + "nmentB\013\n\t_parentIdB\007\n\005_noteB\t\n\007_source\"\357" + + "\003\n\nPDashboard\022\014\n\004name\030\001 \001(\t\022L\n\rconfigura" + + "tion\030\002 \003(\01325.name.abuchen.portfolio.PDas" + + "hboard.ConfigurationEntry\022:\n\007columns\030\003 \003" + + "(\0132).name.abuchen.portfolio.PDashboard.C" + + "olumn\022\n\n\002id\030\004 \001(\t\032\260\001\n\006Widget\022\014\n\004type\030\001 \001" + + "(\t\022\r\n\005label\030\002 \001(\t\022S\n\rconfiguration\030\003 \003(\013" + + "2<.name.abuchen.portfolio.PDashboard.Wid" + + "get.ConfigurationEntry\0324\n\022ConfigurationE" + + "ntry\022\013\n\003key\030\001 \001(\t\022\r\n\005value\030\002 \001(\t:\0028\001\032T\n\006" + + "Column\022\016\n\006weight\030\001 \001(\005\022:\n\007widgets\030\002 \003(\0132" + + ").name.abuchen.portfolio.PDashboard.Widg" + + "et\0324\n\022ConfigurationEntry\022\013\n\003key\030\001 \001(\t\022\r\n" + + "\005value\030\002 \001(\t:\0028\001\"+\n\tPBookmark\022\r\n\005label\030\001" + + " \001(\t\022\017\n\007pattern\030\002 \001(\t\"\307\001\n\016PAttributeType" + + "\022\n\n\002id\030\001 \001(\t\022\014\n\004name\030\002 \001(\t\022\023\n\013columnLabe" + + "l\030\003 \001(\t\022\023\n\006source\030\004 \001(\tH\000\210\001\001\022\016\n\006target\030\005" + + " \001(\t\022\014\n\004type\030\006 \001(\t\022\026\n\016converterClass\030\007 \001" + + "(\t\0220\n\nproperties\030\010 \001(\0132\034.name.abuchen.po" + + "rtfolio.PMapB\t\n\007_source\"J\n\021PConfiguratio" + + "nSet\022\013\n\003key\030\001 \001(\t\022\014\n\004uuid\030\002 \001(\t\022\014\n\004name\030" + + "\003 \001(\t\022\014\n\004data\030\004 \001(\t\"\307\001\n\tPSettings\0224\n\tboo" + + "kmarks\030\001 \003(\0132!.name.abuchen.portfolio.PB" + + "ookmark\022>\n\016attributeTypes\030\002 \003(\0132&.name.a" + + "buchen.portfolio.PAttributeType\022D\n\021confi" + + "gurationSets\030\003 \003(\0132).name.abuchen.portfo" + + "lio.PConfigurationSet\"\366\005\n\007PClient\022\017\n\007ver" + + "sion\030\001 \001(\005\0225\n\nsecurities\030\002 \003(\0132!.name.ab" + + "uchen.portfolio.PSecurity\0222\n\010accounts\030\003 " + + "\003(\0132 .name.abuchen.portfolio.PAccount\0226\n" + + "\nportfolios\030\004 \003(\0132\".name.abuchen.portfol" + + "io.PPortfolio\022:\n\014transactions\030\005 \003(\0132$.na" + + "me.abuchen.portfolio.PTransaction\0226\n\005pla" + + "ns\030\006 \003(\0132\'.name.abuchen.portfolio.PInves" + + "tmentPlan\0226\n\nwatchlists\030\007 \003(\0132\".name.abu" + + "chen.portfolio.PWatchlist\0225\n\ntaxonomies\030" + + "\010 \003(\0132!.name.abuchen.portfolio.PTaxonomy" + + "\0226\n\ndashboards\030\t \003(\0132\".name.abuchen.port" + + "folio.PDashboard\022C\n\nproperties\030\n \003(\0132/.n" + + "ame.abuchen.portfolio.PClient.Properties" + + "Entry\0223\n\010settings\030\013 \001(\0132!.name.abuchen.p" + + "ortfolio.PSettings\022\024\n\014baseCurrency\030\014 \001(\t" + + "\022/\n\006ledger\030\r \001(\0132\037.name.abuchen.portfoli" + + "o.PLedger\022(\n\nextensions\030c \003(\0132\024.google.p" + + "rotobuf.Any\0321\n\017PropertiesEntry\022\013\n\003key\030\001 " + + "\001(\t\022\r\n\005value\030\002 \001(\t:\0028\001\"S\n\rPExchangeRate\022" + + "\014\n\004date\030\001 \001(\003\0224\n\005value\030\002 \001(\0132%.name.abuc" + + "hen.portfolio.PDecimalValue\"\203\001\n\027PExchang" + + "eRateTimeSeries\022\024\n\014baseCurrency\030\001 \001(\t\022\024\n" + + "\014termCurrency\030\002 \001(\t\022<\n\rexchangeRates\030\003 \003" + + "(\0132%.name.abuchen.portfolio.PExchangeRat" + + "e\"a\n\010PECBData\022\024\n\014lastModified\030\001 \001(\003\022?\n\006s" + + "eries\030\002 \003(\0132/.name.abuchen.portfolio.PEx" + + "changeRateTimeSeries*\301\004\n\025PLedgerProjecti" + + "onRole\022&\n\"LEDGER_PROJECTION_ROLE_UNSPECI" + + "FIED\020\000\022\"\n\036LEDGER_PROJECTION_ROLE_ACCOUNT" + + "\020\001\022$\n LEDGER_PROJECTION_ROLE_PORTFOLIO\020\002" + + "\022)\n%LEDGER_PROJECTION_ROLE_SOURCE_ACCOUN" + + "T\020\003\022)\n%LEDGER_PROJECTION_ROLE_TARGET_ACC" + + "OUNT\020\004\022+\n\'LEDGER_PROJECTION_ROLE_SOURCE_" + + "PORTFOLIO\020\005\022+\n\'LEDGER_PROJECTION_ROLE_TA" + + "RGET_PORTFOLIO\020\006\022#\n\037LEDGER_PROJECTION_RO" + + "LE_DELIVERY\020\007\022+\n\'LEDGER_PROJECTION_ROLE_" + + "DELIVERY_INBOUND\020\010\022,\n(LEDGER_PROJECTION_" + + "ROLE_DELIVERY_OUTBOUND\020\t\022,\n(LEDGER_PROJE" + + "CTION_ROLE_CASH_COMPENSATION\020\n\022+\n\'LEDGER" + + "_PROJECTION_ROLE_OLD_SECURITY_LEG\020\013\022+\n\'L" + + "EDGER_PROJECTION_ROLE_NEW_SECURITY_LEG\020\014" + + "*\204\003\n\037PLedgerProjectionMembershipRole\0221\n-" + + "LEDGER_PROJECTION_MEMBERSHIP_ROLE_UNSPEC" + + "IFIED\020\000\022-\n)LEDGER_PROJECTION_MEMBERSHIP_" + + "ROLE_PRIMARY\020\001\0222\n.LEDGER_PROJECTION_MEMB" + + "ERSHIP_ROLE_GROUP_ANCHOR\020\002\022.\n*LEDGER_PRO" + + "JECTION_MEMBERSHIP_ROLE_FEE_UNIT\020\003\022.\n*LE" + + "DGER_PROJECTION_MEMBERSHIP_ROLE_TAX_UNIT" + + "\020\004\0226\n2LEDGER_PROJECTION_MEMBERSHIP_ROLE_" + + "GROSS_VALUE_UNIT\020\005\0223\n/LEDGER_PROJECTION_" + + "MEMBERSHIP_ROLE_FOREX_CONTEXT\020\006*\327\004\n\031PLed" + + "gerParameterValueKind\022+\n\'LEDGER_PARAMETE" + + "R_VALUE_KIND_UNSPECIFIED\020\000\022&\n\"LEDGER_PAR" + + "AMETER_VALUE_KIND_STRING\020\001\022\'\n#LEDGER_PAR" + + "AMETER_VALUE_KIND_DECIMAL\020\002\022$\n LEDGER_PA" + + "RAMETER_VALUE_KIND_LONG\020\003\022%\n!LEDGER_PARA" + + "METER_VALUE_KIND_MONEY\020\004\022(\n$LEDGER_PARAM" + + "ETER_VALUE_KIND_SECURITY\020\005\022\'\n#LEDGER_PAR" + + "AMETER_VALUE_KIND_ACCOUNT\020\006\022)\n%LEDGER_PA" + + "RAMETER_VALUE_KIND_PORTFOLIO\020\007\022/\n+LEDGER" + + "_PARAMETER_VALUE_KIND_LOCAL_DATE_TIME\020\n\022" + + "\'\n#LEDGER_PARAMETER_VALUE_KIND_BOOLEAN\020\013" + + "\022*\n&LEDGER_PARAMETER_VALUE_KIND_LOCAL_DA" + + "TE\020\014\"\004\010\010\020\010\"\004\010\t\020\t*0LEDGER_PARAMETER_VALUE" + + "_KIND_CORPORATE_ACTION_LEG*-LEDGER_PARAM" + + "ETER_VALUE_KIND_COMPENSATION_KINDB7\n%nam" + + "e.abuchen.portfolio.model.proto.v1B\014Clie" + + "ntProtosP\001b\006proto3" }; descriptor = com.google.protobuf.Descriptors.FileDescriptor .internalBuildGeneratedFileFrom(descriptorData, @@ -616,7 +616,7 @@ public static void registerAllExtensions( internal_static_name_abuchen_portfolio_PLedgerEntry_fieldAccessorTable = new com.google.protobuf.GeneratedMessageV3.FieldAccessorTable( internal_static_name_abuchen_portfolio_PLedgerEntry_descriptor, - new java.lang.String[] { "Uuid", "DateTime", "Note", "Source", "UpdatedAt", "Postings", "ProjectionRefs", "TypeId", "Parameters", "GeneratedByPlanKey", "PlanExecutionDate", "PlanExecutionSequence", "PreferredViewKind", "Note", "Source", "TypeId", "GeneratedByPlanKey", "PlanExecutionDate", "PlanExecutionSequence", "PreferredViewKind", }); + new java.lang.String[] { "Uuid", "DateTime", "Note", "Source", "UpdatedAt", "Postings", "ProjectionRefs", "TypeCode", "Parameters", "GeneratedByPlanKey", "PlanExecutionDate", "PlanExecutionSequence", "PreferredViewKind", "Note", "Source", "TypeCode", "GeneratedByPlanKey", "PlanExecutionDate", "PlanExecutionSequence", "PreferredViewKind", }); internal_static_name_abuchen_portfolio_PLedgerPosting_descriptor = getDescriptor().getMessageTypes().get(18); internal_static_name_abuchen_portfolio_PLedgerPosting_fieldAccessorTable = new diff --git a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerEntry.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerEntry.java index f89a7fe8b5..d904588666 100644 --- a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerEntry.java +++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerEntry.java @@ -21,6 +21,7 @@ private PLedgerEntry() { source_ = ""; postings_ = java.util.Collections.emptyList(); projectionRefs_ = java.util.Collections.emptyList(); + typeCode_ = ""; parameters_ = java.util.Collections.emptyList(); generatedByPlanKey_ = ""; preferredViewKind_ = ""; @@ -318,23 +319,51 @@ public name.abuchen.portfolio.model.proto.v1.PLedgerPostingOrBuilder getPostings return projectionRefs_.get(index); } - public static final int TYPEID_FIELD_NUMBER = 9; - private int typeId_ = 0; + public static final int TYPECODE_FIELD_NUMBER = 9; + @SuppressWarnings("serial") + private volatile java.lang.Object typeCode_ = ""; /** - * optional uint32 typeId = 9; - * @return Whether the typeId field is set. + * optional string typeCode = 9; + * @return Whether the typeCode field is set. */ @java.lang.Override - public boolean hasTypeId() { + public boolean hasTypeCode() { return ((bitField0_ & 0x00000004) != 0); } /** - * optional uint32 typeId = 9; - * @return The typeId. + * optional string typeCode = 9; + * @return The typeCode. */ @java.lang.Override - public int getTypeId() { - return typeId_; + public java.lang.String getTypeCode() { + java.lang.Object ref = typeCode_; + if (ref instanceof java.lang.String) { + return (java.lang.String) ref; + } else { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + typeCode_ = s; + return s; + } + } + /** + * optional string typeCode = 9; + * @return The bytes for typeCode. + */ + @java.lang.Override + public com.google.protobuf.ByteString + getTypeCodeBytes() { + java.lang.Object ref = typeCode_; + if (ref instanceof java.lang.String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + typeCode_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } } public static final int PARAMETERS_FIELD_NUMBER = 10; @@ -546,7 +575,7 @@ public void writeTo(com.google.protobuf.CodedOutputStream output) output.writeMessage(8, projectionRefs_.get(i)); } if (((bitField0_ & 0x00000004) != 0)) { - output.writeUInt32(9, typeId_); + com.google.protobuf.GeneratedMessageV3.writeString(output, 9, typeCode_); } for (int i = 0; i < parameters_.size(); i++) { output.writeMessage(10, parameters_.get(i)); @@ -598,8 +627,7 @@ public int getSerializedSize() { .computeMessageSize(8, projectionRefs_.get(i)); } if (((bitField0_ & 0x00000004) != 0)) { - size += com.google.protobuf.CodedOutputStream - .computeUInt32Size(9, typeId_); + size += com.google.protobuf.GeneratedMessageV3.computeStringSize(9, typeCode_); } for (int i = 0; i < parameters_.size(); i++) { size += com.google.protobuf.CodedOutputStream @@ -660,10 +688,10 @@ public boolean equals(final java.lang.Object obj) { .equals(other.getPostingsList())) return false; if (!getProjectionRefsList() .equals(other.getProjectionRefsList())) return false; - if (hasTypeId() != other.hasTypeId()) return false; - if (hasTypeId()) { - if (getTypeId() - != other.getTypeId()) return false; + if (hasTypeCode() != other.hasTypeCode()) return false; + if (hasTypeCode()) { + if (!getTypeCode() + .equals(other.getTypeCode())) return false; } if (!getParametersList() .equals(other.getParametersList())) return false; @@ -724,9 +752,9 @@ public int hashCode() { hash = (37 * hash) + PROJECTIONREFS_FIELD_NUMBER; hash = (53 * hash) + getProjectionRefsList().hashCode(); } - if (hasTypeId()) { - hash = (37 * hash) + TYPEID_FIELD_NUMBER; - hash = (53 * hash) + getTypeId(); + if (hasTypeCode()) { + hash = (37 * hash) + TYPECODE_FIELD_NUMBER; + hash = (53 * hash) + getTypeCode().hashCode(); } if (getParametersCount() > 0) { hash = (37 * hash) + PARAMETERS_FIELD_NUMBER; @@ -905,7 +933,7 @@ public Builder clear() { projectionRefsBuilder_.clear(); } bitField0_ = (bitField0_ & ~0x00000040); - typeId_ = 0; + typeCode_ = ""; if (parametersBuilder_ == null) { parameters_ = java.util.Collections.emptyList(); } else { @@ -1004,7 +1032,7 @@ private void buildPartial0(name.abuchen.portfolio.model.proto.v1.PLedgerEntry re : updatedAtBuilder_.build(); } if (((from_bitField0_ & 0x00000080) != 0)) { - result.typeId_ = typeId_; + result.typeCode_ = typeCode_; to_bitField0_ |= 0x00000004; } if (((from_bitField0_ & 0x00000200) != 0)) { @@ -1111,8 +1139,10 @@ public Builder mergeFrom(name.abuchen.portfolio.model.proto.v1.PLedgerEntry othe } } } - if (other.hasTypeId()) { - setTypeId(other.getTypeId()); + if (other.hasTypeCode()) { + typeCode_ = other.typeCode_; + bitField0_ |= 0x00000080; + onChanged(); } if (parametersBuilder_ == null) { if (!other.parameters_.isEmpty()) { @@ -1237,11 +1267,11 @@ public Builder mergeFrom( } break; } // case 66 - case 72: { - typeId_ = input.readUInt32(); + case 74: { + typeCode_ = input.readStringRequireUtf8(); bitField0_ |= 0x00000080; break; - } // case 72 + } // case 74 case 82: { name.abuchen.portfolio.model.proto.v1.PLedgerParameter m = input.readMessage( @@ -2250,42 +2280,81 @@ private void ensureProjectionRefsIsMutable() { return projectionRefsBuilder_; } - private int typeId_ ; + private java.lang.Object typeCode_ = ""; /** - * optional uint32 typeId = 9; - * @return Whether the typeId field is set. + * optional string typeCode = 9; + * @return Whether the typeCode field is set. */ - @java.lang.Override - public boolean hasTypeId() { + public boolean hasTypeCode() { return ((bitField0_ & 0x00000080) != 0); } /** - * optional uint32 typeId = 9; - * @return The typeId. + * optional string typeCode = 9; + * @return The typeCode. */ - @java.lang.Override - public int getTypeId() { - return typeId_; + public java.lang.String getTypeCode() { + java.lang.Object ref = typeCode_; + if (!(ref instanceof java.lang.String)) { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + typeCode_ = s; + return s; + } else { + return (java.lang.String) ref; + } } /** - * optional uint32 typeId = 9; - * @param value The typeId to set. + * optional string typeCode = 9; + * @return The bytes for typeCode. + */ + public com.google.protobuf.ByteString + getTypeCodeBytes() { + java.lang.Object ref = typeCode_; + if (ref instanceof String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + typeCode_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + /** + * optional string typeCode = 9; + * @param value The typeCode to set. * @return This builder for chaining. */ - public Builder setTypeId(int value) { - - typeId_ = value; + public Builder setTypeCode( + java.lang.String value) { + if (value == null) { throw new NullPointerException(); } + typeCode_ = value; bitField0_ |= 0x00000080; onChanged(); return this; } /** - * optional uint32 typeId = 9; + * optional string typeCode = 9; * @return This builder for chaining. */ - public Builder clearTypeId() { + public Builder clearTypeCode() { + typeCode_ = getDefaultInstance().getTypeCode(); bitField0_ = (bitField0_ & ~0x00000080); - typeId_ = 0; + onChanged(); + return this; + } + /** + * optional string typeCode = 9; + * @param value The bytes for typeCode to set. + * @return This builder for chaining. + */ + public Builder setTypeCodeBytes( + com.google.protobuf.ByteString value) { + if (value == null) { throw new NullPointerException(); } + checkByteStringIsUtf8(value); + typeCode_ = value; + bitField0_ |= 0x00000080; onChanged(); return this; } diff --git a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerEntryOrBuilder.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerEntryOrBuilder.java index 2e320a7c88..db363a5117 100644 --- a/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerEntryOrBuilder.java +++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerEntryOrBuilder.java @@ -136,15 +136,21 @@ name.abuchen.portfolio.model.proto.v1.PLedgerPostingOrBuilder getPostingsOrBuild int index); /** - * optional uint32 typeId = 9; - * @return Whether the typeId field is set. + * optional string typeCode = 9; + * @return Whether the typeCode field is set. */ - boolean hasTypeId(); + boolean hasTypeCode(); /** - * optional uint32 typeId = 9; - * @return The typeId. + * optional string typeCode = 9; + * @return The typeCode. */ - int getTypeId(); + java.lang.String getTypeCode(); + /** + * optional string typeCode = 9; + * @return The bytes for typeCode. + */ + com.google.protobuf.ByteString + getTypeCodeBytes(); /** * repeated .name.abuchen.portfolio.PLedgerParameter parameters = 10; diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceSupport.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceSupport.java index 3dc4e79823..18e77f2eee 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceSupport.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceSupport.java @@ -203,7 +203,7 @@ private static void loadLedger(PLedger newLedger, Client client, ProtobufWriter. for (PLedgerEntry newEntry : newLedger.getEntriesList()) { LedgerEntry entry = LedgerModelLoadSupport.newEntry(UUID.randomUUID().toString(), - LedgerEntryType.fromProtobufId(newEntry.getTypeId()), + LedgerEntryType.fromCode(newEntry.getTypeCode()), fromTimestamp(newEntry.getDateTime())); if (newEntry.hasNote()) @@ -330,7 +330,7 @@ private static PLedgerEntry saveLedgerEntry(LedgerEntry entry) { PLedgerEntry.Builder newEntry = PLedgerEntry.newBuilder(); - newEntry.setTypeId(entry.getType().getProtobufId()); + newEntry.setTypeCode(entry.getType().getCode()); newEntry.setDateTime(asTimestamp(entry.getDateTime())); if (entry.getNote() != null) diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/client.proto b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/client.proto index 97d698f8d8..06ea1215fa 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/client.proto +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/client.proto @@ -284,7 +284,7 @@ message PLedgerEntry { google.protobuf.Timestamp updatedAt = 6; repeated PLedgerPosting postings = 7; repeated PLedgerProjectionRef projectionRefs = 8 [deprecated = true]; - optional uint32 typeId = 9; + optional string typeCode = 9; repeated PLedgerParameter parameters = 10; optional string generatedByPlanKey = 11; optional int64 planExecutionDate = 12; 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 index a2ce0e0f85..4527fb1c43 100644 --- 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 @@ -3,37 +3,31 @@ import name.abuchen.portfolio.model.LedgerDiagnosticCode; /** - * Defines stable Ledger type codes used by persistence and validation. - * This is Ledger configuration metadata. Existing persistence ids must stay stable, and - * normal transaction-editing code should use higher-level write paths. - * - *

- * Protobuf stores {@link #getProtobufId()} in {@code PLedgerEntry.typeId}. Existing ids must - * never be changed or reused, and new entry types must receive a new stable id. - *

+ * 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. */ public enum LedgerEntryType { - DEPOSIT(1, Shape.LEGACY_FIXED), - REMOVAL(2, Shape.LEGACY_FIXED), - INTEREST(3, Shape.LEGACY_FIXED), - INTEREST_CHARGE(4, Shape.LEGACY_FIXED), - FEES(5, Shape.LEGACY_FIXED), - FEES_REFUND(6, Shape.LEGACY_FIXED), - TAXES(7, Shape.LEGACY_FIXED), - TAX_REFUND(8, Shape.LEGACY_FIXED), - DIVIDENDS(9, Shape.LEGACY_FIXED), - BUY(10, Shape.LEGACY_FIXED), - SELL(11, Shape.LEGACY_FIXED), - CASH_TRANSFER(12, Shape.LEGACY_FIXED), - SECURITY_TRANSFER(13, Shape.LEGACY_FIXED), - DELIVERY_INBOUND(14, Shape.LEGACY_FIXED), - DELIVERY_OUTBOUND(15, Shape.LEGACY_FIXED), - SPIN_OFF(16, Shape.LEDGER_NATIVE_TARGETED), - STOCK_DIVIDEND(17, Shape.LEDGER_NATIVE_TARGETED), - BONUS_ISSUE(18, Shape.LEDGER_NATIVE_TARGETED), - RIGHTS_DISTRIBUTION(19, Shape.LEDGER_NATIVE_TARGETED), - BOND_CONVERSION(20, Shape.LEDGER_NATIVE_TARGETED); + DEPOSIT(Shape.LEGACY_FIXED), + REMOVAL(Shape.LEGACY_FIXED), + INTEREST(Shape.LEGACY_FIXED), + INTEREST_CHARGE(Shape.LEGACY_FIXED), + FEES(Shape.LEGACY_FIXED), + FEES_REFUND(Shape.LEGACY_FIXED), + TAXES(Shape.LEGACY_FIXED), + TAX_REFUND(Shape.LEGACY_FIXED), + DIVIDENDS(Shape.LEGACY_FIXED), + BUY(Shape.LEGACY_FIXED), + SELL(Shape.LEGACY_FIXED), + CASH_TRANSFER(Shape.LEGACY_FIXED), + SECURITY_TRANSFER(Shape.LEGACY_FIXED), + DELIVERY_INBOUND(Shape.LEGACY_FIXED), + DELIVERY_OUTBOUND(Shape.LEGACY_FIXED), + SPIN_OFF(Shape.LEDGER_NATIVE_TARGETED), + STOCK_DIVIDEND(Shape.LEDGER_NATIVE_TARGETED), + BONUS_ISSUE(Shape.LEDGER_NATIVE_TARGETED), + RIGHTS_DISTRIBUTION(Shape.LEDGER_NATIVE_TARGETED), + BOND_CONVERSION(Shape.LEDGER_NATIVE_TARGETED); private enum Shape { @@ -41,33 +35,33 @@ private enum Shape LEDGER_NATIVE_TARGETED } - // Protobuf persistence ID. - // Never change existing IDs. - // Never reuse removed IDs. - // New enum constants must receive a new unique ID. - private final int protobufId; + private final String code; private final Shape shape; - private LedgerEntryType(int protobufId, Shape shape) + private LedgerEntryType(Shape shape) { - this.protobufId = protobufId; + this.code = name(); this.shape = shape; } - public int getProtobufId() + public String getCode() { - return protobufId; + return code; } - public static LedgerEntryType fromProtobufId(int id) + public static LedgerEntryType fromCode(String code) { + if (code == null || code.isBlank()) + throw new IllegalArgumentException( + LedgerDiagnosticCode.LEDGER_CORE_017.message("Missing LedgerEntryType code")); //$NON-NLS-1$ + for (LedgerEntryType type : values()) - if (type.protobufId == id) + if (type.code.equals(code)) return type; throw new IllegalArgumentException( - LedgerDiagnosticCode.LEDGER_CORE_017.message("Unknown LedgerEntryType protobuf ID: " + id)); //$NON-NLS-1$ + LedgerDiagnosticCode.LEDGER_CORE_017.message("Unknown LedgerEntryType code: " + code)); //$NON-NLS-1$ } public boolean isLegacyFixedShape() From cd42e2518266e0d82c4c6c4e6baac756dcfa0f3c Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:10:24 +0200 Subject: [PATCH 36/68] Decouple Ledger entry type code from enum name LedgerEntryType now stores each persisted type code as an explicit enum constructor argument and validates that entry type codes are nonblank and unique. This prevents native Ledger XML/protobuf codes from depending on Java enum identifiers while keeping the current serialized code values unchanged. The protobuf schema, generated protobuf sources, legacy PTransaction.Type mapping, XML behavior, UI/import/check code, InvestmentPlan linkage, and migration diagnostics are intentionally unchanged. --- .../model/ledger/LedgerModelTest.java | 4 ++ .../ledger/configuration/LedgerEntryType.java | 71 ++++++++++++------- 2 files changed, 50 insertions(+), 25 deletions(-) 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 index ce18ef61fb..8bd21d9213 100644 --- 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 @@ -548,6 +548,10 @@ 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"))); } /** 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 index 4527fb1c43..73beaa0ffa 100644 --- 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 @@ -1,33 +1,37 @@ 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(Shape.LEGACY_FIXED), - REMOVAL(Shape.LEGACY_FIXED), - INTEREST(Shape.LEGACY_FIXED), - INTEREST_CHARGE(Shape.LEGACY_FIXED), - FEES(Shape.LEGACY_FIXED), - FEES_REFUND(Shape.LEGACY_FIXED), - TAXES(Shape.LEGACY_FIXED), - TAX_REFUND(Shape.LEGACY_FIXED), - DIVIDENDS(Shape.LEGACY_FIXED), - BUY(Shape.LEGACY_FIXED), - SELL(Shape.LEGACY_FIXED), - CASH_TRANSFER(Shape.LEGACY_FIXED), - SECURITY_TRANSFER(Shape.LEGACY_FIXED), - DELIVERY_INBOUND(Shape.LEGACY_FIXED), - DELIVERY_OUTBOUND(Shape.LEGACY_FIXED), - SPIN_OFF(Shape.LEDGER_NATIVE_TARGETED), - STOCK_DIVIDEND(Shape.LEDGER_NATIVE_TARGETED), - BONUS_ISSUE(Shape.LEDGER_NATIVE_TARGETED), - RIGHTS_DISTRIBUTION(Shape.LEDGER_NATIVE_TARGETED), - BOND_CONVERSION(Shape.LEDGER_NATIVE_TARGETED); + 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), + SPIN_OFF("SPIN_OFF", Shape.LEDGER_NATIVE_TARGETED), + STOCK_DIVIDEND("STOCK_DIVIDEND", Shape.LEDGER_NATIVE_TARGETED), + BONUS_ISSUE("BONUS_ISSUE", Shape.LEDGER_NATIVE_TARGETED), + RIGHTS_DISTRIBUTION("RIGHTS_DISTRIBUTION", Shape.LEDGER_NATIVE_TARGETED), + BOND_CONVERSION("BOND_CONVERSION", Shape.LEDGER_NATIVE_TARGETED); private enum Shape { @@ -39,10 +43,27 @@ private enum Shape private final Shape shape; - private LedgerEntryType(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 = name(); - this.shape = shape; + this.code = Objects.requireNonNull(code); + this.shape = Objects.requireNonNull(shape); } public String getCode() @@ -54,14 +75,14 @@ public static LedgerEntryType fromCode(String code) { if (code == null || code.isBlank()) throw new IllegalArgumentException( - LedgerDiagnosticCode.LEDGER_CORE_017.message("Missing LedgerEntryType code")); //$NON-NLS-1$ + 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)); //$NON-NLS-1$ + LedgerDiagnosticCode.LEDGER_CORE_017.message("Unknown LedgerEntryType code: " + code)); } public boolean isLegacyFixedShape() From 5f78a4db518368616a0936ef7ad45acc2a094f55 Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:33:48 +0200 Subject: [PATCH 37/68] Route ex-date backfill through Ledger editor Replace the BackfillExDatesHandler apply loop with a preflighted update path that keeps legacy dividends on the existing setter and routes Ledger-backed dividends through LedgerInlineEditingPolicy. This avoids writing through read-only Ledger runtime projections while keeping candidate collection, DivvyDiary lookup, and confirmation flow unchanged. XML, protobuf, Ledger model semantics, import, check, repair, dialog, conversion, and file-save behavior were intentionally left unchanged. --- .../tools/BackfillExDatesHandlerTest.java | 242 ++++++++++++++++++ .../tools/BackfillExDatesHandler.java | 69 ++++- 2 files changed, 308 insertions(+), 3 deletions(-) create mode 100644 name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/handlers/tools/BackfillExDatesHandlerTest.java diff --git a/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/handlers/tools/BackfillExDatesHandlerTest.java b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/handlers/tools/BackfillExDatesHandlerTest.java new file mode 100644 index 0000000000..958f35e7c7 --- /dev/null +++ b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/handlers/tools/BackfillExDatesHandlerTest.java @@ -0,0 +1,242 @@ +package name.abuchen.portfolio.ui.handlers.tools; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertThrows; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; + +import org.eclipse.core.runtime.NullProgressMonitor; +import org.junit.Test; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.ClientFactory; +import name.abuchen.portfolio.model.SaveFlag; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.TransactionPair; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountOnlyTransactionCreator; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerDividendTransactionCreator; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Values; + +@SuppressWarnings("nls") +public class BackfillExDatesHandlerTest +{ + private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 6, 7, 8, 9); + private static final LocalDate BACKFILLED_DATE = LocalDate.of(2026, 6, 1); + private static final LocalDateTime BACKFILLED_EX_DATE = BACKFILLED_DATE.atStartOfDay(); + private static final Instant UPDATED_AT = Instant.parse("2026-06-07T08:09:00Z"); + + @Test + public void testLegacyDividendBackfillUsesLegacySetter() + { + var fixture = fixture(); + var transaction = createLegacyDividend(fixture); + var dirtyEvents = trackDirtyEvents(fixture.client()); + + BackfillExDatesHandler.applyExDates(fixture.client(), List.of(match(fixture.account(), transaction))); + + assertThat(transaction.getExDate(), is(BACKFILLED_EX_DATE)); + assertThat(dirtyEvents.size(), is(1)); + } + + @Test + public void testLedgerBackedDividendBackfillRoutesThroughLedgerEditor() throws Exception + { + var fixture = fixture(); + var transaction = createLedgerDividend(fixture); + var dirtyEvents = trackDirtyEvents(fixture.client()); + + assertThat(transaction.getClass().getSimpleName(), is("LedgerBackedAccountTransaction")); + assertThat(transaction.getExDate(), nullValue()); + assertThrows(UnsupportedOperationException.class, () -> transaction.setExDate(BACKFILLED_EX_DATE)); + + BackfillExDatesHandler.applyExDates(fixture.client(), List.of(match(fixture.account(), transaction))); + + assertThat(transaction.getExDate(), is(BACKFILLED_EX_DATE)); + assertThat(ledgerPostingExDate(transaction), is(BACKFILLED_EX_DATE)); + assertThat(fixture.account().getTransactions().size(), is(1)); + assertThat(dirtyEvents.size(), is(1)); + assertLedgerStructurallyValid(fixture.client()); + + var xmlLoaded = reloadXml(fixture.client()); + var xmlTransaction = xmlLoaded.getAccounts().get(0).getTransactions().get(0); + assertThat(xmlTransaction.getExDate(), is(BACKFILLED_EX_DATE)); + assertThat(ledgerPostingExDate(xmlTransaction), is(BACKFILLED_EX_DATE)); + + var protobufLoaded = reloadProtobuf(fixture.client()); + var protobufTransaction = protobufLoaded.getAccounts().get(0).getTransactions().get(0); + assertThat(protobufTransaction.getExDate(), is(BACKFILLED_EX_DATE)); + assertThat(ledgerPostingExDate(protobufTransaction), is(BACKFILLED_EX_DATE)); + } + + @Test + public void testMixedLegacyAndLedgerBackfillUpdatesBoth() throws Exception + { + var fixture = fixture(); + var legacy = createLegacyDividend(fixture); + var ledger = createLedgerDividend(fixture); + + BackfillExDatesHandler.applyExDates(fixture.client(), + List.of(match(fixture.account(), legacy), match(fixture.account(), ledger))); + + assertThat(legacy.getExDate(), is(BACKFILLED_EX_DATE)); + assertThat(ledger.getExDate(), is(BACKFILLED_EX_DATE)); + assertThat(ledgerPostingExDate(ledger), is(BACKFILLED_EX_DATE)); + assertThat(fixture.account().getTransactions().size(), is(2)); + } + + @Test + public void testPreflightFailureLeavesLegacyDividendUnchanged() + { + var fixture = fixture(); + var legacy = createLegacyDividend(fixture); + var unsupportedLedgerRow = createUnsupportedLedgerAccountTransaction(fixture); + var dirtyEvents = trackDirtyEvents(fixture.client()); + + assertThrows(IllegalArgumentException.class, () -> BackfillExDatesHandler.applyExDates(fixture.client(), + List.of(match(fixture.account(), legacy), match(fixture.account(), unsupportedLedgerRow)))); + + assertThat(legacy.getExDate(), nullValue()); + assertThat(unsupportedLedgerRow.getExDate(), nullValue()); + assertThat(dirtyEvents.size(), is(0)); + } + + private BackfillExDatesHandler.MatchedTransaction match(Account account, AccountTransaction transaction) + { + return new BackfillExDatesHandler.MatchedTransaction(new TransactionPair<>(account, transaction), + BACKFILLED_DATE); + } + + private AccountTransaction createLegacyDividend(Fixture fixture) + { + var transaction = new AccountTransaction(AccountTransaction.Type.DIVIDENDS); + + transaction.setDateTime(DATE_TIME); + transaction.setAmount(Values.Amount.factorize(123)); + transaction.setCurrencyCode(CurrencyUnit.EUR); + transaction.setSecurity(fixture.security()); + transaction.setShares(Values.Share.factorize(5)); + transaction.setNote("legacy note"); + fixture.account().addTransaction(transaction); + + return transaction; + } + + private AccountTransaction createLedgerDividend(Fixture fixture) + { + return new LedgerDividendTransactionCreator(fixture.client()).create(fixture.account(), DATE_TIME, + Values.Amount.factorize(123), CurrencyUnit.EUR, fixture.security(), + Values.Share.factorize(5), null, null, null, List.of(), "ledger note", "ledger source"); + } + + private AccountTransaction createUnsupportedLedgerAccountTransaction(Fixture fixture) + { + return new LedgerAccountOnlyTransactionCreator(fixture.client()).create(fixture.account(), + AccountTransaction.Type.FEES, DATE_TIME, Values.Amount.factorize(123), CurrencyUnit.EUR, + fixture.security(), List.of(), "fee note", "fee source"); + } + + private List trackDirtyEvents(Client client) + { + var events = new ArrayList<>(); + + client.addPropertyChangeListener("dirty", event -> events.add(event)); + + return events; + } + + private LocalDateTime ledgerPostingExDate(AccountTransaction transaction) throws Exception + { + var entry = transaction.getClass().getMethod("getLedgerEntry").invoke(transaction); + var postings = (List) entry.getClass().getMethod("getPostings").invoke(entry); + + for (var posting : postings) + { + var parameters = (List) posting.getClass().getMethod("getParameters").invoke(posting); + for (var parameter : parameters) + { + if ("EX_DATE".equals(parameter.getClass().getMethod("getType").invoke(parameter).toString())) + return (LocalDateTime) parameter.getClass().getMethod("getValue").invoke(parameter); + } + } + + return null; + } + + private Client reloadXml(Client client) throws Exception + { + var file = File.createTempFile("backfill-ex-date", ".xml"); + + try + { + ClientFactory.save(client, file); + return ClientFactory.load(new ByteArrayInputStream(Files.readString(file.toPath(), StandardCharsets.UTF_8) + .getBytes(StandardCharsets.UTF_8))); + } + finally + { + Files.deleteIfExists(file.toPath()); + } + } + + private Client reloadProtobuf(Client client) throws Exception + { + var file = File.createTempFile("backfill-ex-date", ".portfolio"); + + try + { + ClientFactory.saveAs(client, file, null, EnumSet.of(SaveFlag.BINARY, SaveFlag.COMPRESSED)); + return ClientFactory.load(file, null, new NullProgressMonitor()); + } + finally + { + Files.deleteIfExists(file.toPath()); + } + } + + private void assertLedgerStructurallyValid(Client client) throws Exception + { + var ledger = Client.class.getMethod("getLedger").invoke(client); + Class validator = Class.forName("name.abuchen.portfolio.model.ledger.LedgerStructuralValidator", true, + Client.class.getClassLoader()); + Method validate = validator.getMethod("validate", ledger.getClass()); + var result = validate.invoke(null, ledger); + + assertThat(result.getClass().getMethod("getIssues").invoke(result).toString(), + (Boolean) result.getClass().getMethod("isOK").invoke(result), is(true)); + } + + private Fixture fixture() + { + var client = new Client(); + var account = new Account("Account"); + account.setCurrencyCode(CurrencyUnit.EUR); + account.setUpdatedAt(UPDATED_AT); + + var security = new Security("Security", CurrencyUnit.EUR); + security.setUpdatedAt(UPDATED_AT); + + client.addAccount(account); + client.addSecurity(security); + + return new Fixture(client, account, security); + } + + private record Fixture(Client client, Account account, Security security) + { + } +} diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/handlers/tools/BackfillExDatesHandler.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/handlers/tools/BackfillExDatesHandler.java index b55a072c49..75f05d326a 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/handlers/tools/BackfillExDatesHandler.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/handlers/tools/BackfillExDatesHandler.java @@ -4,10 +4,12 @@ import java.lang.reflect.InvocationTargetException; import java.text.MessageFormat; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import jakarta.inject.Named; @@ -38,10 +40,13 @@ import name.abuchen.portfolio.model.Account; import name.abuchen.portfolio.model.AccountTransaction; import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; import name.abuchen.portfolio.model.Security; import name.abuchen.portfolio.model.SecurityEvent; import name.abuchen.portfolio.model.SecurityEvent.DividendEvent; import name.abuchen.portfolio.model.TransactionPair; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerInlineEditingField; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerInlineEditingPolicy; import name.abuchen.portfolio.money.Values; import name.abuchen.portfolio.online.Factory; import name.abuchen.portfolio.online.impl.DivvyDiaryDividendFeed; @@ -104,13 +109,71 @@ public void execute(@Named(IServiceConstants.ACTIVE_PART) MPart part, if (dialog.open() != IDialogConstants.OK_ID) return; - matches.forEach(match -> match.transaction.getTransaction().setExDate(match.exDate().atStartOfDay())); - client.markDirty(); + applyExDates(client, matches); MessageDialog.openInformation(shell, Messages.LabelInfo, MessageFormat.format(Messages.MsgBackfillExDatesUpdated, matches.size())); } + static void applyExDates(Client client, List matches) + { + Objects.requireNonNull(client); + Objects.requireNonNull(matches); + + var updates = matches.stream().map(match -> prepareExDateUpdate(client, match)).toList(); + updates.forEach(PreparedExDateUpdate::apply); + + if (!updates.isEmpty()) + client.markDirty(); + } + + private static PreparedExDateUpdate prepareExDateUpdate(Client client, MatchedTransaction match) + { + var pair = match.transaction(); + var transaction = pair.getTransaction(); + var exDate = match.exDate().atStartOfDay(); + + if (transaction.getType() != AccountTransaction.Type.DIVIDENDS) + throw new IllegalArgumentException("Backfill ex-date updates require dividend transactions"); //$NON-NLS-1$ + + if (!LedgerInlineEditingPolicy.isLedgerBacked(client, transaction)) + return () -> transaction.setExDate(exDate); + + if (!LedgerInlineEditingPolicy.isEditable(transaction, LedgerInlineEditingField.EX_DATE) + || !LedgerInlineEditingPolicy.canUpdateDividendExDate(client, transaction)) + { + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_UI_009 + .message(Messages.LedgerExDateEditingSupportNoSafeEditorPolicyBlocked)); + } + + if (!(pair.getOwner() instanceof Account owner)) + { + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_UI_010 + .message(Messages.LedgerExDateEditingSupportNoSafeEditorLedgerBacked)); + } + + return new LedgerExDateUpdate(client, owner, transaction, exDate); + } + + private interface PreparedExDateUpdate + { + void apply(); + } + + private record LedgerExDateUpdate(Client client, Account owner, AccountTransaction transaction, + LocalDateTime exDate) implements PreparedExDateUpdate + { + @Override + public void apply() + { + if (!LedgerInlineEditingPolicy.updateDividendExDate(client, owner, transaction, exDate)) + { + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_UI_010 + .message(Messages.LedgerExDateEditingSupportNoSafeEditorLedgerBacked)); + } + } + } + private void collectMatches(Client client, List matches, IProgressMonitor monitor) throws InterruptedException { @@ -188,7 +251,7 @@ private List> collectCandidateTransactions(C return answer; } - private record MatchedTransaction(TransactionPair transaction, LocalDate exDate) + record MatchedTransaction(TransactionPair transaction, LocalDate exDate) { } From 6cfbba452959bc86e0d4398f2c967a42fa5cd676 Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:07:56 +0200 Subject: [PATCH 38/68] Write protobuf shadows for native Ledger projections Remove the legacy-fixed entry gate from Ledger protobuf compatibility shadow writing so derived native Ledger projections can be serialized through existing legacy-shaped PTransaction rows. This keeps native Ledger truth in PLedgerEntry.typeCode while providing boundary-only shadows for readers that inspect legacy transaction rows. The change intentionally leaves PTransaction.Type, XML persistence, generated protobuf code, migration diagnostics, UI/import/check code, and Ledger model semantics unchanged. --- .../model/LedgerProtobufPersistenceTest.java | 22 ++++++++++++++++--- .../LedgerProtobufPersistenceSupport.java | 3 --- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceTest.java index d1d7378c27..8a7d6ac98f 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceTest.java @@ -698,8 +698,8 @@ public void testLegacyTransactionTypeEnumDoesNotContainCorporateActions() } /** - * Verifies that native Corporate Actions persist as semantic Ledger protobuf truth. - * They must not require legacy transaction types or persisted projection refs. + * Verifies that native Corporate Actions persist as semantic Ledger protobuf truth + * while writing boundary-only legacy-shaped compatibility shadows. */ @Test public void testNativeCorporateActionsRoundtripAsSemanticLedgerTruth() throws IOException @@ -714,8 +714,11 @@ public void testNativeCorporateActionsRoundtripAsSemanticLedgerTruth() throws IO var proto = saveProto(fixture.client()); assertNoLedgerUuidTruth(proto); - assertThat(proto.getTransactionsCount(), is(0)); assertThat(proto.getLedger().getEntries(0).getTypeCode(), is(entryType.getCode())); + assertThat(proto.getTransactionsList().stream().map(PTransaction::getType).toList(), + is(nativeCorporateActionShadowTypes(entryType))); + assertTrue(proto.getTransactionsList().stream() + .allMatch(transaction -> transaction.getUuid().startsWith("ledger-shadow:"))); assertTrue(proto.getLedger().getEntries(0).getPostingsList().stream() .anyMatch(posting -> posting.hasCorporateActionLeg())); @@ -725,6 +728,8 @@ public void testNativeCorporateActionsRoundtripAsSemanticLedgerTruth() throws IO assertThat(reloadedEntry.getType(), is(entryType)); assertFalse(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(reloadedEntry) .isEmpty()); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(reloadedEntry) + .size(), is(proto.getTransactionsCount())); assertTrue(reloadedEntry.getPostings().stream() .anyMatch(posting -> posting.getCorporateActionLeg() != null)); assertValid(loaded); @@ -1121,6 +1126,17 @@ private void addNativeEntry(ClientFixture fixture, LedgerEntryType entryType) fixture.client().getLedger().addEntry(entry); } + private List nativeCorporateActionShadowTypes(LedgerEntryType entryType) + { + return switch (entryType) + { + case SPIN_OFF -> List.of(PTransaction.Type.OUTBOUND_DELIVERY, PTransaction.Type.INBOUND_DELIVERY); + case STOCK_DIVIDEND, BONUS_ISSUE, RIGHTS_DISTRIBUTION -> List.of(PTransaction.Type.INBOUND_DELIVERY); + case BOND_CONVERSION -> List.of(PTransaction.Type.OUTBOUND_DELIVERY, PTransaction.Type.INBOUND_DELIVERY); + default -> throw new IllegalArgumentException(entryType.name()); + }; + } + private LedgerPosting nativeSecurityPosting(ClientFixture fixture, LedgerPostingType postingType, CorporateActionLeg leg, Security security, LedgerPostingDirection direction, LedgerProjectionRole role) { diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceSupport.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceSupport.java index 18e77f2eee..014eb8f43c 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceSupport.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceSupport.java @@ -113,9 +113,6 @@ static void saveLedgerCompatibilityShadows(Client client, PClient.Builder newCli { for (LedgerEntry entry : client.getLedger().getEntries()) { - if (!entry.getType().isLegacyFixedShape()) - continue; - for (Transaction transaction : LedgerProjectionService.createProjections(entry)) { if (!shouldSaveLedgerCompatibilityShadow(transaction)) From 644fc8c0d58fc747a3610b8cff41875765125a49 Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:59:08 +0200 Subject: [PATCH 39/68] Clean up Ledger Java hygiene Removes stale Ledger helper code and warning cleanup leftovers, isolates deprecated protobuf absence checks, and tightens local Ledger runtime selector usage. Leaves XML/protobuf schemas, generated protobuf code, and Ledger behavior unchanged; broader UUID-bearing runtime compatibility seams remain for separate design work. --- .../checks/impl/CrossEntryCheckTest.java | 9 - .../actions/InsertActionTest.java | 1 - .../LedgerImportWriteGuardrailTest.java | 1 - .../model/LedgerProtobufPersistenceTest.java | 37 +++- .../model/LedgerSaveLoadParityTest.java | 204 +----------------- .../model/ledger/LedgerEditorTest.java | 1 - .../ledger/LedgerEntryDefinitionTest.java | 1 - .../ledger/LedgerMutationContextTest.java | 1 - .../LedgerPostingTypeDefinitionTest.java | 1 - .../LedgerProjectionMaterializerTest.java | 12 +- .../ledger/LedgerSpinOffScenarioTest.java | 1 - ...dgerAccountOnlyTransactionCreatorTest.java | 10 +- ...TransferToDepositRemovalConverterTest.java | 10 - .../LedgerAccountTypeToggleConverterTest.java | 1 - .../LedgerBuySellDeliveryConverterTest.java | 2 - .../LedgerDeliveryTransactionCreatorTest.java | 8 - .../LedgerDividendTransactionCreatorTest.java | 8 - ...LegacyTransactionToLedgerMigratorTest.java | 186 ---------------- .../LedgerNativeEntryAssemblerTest.java | 2 +- .../LedgerRuntimeProjectionRestorerTest.java | 2 +- .../AccountTransactionModelTest.java | 1 - .../views/LedgerTransactionRoutingTest.java | 8 - .../AccountTransferLegacyActionGuardTest.java | 3 - .../impl/NegativeExchangeRateCheck.java | 9 +- ...actionsWithSecurityCanHaveExDateCheck.java | 13 +- .../portfolio/model/ClientFactory.java | 2 +- .../model/LedgerXmlPersistenceSupport.java | 4 +- .../model/ledger/LedgerEntryEditSupport.java | 18 ++ .../ledger/LedgerStructuralValidator.java | 31 +-- .../LedgerAccountOnlyTransactionCreator.java | 2 +- .../LedgerAccountTransactionEditor.java | 17 +- .../LedgerAccountTransferEditor.java | 18 +- ...ountTransferToDepositRemovalConverter.java | 2 +- ...dgerAccountTransferTransactionCreator.java | 2 +- .../LedgerAccountTypeToggleConverter.java | 2 +- .../compatibility/LedgerBuySellEditor.java | 22 +- .../LedgerBuySellTransactionCreator.java | 2 +- .../LedgerDeliveryTransactionCreator.java | 2 +- .../LedgerDeliveryTransactionEditor.java | 14 +- .../LedgerDividendTransactionCreator.java | 2 +- .../compatibility/LedgerOwnerPatchHelper.java | 2 +- .../LedgerPortfolioTransferEditor.java | 18 +- ...erPortfolioTransferTransactionCreator.java | 2 +- .../LedgerShareAdjustmentHelper.java | 2 +- .../LedgerTransactionCreator.java | 2 +- .../LedgerTransferDirectionConverter.java | 23 -- .../LedgerNativeEntryDefinitionValidator.java | 5 - .../LegacyTransactionToLedgerMigrator.java | 9 - .../DerivedProjectionDescriptorService.java | 6 +- .../LedgerBackedAccountTransaction.java | 2 +- .../projection/LedgerBackedCrossEntry.java | 2 +- .../LedgerBackedPortfolioTransaction.java | 2 +- .../projection/LedgerProjectionFactory.java | 2 +- .../projection/LedgerProjectionService.java | 4 +- .../projection/LedgerProjectionSupport.java | 9 - 55 files changed, 144 insertions(+), 618 deletions(-) diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/checks/impl/CrossEntryCheckTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/checks/impl/CrossEntryCheckTest.java index e70ab7e4b6..10d7f79c33 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/checks/impl/CrossEntryCheckTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/checks/impl/CrossEntryCheckTest.java @@ -36,7 +36,6 @@ import name.abuchen.portfolio.model.Transaction; import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountOnlyTransactionCreator; import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountTransferTransactionCreator; import name.abuchen.portfolio.model.ledger.compatibility.LedgerBuySellTransactionCreator; @@ -514,14 +513,6 @@ private LedgerEntry ledgerEntry(Transaction transaction) return ((LedgerBackedTransaction) transaction).getLedgerEntry(); } - private void rematerializeLedgerProjections() - { - client.getAccounts().forEach(owner -> owner.getTransactions().removeIf(LedgerCheckSupport::isLedgerBacked)); - client.getPortfolios().forEach(owner -> owner.getTransactions().removeIf(LedgerCheckSupport::isLedgerBacked)); - - LedgerProjectionService.materialize(client); - } - private void assertEntryDeleted(String entryUUID) { assertFalse(client.getLedger().getEntries().stream().anyMatch(entry -> entry.getUUID().equals(entryUUID))); diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/datatransfer/actions/InsertActionTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/datatransfer/actions/InsertActionTest.java index cfbc1129cc..aa4cf61b21 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/datatransfer/actions/InsertActionTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/datatransfer/actions/InsertActionTest.java @@ -47,7 +47,6 @@ import name.abuchen.portfolio.model.SaveFlag; import name.abuchen.portfolio.model.Security; import name.abuchen.portfolio.model.SecurityPrice; -import name.abuchen.portfolio.model.Transaction; import name.abuchen.portfolio.model.Transaction.Unit; import name.abuchen.portfolio.model.ledger.LedgerParameter; import name.abuchen.portfolio.model.ledger.LedgerPosting; diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/datatransfer/actions/LedgerImportWriteGuardrailTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/datatransfer/actions/LedgerImportWriteGuardrailTest.java index cf340e0145..a499a3511b 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/datatransfer/actions/LedgerImportWriteGuardrailTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/datatransfer/actions/LedgerImportWriteGuardrailTest.java @@ -3,7 +3,6 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.is; import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; import java.io.ByteArrayInputStream; import java.io.File; diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceTest.java index 8a7d6ac98f..ea1b969689 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceTest.java @@ -57,6 +57,7 @@ import name.abuchen.portfolio.model.proto.v1.PLedgerEntry; import name.abuchen.portfolio.model.proto.v1.PLedgerParameter; import name.abuchen.portfolio.model.proto.v1.PLedgerParameterValueKind; +import name.abuchen.portfolio.model.proto.v1.PLedgerPosting; import name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRole; import name.abuchen.portfolio.model.proto.v1.PTransaction; import name.abuchen.portfolio.money.CurrencyUnit; @@ -96,9 +97,7 @@ public void testSaveWritesSemanticLedgerTruthOnField13AndAccountShadow() throws assertNoLedgerUuidTruth(proto); assertTrue(proto.hasLedger()); assertThat(proto.getLedger().getEntriesCount(), is(1)); - assertThat(ledgerEntry.getUuid(), is("")); - assertThat(ledgerEntry.getProjectionRefsCount(), is(0)); - assertThat(posting.getUuid(), is("")); + assertNoDeprecatedLedgerIdentity(ledgerEntry); assertThat(posting.getSemanticRole(), is(LedgerPostingSemanticRole.CASH.name())); assertThat(posting.getDirection(), is(LedgerPostingDirection.NEUTRAL.name())); assertThat(posting.getUnitRole(), is(LedgerPostingUnitRole.PRIMARY.name())); @@ -152,7 +151,7 @@ public void testDividendRoundtripPreservesExDateUnitsForexAndSemantics() throws var proto = saveProto(fixture.client()); assertNoLedgerUuidTruth(proto); - assertThat(proto.getLedger().getEntries(0).getProjectionRefsCount(), is(0)); + assertNoDeprecatedLedgerProjectionRefs(proto.getLedger().getEntries(0)); assertTrue(proto.getLedger().getEntries(0).getPostingsList().stream() .anyMatch(posting -> LedgerPostingUnitRole.FEE.name().equals(posting.getUnitRole()))); assertTrue(proto.getLedger().getEntries(0).getPostingsList().stream() @@ -806,7 +805,7 @@ public void testLocalDateLedgerParameterProtobufUsesTypePolicy() throws IOExcept public void testInvestmentPlanExecutionMetadataRoundtrip() throws IOException { var fixture = fixture(); - var buy = new LedgerTransactionCreator(fixture.client()).createBuy(metadata(), cashLeg(fixture.account(), 100), + new LedgerTransactionCreator(fixture.client()).createBuy(metadata(), cashLeg(fixture.account(), 100), securityLeg(fixture.portfolio(), fixture.security(), 5, 100), LedgerCreationUnits.none()) .getEntry(); LedgerProjectionService.materialize(fixture.client()); @@ -1190,13 +1189,29 @@ private void assertUnit(name.abuchen.portfolio.model.proto.v1.PTransactionUnit u private void assertNoLedgerUuidTruth(PClient client) { for (var entry : client.getLedger().getEntriesList()) - { - assertThat(entry.getUuid(), is("")); - assertThat(entry.getProjectionRefsCount(), is(0)); + assertNoDeprecatedLedgerIdentity(entry); + } - for (var posting : entry.getPostingsList()) - assertThat(posting.getUuid(), is("")); - } + @SuppressWarnings("deprecation") + private void assertNoDeprecatedLedgerIdentity(PLedgerEntry entry) + { + assertThat(entry.getUuid(), is("")); + assertThat(entry.getProjectionRefsCount(), is(0)); + + for (var posting : entry.getPostingsList()) + assertNoDeprecatedLedgerPostingIdentity(posting); + } + + @SuppressWarnings("deprecation") + private void assertNoDeprecatedLedgerProjectionRefs(PLedgerEntry entry) + { + assertThat(entry.getProjectionRefsCount(), is(0)); + } + + @SuppressWarnings("deprecation") + private void assertNoDeprecatedLedgerPostingIdentity(PLedgerPosting posting) + { + assertThat(posting.getUuid(), is("")); } private void assertProjectionUUIDs(Client client, LedgerEntryType type, String... projectionUUIDs) diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerSaveLoadParityTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerSaveLoadParityTest.java index e492f98d26..a84e9e92cc 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerSaveLoadParityTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerSaveLoadParityTest.java @@ -1,9 +1,7 @@ package name.abuchen.portfolio.model; import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.CoreMatchers.nullValue; import static org.hamcrest.MatcherAssert.assertThat; -import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import java.io.ByteArrayInputStream; @@ -17,7 +15,6 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.util.Comparator; -import java.util.EnumSet; import java.util.List; import org.junit.Test; @@ -313,12 +310,6 @@ private List projectionUUIDs(Client client) .toList(); } - private String addEmptyLedgerParameterCollections(String xml) - { - return xml.replaceFirst("(]*>)", "$1") //$NON-NLS-1$ //$NON-NLS-2$ - .replaceFirst("(]*>)", "$1"); //$NON-NLS-1$ //$NON-NLS-2$ - } - private List materializedProjectionUUIDs(Client client) { return java.util.stream.Stream @@ -474,7 +465,7 @@ private ParityFixture parityFixture() var creator = new LedgerTransactionCreator(client); var deposit = creator.createDeposit(metadata("deposit"), LedgerAccountCashLeg.of(account, money(11))).getEntry(); - var dividend = creator.createDividend(metadata("dividend"), + creator.createDividend(metadata("dividend"), LedgerDividend.withExDate( LedgerAccountCashLeg.of(account, money(120), LedgerForexAmount.of( @@ -670,11 +661,6 @@ private void addPlan(Client client, String name, Account account, Portfolio port client.addPlan(plan); } - private InvestmentPlan plan(Client client, String name) - { - return client.getPlans().stream().filter(plan -> name.equals(plan.getName())).findFirst().orElseThrow(); - } - private InvestmentPlan plan(String name, Account account, Portfolio portfolio, Security security) { var plan = new InvestmentPlan(name); @@ -753,196 +739,11 @@ private PClient parseProtobuf(byte[] bytes) throws IOException bytes.length - PROTOBUF_SIGNATURE.length)); } - private byte[] wrapProtobuf(PClient client) throws IOException - { - var stream = new ByteArrayOutputStream(); - - stream.write(PROTOBUF_SIGNATURE); - client.writeTo(stream); - - return stream.toByteArray(); - } - private void assertValid(Client client) { assertTrue(LedgerStructuralValidator.validate(client.getLedger()).isOK()); } - - private void assertLedgerParameterOwnership(Client client, String targetSecurityUUID) - { - assertValid(client); - - var entry = client.getLedger().getEntries().stream() - .filter(candidate -> "entry-parameter-ownership".equals(candidate.getUUID())) - .findFirst().orElseThrow(); - var cashPosting = posting(entry, "posting-a"); - var feePosting = posting(entry, "posting-b"); - - assertThat(entry.getParameters().size(), is(2)); - assertEntryParameter(entry, LedgerParameterType.CORPORATE_ACTION_KIND, - LedgerParameter.ValueKind.STRING, "SPIN_OFF"); - assertEntryParameter(entry, LedgerParameterType.CASH_IN_LIEU_APPLIED, - LedgerParameter.ValueKind.BOOLEAN, Boolean.TRUE); - assertThat(entry.getParameters().stream() - .noneMatch(parameter -> parameter.getType() == LedgerParameterType.EX_DATE), is(true)); - assertThat(entry.getParameters().stream() - .noneMatch(parameter -> parameter.getType() == LedgerParameterType.TARGET_SECURITY), - is(true)); - assertThat(entry.getPostings().size(), is(2)); - assertOnlyParameter(cashPosting, LedgerParameterType.EX_DATE, - LedgerParameter.ValueKind.LOCAL_DATE_TIME, EX_DATE); - assertOnlySecurityParameter(feePosting, LedgerParameterType.TARGET_SECURITY, targetSecurityUUID); - assertThat(cashPosting.getParameters().stream() - .noneMatch(parameter -> parameter.getType() == LedgerParameterType.CORPORATE_ACTION_KIND), - is(true)); - assertThat(feePosting.getParameters().stream() - .noneMatch(parameter -> parameter.getType() == LedgerParameterType.CASH_IN_LIEU_APPLIED), - is(true)); - } - - private void assertCompactLedgerParameterXml(String xml, Money nominalValue) - { - assertTrue(xml.contains("")); - assertTrue(xml.contains("")); - assertTrue(xml.contains("")); - assertTrue(xml.contains("")); - assertTrue(xml.contains("")); - assertTrue(xml.contains("")); - assertTrue(xml.matches("(?s).*\\s*" //$NON-NLS-1$ - + "]*reference=\"[^\"]+\"[^>]*/>\\s*.*")); //$NON-NLS-1$ - assertTrue(xml.matches("(?s).*\\s*" //$NON-NLS-1$ - + "]*reference=\"[^\"]+\"[^>]*/>\\s*.*")); //$NON-NLS-1$ - assertTrue(xml.matches("(?s).*\\s*" //$NON-NLS-1$ - + "]*reference=\"[^\"]+\"[^>]*/>\\s*.*")); //$NON-NLS-1$ - assertFalse(xml.contains("")); - assertFalse(xml.contains("")); - assertFalse(xml.contains("")); - assertFalse(xml.contains("")); - assertFalse(xml.contains("")); - assertFalse(xml.contains("")); - } - - private void assertNewLedgerParameterVocabulary(Client client, LocalDate recordDate, Money nominalValue, - String rightSecurityUUID, String accountUUID, String portfolioUUID) - { - assertValid(client); - - var entry = client.getLedger().getEntries().stream() - .filter(candidate -> "entry-new-parameter-vocabulary".equals(candidate.getUUID())) - .findFirst().orElseThrow(); - var cashPosting = posting(entry, "posting-new-vocabulary-a"); - var feePosting = posting(entry, "posting-new-vocabulary-b"); - - assertThat(entry.getParameters().isEmpty(), is(true)); - assertThat(entry.getPostings().size(), is(2)); - assertParameter(cashPosting, LedgerParameterType.RECORD_DATE, LedgerParameter.ValueKind.LOCAL_DATE, - recordDate); - assertParameter(cashPosting, LedgerParameterType.EX_DATE, LedgerParameter.ValueKind.LOCAL_DATE_TIME, - EX_DATE); - assertParameter(cashPosting, LedgerParameterType.RATIO_NUMERATOR, LedgerParameter.ValueKind.DECIMAL, - new BigDecimal("1.25")); - assertParameter(cashPosting, LedgerParameterType.CASH_IN_LIEU_APPLIED, - LedgerParameter.ValueKind.BOOLEAN, Boolean.TRUE); - assertParameter(cashPosting, LedgerParameterType.NOMINAL_VALUE, LedgerParameter.ValueKind.MONEY, - nominalValue); - assertThat(cashPosting.getParameters().size(), is(5)); - - assertSecurityParameter(feePosting, LedgerParameterType.RIGHT_SECURITY, rightSecurityUUID); - assertParameter(feePosting, LedgerParameterType.CORPORATE_ACTION_KIND, - LedgerParameter.ValueKind.STRING, "SPIN_OFF"); - assertAccountParameter(feePosting, LedgerParameterType.SOURCE_ACCOUNT, accountUUID); - assertPortfolioParameter(feePosting, LedgerParameterType.SOURCE_PORTFOLIO, portfolioUUID); - assertThat(feePosting.getParameters().size(), is(4)); - } - - private LedgerPosting posting(LedgerEntry entry, String uuid) - { - return entry.getPostings().stream().filter(candidate -> uuid.equals(candidate.getUUID())).findFirst() - .orElseThrow(); - } - - private void assertOnlyParameter(LedgerPosting posting, LedgerParameterType type, - LedgerParameter.ValueKind valueKind, Object value) - { - assertThat(posting.getParameters().size(), is(1)); - - var parameter = posting.getParameters().get(0); - - assertThat(parameter.getType(), is(type)); - assertThat(parameter.getValueKind(), is(valueKind)); - assertThat(parameter.getValue(), is(value)); - } - - private void assertOnlySecurityParameter(LedgerPosting posting, LedgerParameterType type, - String securityUUID) - { - assertThat(posting.getParameters().size(), is(1)); - - var parameter = posting.getParameters().get(0); - var security = (Security) parameter.getValue(); - - assertThat(parameter.getType(), is(type)); - assertThat(parameter.getValueKind(), is(LedgerParameter.ValueKind.SECURITY)); - assertThat(security.getUUID(), is(securityUUID)); - } - - private void assertEntryParameter(LedgerEntry entry, LedgerParameterType type, - LedgerParameter.ValueKind valueKind, Object value) - { - var parameter = entry.getParameters().stream().filter(candidate -> candidate.getType() == type).findFirst() - .orElseThrow(); - - assertThat(parameter.getValueKind(), is(valueKind)); - assertThat(parameter.getValue(), is(value)); - } - - private void assertParameter(LedgerPosting posting, LedgerParameterType type, - LedgerParameter.ValueKind valueKind, Object value) - { - var parameter = posting.getParameters().stream().filter(candidate -> candidate.getType() == type).findFirst() - .orElseThrow(); - - assertThat(parameter.getValueKind(), is(valueKind)); - assertThat(parameter.getValue(), is(value)); - } - - private void assertSecurityParameter(LedgerPosting posting, LedgerParameterType type, String securityUUID) - { - var parameter = posting.getParameters().stream().filter(candidate -> candidate.getType() == type).findFirst() - .orElseThrow(); - var security = (Security) parameter.getValue(); - - assertThat(parameter.getValueKind(), is(LedgerParameter.ValueKind.SECURITY)); - assertThat(security.getUUID(), is(securityUUID)); - } - - private void assertAccountParameter(LedgerPosting posting, LedgerParameterType type, String accountUUID) - { - var parameter = posting.getParameters().stream().filter(candidate -> candidate.getType() == type).findFirst() - .orElseThrow(); - var account = (Account) parameter.getValue(); - - assertThat(parameter.getValueKind(), is(LedgerParameter.ValueKind.ACCOUNT)); - assertThat(account.getUUID(), is(accountUUID)); - } - - private void assertPortfolioParameter(LedgerPosting posting, LedgerParameterType type, String portfolioUUID) - { - var parameter = posting.getParameters().stream().filter(candidate -> candidate.getType() == type).findFirst() - .orElseThrow(); - var portfolio = (Portfolio) parameter.getValue(); - - assertThat(parameter.getValueKind(), is(LedgerParameter.ValueKind.PORTFOLIO)); - assertThat(portfolio.getUUID(), is(portfolioUUID)); - } - private String uuid(Object object) { if (object instanceof Account account) @@ -1036,7 +837,8 @@ private record ProjectionSnapshot(LedgerProjectionRole role, String accountUUID, LedgerPostingType primaryPostingType, String groupKey) { } -private record TransactionSnapshot(String ownerUUID, String transactionClass, String type, + + private record TransactionSnapshot(String ownerUUID, String transactionClass, String type, LocalDateTime dateTime, long amount, String currency, String securityUUID, long shares, String note, String source, LocalDateTime exDate, List units, String crossOwnerUUID) { diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerEditorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerEditorTest.java index 7216c81dde..de01074fa5 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerEditorTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerEditorTest.java @@ -59,7 +59,6 @@ import name.abuchen.portfolio.model.ledger.projection.LedgerBackedPortfolioTransaction; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionService; -import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionSupport; import name.abuchen.portfolio.money.CurrencyUnit; import name.abuchen.portfolio.money.Money; import name.abuchen.portfolio.money.Values; 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 index faf5cf338c..6d8537ca01 100644 --- 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 @@ -11,7 +11,6 @@ import org.junit.Test; -import name.abuchen.portfolio.model.Portfolio; import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; import name.abuchen.portfolio.model.ledger.configuration.LedgerDownstreamResult; import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinitionRegistry; diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerMutationContextTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerMutationContextTest.java index eaf676484e..30d95f5785 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerMutationContextTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerMutationContextTest.java @@ -39,7 +39,6 @@ import name.abuchen.portfolio.model.ledger.projection.LedgerBackedPortfolioTransaction; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionService; -import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionSupport; import name.abuchen.portfolio.money.CurrencyUnit; import name.abuchen.portfolio.money.Money; import name.abuchen.portfolio.money.Values; 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 index 7e09c08478..6a865a945d 100644 --- 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 @@ -10,7 +10,6 @@ import org.junit.Test; -import name.abuchen.portfolio.model.Portfolio; import name.abuchen.portfolio.model.ledger.configuration.FeeReason; import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinitionRegistry; import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerProjectionMaterializerTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerProjectionMaterializerTest.java index 08b5a56345..a7e8409b04 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerProjectionMaterializerTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerProjectionMaterializerTest.java @@ -23,12 +23,6 @@ import name.abuchen.portfolio.model.Security; import name.abuchen.portfolio.model.Transaction; import name.abuchen.portfolio.model.Transaction.Unit; -import name.abuchen.portfolio.model.ledger.configuration.CashCompensationKind; -import name.abuchen.portfolio.model.ledger.configuration.CorporateActionKind; -import name.abuchen.portfolio.model.ledger.configuration.CorporateActionSubtype; -import name.abuchen.portfolio.model.ledger.configuration.EventStage; -import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; -import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountCashLeg; import name.abuchen.portfolio.model.ledger.compatibility.LedgerCashTransferLeg; import name.abuchen.portfolio.model.ledger.compatibility.LedgerCreationUnit; @@ -42,6 +36,12 @@ import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferSecurity; import name.abuchen.portfolio.model.ledger.compatibility.LedgerSecurityQuantity; import name.abuchen.portfolio.model.ledger.compatibility.LedgerTransactionCreator; +import name.abuchen.portfolio.model.ledger.configuration.CashCompensationKind; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionKind; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionSubtype; +import name.abuchen.portfolio.model.ledger.configuration.EventStage; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; import name.abuchen.portfolio.model.ledger.nativeentry.LedgerNativeEntryAssembler; import name.abuchen.portfolio.model.ledger.nativeentry.NativeCashCompensation; import name.abuchen.portfolio.model.ledger.nativeentry.NativeCorporateActionEvent; diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffScenarioTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffScenarioTest.java index 1a51070f3c..b9731cbbca 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffScenarioTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffScenarioTest.java @@ -17,7 +17,6 @@ import java.nio.file.Path; import java.time.Instant; import java.time.LocalDateTime; -import java.util.List; import org.junit.Test; diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountOnlyTransactionCreatorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountOnlyTransactionCreatorTest.java index b120e78ba6..97a6d2912d 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountOnlyTransactionCreatorTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountOnlyTransactionCreatorTest.java @@ -25,8 +25,8 @@ import name.abuchen.portfolio.model.ProtobufTestUtilities; import name.abuchen.portfolio.model.Security; import name.abuchen.portfolio.model.Transaction.Unit; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; @@ -349,14 +349,6 @@ private Client accountOnlyClient() return client; } - private List projectionUUIDs(Client client) - { - return client.getLedger().getEntries().stream() - .flatMap(entry -> name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream()) - .map(descriptor -> descriptor.getRuntimeProjectionId()) - .toList(); - } - private List projectionRoles(Client client) { return client.getLedger().getEntries().stream() diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferToDepositRemovalConverterTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferToDepositRemovalConverterTest.java index aa07d06a71..69dcadddcf 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferToDepositRemovalConverterTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferToDepositRemovalConverterTest.java @@ -358,16 +358,6 @@ private InvestmentPlan newPlan() return plan; } - private void assertExecutionRef(InvestmentPlan plan, int index, String entryUUID, String projectionUUID, - LedgerProjectionRole role) - { - var ref = plan.getLedgerExecutionRefs().get(index); - - assertThat(ref.getLedgerEntryUUID(), is(entryUUID)); - assertThat(ref.getProjectionUUID(), is(projectionUUID)); - assertThat(ref.getProjectionRole(), is(role)); - } - private void assertResolvedPlanTransaction(Client client, InvestmentPlan plan, int index, AccountTransaction.Type type) { diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTypeToggleConverterTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTypeToggleConverterTest.java index f5f460fc61..c15c76e093 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTypeToggleConverterTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTypeToggleConverterTest.java @@ -37,7 +37,6 @@ import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; -import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionSupport; import name.abuchen.portfolio.money.CurrencyUnit; import name.abuchen.portfolio.money.Money; import name.abuchen.portfolio.money.Values; diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellDeliveryConverterTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellDeliveryConverterTest.java index b35c719b32..3236456586 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellDeliveryConverterTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellDeliveryConverterTest.java @@ -553,7 +553,6 @@ private void assertCompositeConvertsBuySellToDelivery(PortfolioTransaction.Type var cashPostingUUID = posting(entry, LedgerPostingType.CASH).getUUID(); var securityPostingUUID = posting(entry, LedgerPostingType.SECURITY).getUUID(); var accountProjectionUUID = projection(entry, LedgerProjectionRole.ACCOUNT).getRuntimeProjectionId(); - var portfolioProjectionUUID = projection(entry, LedgerProjectionRole.PORTFOLIO).getRuntimeProjectionId(); var unitPostingUUIDs = unitPostingUUIDs(entry); var converted = compositeConverter(fixture).convert(pair(fixture, portfolioTransaction)); @@ -589,7 +588,6 @@ private void assertCompositeConvertsDeliveryToBuySell(PortfolioTransaction.Type var entry = fixture.client().getLedger().getEntries().get(0); var entryUUID = entry.getUUID(); var securityPostingUUID = posting(entry, LedgerPostingType.SECURITY).getUUID(); - var portfolioProjectionUUID = projection(entry, sourceProjectionRole).getRuntimeProjectionId(); var unitPostingUUIDs = unitPostingUUIDs(entry); var converted = compositeConverter(fixture).convert(pair(fixture, delivery)); diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryTransactionCreatorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryTransactionCreatorTest.java index 8e8da1e08a..41ddbdf695 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryTransactionCreatorTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryTransactionCreatorTest.java @@ -345,14 +345,6 @@ private void assertUnitPostings(List postings) && EXCHANGE_RATE.equals(posting.getExchangeRate()))); } - private List projectionUUIDs(Client client) - { - return client.getLedger().getEntries().stream() - .flatMap(entry -> name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream()) - .map(descriptor -> descriptor.getRuntimeProjectionId()) - .toList(); - } - private List projectionRoles(Client client) { return client.getLedger().getEntries().stream() diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDividendTransactionCreatorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDividendTransactionCreatorTest.java index 1856c93e45..e7850d6827 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDividendTransactionCreatorTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDividendTransactionCreatorTest.java @@ -324,14 +324,6 @@ private LocalDateTime exDate(LedgerPosting posting) .orElse(null); } - private List projectionUUIDs(Client client) - { - return client.getLedger().getEntries().stream() - .flatMap(entry -> name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream()) - .map(descriptor -> descriptor.getRuntimeProjectionId()) - .toList(); - } - private List projectionRoles(Client client) { return client.getLedger().getEntries().stream() diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigratorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigratorTest.java index bd8d4f72e4..a5dc8854f3 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigratorTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigratorTest.java @@ -5,10 +5,8 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; -import java.lang.reflect.InvocationTargetException; import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.ArrayList; @@ -1106,108 +1104,6 @@ private void assertDividendDuplicateConflict(LedgerEntryType existingEntryType, assertFalse(account.getTransactions().stream().anyMatch(LedgerBackedTransaction.class::isInstance)); } - private void assertBuySellDuplicateConflict(Consumer mutator, String expectedMismatch) - { - assertBuySellDuplicateConflict((existing, account, portfolio, otherAccount, otherPortfolio) -> mutator.accept( - existing), expectedMismatch); - } - - private void assertBuySellDuplicateConflict(BuySellDuplicateMutator mutator, String expectedMismatch) - { - var client = new Client(); - var account = register(client, account()); - var portfolio = register(client, portfolio()); - var otherAccount = register(client, account()); - var otherPortfolio = register(client, portfolio()); - var entry = buySellEntry(portfolio, account, PortfolioTransaction.Type.BUY); - var existing = existingBuySellEntry(account, portfolio, entry.getAccountTransaction().getUUID(), - entry.getPortfolioTransaction().getUUID()); - - entry.insert(); - mutator.accept(existing, account, portfolio, otherAccount, otherPortfolio); - client.getLedger().addEntry(existing); - - var result = migrate(client); - - assertDuplicateConflictWithMismatch(result, "BUY_SELL", expectedMismatch, entry.getAccountTransaction().getUUID(), - entry.getPortfolioTransaction().getUUID()); - assertThat(client.getLedger().getEntries().size(), is(1)); - assertSame(entry.getAccountTransaction(), account.getTransactions().get(0)); - assertSame(entry.getPortfolioTransaction(), portfolio.getTransactions().get(0)); - assertFalse(account.getTransactions().stream().anyMatch(LedgerBackedTransaction.class::isInstance)); - assertFalse(portfolio.getTransactions().stream().anyMatch(LedgerBackedTransaction.class::isInstance)); - } - - private void assertAccountTransferDuplicateConflict(Consumer mutator, String expectedMismatch) - { - assertAccountTransferDuplicateConflict((existing, source, target, other) -> mutator.accept(existing), - expectedMismatch); - } - - private void assertAccountTransferDuplicateConflict(AccountTransferDuplicateMutator mutator, - String expectedMismatch) - { - var client = new Client(); - var source = register(client, account()); - var target = register(client, account()); - var other = register(client, account()); - var transfer = new AccountTransferEntry(source, target); - transfer.setDate(DATE_TIME); - transfer.setAmount(Values.Amount.factorize(55)); - transfer.setCurrencyCode(CurrencyUnit.EUR); - transfer.insert(); - var existing = existingAccountTransferEntry(source, target, transfer.getSourceTransaction().getUUID(), - transfer.getTargetTransaction().getUUID()); - - mutator.accept(existing, source, target, other); - client.getLedger().addEntry(existing); - - var result = migrate(client); - - assertDuplicateConflictWithMismatch(result, "ACCOUNT_TRANSFER", expectedMismatch, - transfer.getSourceTransaction().getUUID(), - transfer.getTargetTransaction().getUUID()); - assertThat(client.getLedger().getEntries().size(), is(1)); - assertSame(transfer.getSourceTransaction(), source.getTransactions().get(0)); - assertSame(transfer.getTargetTransaction(), target.getTransactions().get(0)); - } - - private void assertPortfolioTransferDuplicateConflict(Consumer mutator, String expectedMismatch) - { - assertPortfolioTransferDuplicateConflict((existing, source, target, other) -> mutator.accept(existing), - expectedMismatch); - } - - private void assertPortfolioTransferDuplicateConflict(PortfolioTransferDuplicateMutator mutator, - String expectedMismatch) - { - var client = new Client(); - var source = register(client, portfolio()); - var target = register(client, portfolio()); - var other = register(client, portfolio()); - var transfer = new name.abuchen.portfolio.model.PortfolioTransferEntry(source, target); - transfer.setDate(DATE_TIME); - transfer.setSecurity(security()); - transfer.setShares(Values.Share.factorize(7)); - transfer.setAmount(Values.Amount.factorize(400)); - transfer.setCurrencyCode(CurrencyUnit.EUR); - transfer.insert(); - var existing = existingPortfolioTransferEntry(source, target, transfer.getSourceTransaction().getUUID(), - transfer.getTargetTransaction().getUUID()); - - mutator.accept(existing, source, target, other); - client.getLedger().addEntry(existing); - - var result = migrate(client); - - assertDuplicateConflictWithMismatch(result, "PORTFOLIO_TRANSFER", expectedMismatch, - transfer.getSourceTransaction().getUUID(), - transfer.getTargetTransaction().getUUID()); - assertThat(client.getLedger().getEntries().size(), is(1)); - assertSame(transfer.getSourceTransaction(), source.getTransactions().get(0)); - assertSame(transfer.getTargetTransaction(), target.getTransactions().get(0)); - } - private void assertDeliveryDuplicateConflict(LedgerEntryType existingEntryType, boolean wrongOwner, String expectedMismatch) { @@ -1417,69 +1313,6 @@ private LedgerEntry existingBuySellEntry(Account account, Portfolio portfolio, S return entry; } - private LedgerEntry existingAccountTransferEntry(Account source, Account target, String sourceProjectionUUID, - String targetProjectionUUID) - { - var entry = new LedgerEntry(); - var sourcePosting = new LedgerPosting(); - var targetPosting = new LedgerPosting(); - - entry.setType(LedgerEntryType.CASH_TRANSFER); - entry.setDateTime(DATE_TIME); - sourcePosting.setType(LedgerPostingType.CASH); - sourcePosting.setAccount(source); - sourcePosting.setAmount(Values.Amount.factorize(55)); - sourcePosting.setCurrency(CurrencyUnit.EUR); - sourcePosting.setSemanticRole(LedgerPostingSemanticRole.CASH); - sourcePosting.setDirection(LedgerPostingDirection.OUTBOUND); - sourcePosting.setUnitRole(LedgerPostingUnitRole.PRIMARY); - targetPosting.setType(LedgerPostingType.CASH); - targetPosting.setAccount(target); - targetPosting.setAmount(Values.Amount.factorize(55)); - targetPosting.setCurrency(CurrencyUnit.EUR); - targetPosting.setSemanticRole(LedgerPostingSemanticRole.CASH); - targetPosting.setDirection(LedgerPostingDirection.INBOUND); - targetPosting.setUnitRole(LedgerPostingUnitRole.PRIMARY); - entry.addPosting(sourcePosting); - entry.addPosting(targetPosting); - - return entry; - } - - private LedgerEntry existingPortfolioTransferEntry(Portfolio source, Portfolio target, String sourceProjectionUUID, - String targetProjectionUUID) - { - var entry = new LedgerEntry(); - var sourcePosting = new LedgerPosting(); - var targetPosting = new LedgerPosting(); - var security = security(); - - entry.setType(LedgerEntryType.SECURITY_TRANSFER); - entry.setDateTime(DATE_TIME); - sourcePosting.setType(LedgerPostingType.SECURITY); - sourcePosting.setPortfolio(source); - sourcePosting.setAmount(Values.Amount.factorize(400)); - sourcePosting.setCurrency(CurrencyUnit.EUR); - sourcePosting.setSecurity(security); - sourcePosting.setShares(Values.Share.factorize(7)); - sourcePosting.setSemanticRole(LedgerPostingSemanticRole.SECURITY); - sourcePosting.setDirection(LedgerPostingDirection.OUTBOUND); - sourcePosting.setUnitRole(LedgerPostingUnitRole.PRIMARY); - targetPosting.setType(LedgerPostingType.SECURITY); - targetPosting.setPortfolio(target); - targetPosting.setAmount(Values.Amount.factorize(400)); - targetPosting.setCurrency(CurrencyUnit.EUR); - targetPosting.setSecurity(security); - targetPosting.setShares(Values.Share.factorize(7)); - targetPosting.setSemanticRole(LedgerPostingSemanticRole.SECURITY); - targetPosting.setDirection(LedgerPostingDirection.INBOUND); - targetPosting.setUnitRole(LedgerPostingUnitRole.PRIMARY); - entry.addPosting(sourcePosting); - entry.addPosting(targetPosting); - - return entry; - } - private LedgerEntry existingDeliveryEntry(LedgerEntryType entryType, Portfolio portfolio, String projectionUUID, LedgerProjectionRole role) { @@ -1531,25 +1364,6 @@ private LedgerPosting grossValuePosting(int amount, String forexCurrency, int fo return posting; } - @FunctionalInterface - private interface BuySellDuplicateMutator - { - void accept(LedgerEntry entry, Account account, Portfolio portfolio, Account otherAccount, - Portfolio otherPortfolio); - } - - @FunctionalInterface - private interface AccountTransferDuplicateMutator - { - void accept(LedgerEntry entry, Account source, Account target, Account other); - } - - @FunctionalInterface - private interface PortfolioTransferDuplicateMutator - { - void accept(LedgerEntry entry, Portfolio source, Portfolio target, Portfolio other); - } - private LegacyTransactionToLedgerMigrator.MigrationResult migrate(Client client) { return new LegacyTransactionToLedgerMigrator().migrate(client); diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblerTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblerTest.java index 54055856a4..8d3b0457e7 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblerTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblerTest.java @@ -31,8 +31,8 @@ import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; import name.abuchen.portfolio.model.ledger.configuration.CashCompensationKind; -import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; 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.EventStage; import name.abuchen.portfolio.model.ledger.configuration.FeeReason; diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerRuntimeProjectionRestorerTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerRuntimeProjectionRestorerTest.java index 33e4ab7b2c..04591d1f58 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerRuntimeProjectionRestorerTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerRuntimeProjectionRestorerTest.java @@ -26,10 +26,10 @@ import name.abuchen.portfolio.model.Security; import name.abuchen.portfolio.model.Transaction; import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerPosting; import name.abuchen.portfolio.model.ledger.LedgerPostingDirection; import name.abuchen.portfolio.model.ledger.LedgerPostingSemanticRole; import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; -import name.abuchen.portfolio.model.ledger.LedgerPosting; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; import name.abuchen.portfolio.model.ledger.LedgerTransactionMetadata; diff --git a/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/AccountTransactionModelTest.java b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/AccountTransactionModelTest.java index 6e6a66a4f2..6fc6870453 100644 --- a/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/AccountTransactionModelTest.java +++ b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/AccountTransactionModelTest.java @@ -65,7 +65,6 @@ public void testExistingLedgerBackedAccountOnlyTransactionEditsThroughLedger() createModel.applyChanges(); var transaction = account.getTransactions().get(0); - var transactionUUID = transaction.getUUID(); var editModel = new AccountTransactionModel(client, AccountTransaction.Type.DEPOSIT); editModel.setSource(account, transaction); editModel.setTotal(Values.Amount.factorize(456)); diff --git a/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/views/LedgerTransactionRoutingTest.java b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/views/LedgerTransactionRoutingTest.java index 13df13455b..83ffe74ad3 100644 --- a/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/views/LedgerTransactionRoutingTest.java +++ b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/views/LedgerTransactionRoutingTest.java @@ -1536,14 +1536,6 @@ private InvestmentPlan addInvestmentPlanRef(Client client, Transaction transacti return plan; } - private void setField(Object object, String name, Object value) throws Exception - { - Field field = object.getClass().getDeclaredField(name); - field.setAccessible(true); - field.set(object, value); - } - - private void assertLedgerStructurallyValid(Client client) throws Exception { var ledger = Client.class.getMethod("getLedger").invoke(client); diff --git a/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/views/actions/AccountTransferLegacyActionGuardTest.java b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/views/actions/AccountTransferLegacyActionGuardTest.java index c964044c29..ab156c1420 100644 --- a/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/views/actions/AccountTransferLegacyActionGuardTest.java +++ b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/views/actions/AccountTransferLegacyActionGuardTest.java @@ -55,7 +55,6 @@ public void testConvertTransferToDepositRemovalSplitsLedgerBackedTransferThrough { var fixture = ledgerFixture(); var sourceTransaction = fixture.source().getTransactions().get(0); - var targetTransaction = fixture.target().getTransactions().get(0); new ConvertTransferToDepositRemovalAction(fixture.client(), List.of(sourceTransaction)).run(); @@ -144,7 +143,6 @@ public void testRevertTransferActionReversesLedgerBackedTransferThroughLedgerTru { var fixture = ledgerFixture(); var sourceTransaction = fixture.source().getTransactions().get(0); - var targetTransaction = fixture.target().getTransactions().get(0); new RevertTransferAction(fixture.client(), new TransactionPair<>(fixture.source(), sourceTransaction)).run(); @@ -226,7 +224,6 @@ public void testRevertTransferActionReversesLedgerBackedPortfolioTransferThrough { var fixture = ledgerPortfolioFixture(); var sourceTransaction = fixture.source().getTransactions().get(0); - var targetTransaction = fixture.target().getTransactions().get(0); new RevertTransferAction(fixture.client(), new TransactionPair<>(fixture.source(), sourceTransaction)).run(); diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/NegativeExchangeRateCheck.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/NegativeExchangeRateCheck.java index 8e2c8cb6dd..402b3b6910 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/NegativeExchangeRateCheck.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/NegativeExchangeRateCheck.java @@ -3,7 +3,8 @@ import java.text.MessageFormat; import java.time.LocalDate; import java.util.ArrayList; -import java.util.HashSet; +import java.util.Collections; +import java.util.IdentityHashMap; import java.util.List; import java.util.Optional; import java.util.Set; @@ -84,7 +85,7 @@ public List getAvailableFixes() public List execute(Client client) { List issues = new ArrayList<>(); - Set reportedLedgerEntries = new HashSet<>(); + Set reportedLedgerEntries = Collections.newSetFromMap(new IdentityHashMap<>()); for (Account account : client.getAccounts()) { @@ -92,7 +93,7 @@ public List execute(Client client) { if (t instanceof LedgerBackedTransaction ledgerBacked) { - if (reportedLedgerEntries.add(ledgerBacked.getLedgerEntry().getUUID())) + if (reportedLedgerEntries.add(ledgerBacked.getLedgerEntry())) addLedgerBackedIssue(client, issues, new TransactionPair<>(account, t), ledgerBacked.getLedgerEntry()); continue; @@ -124,7 +125,7 @@ public List execute(Client client) { if (t instanceof LedgerBackedTransaction ledgerBacked) { - if (reportedLedgerEntries.add(ledgerBacked.getLedgerEntry().getUUID())) + if (reportedLedgerEntries.add(ledgerBacked.getLedgerEntry())) addLedgerBackedIssue(client, issues, new TransactionPair<>(portfolio, t), ledgerBacked.getLedgerEntry()); continue; diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/OnlyAccountTransactionsWithSecurityCanHaveExDateCheck.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/OnlyAccountTransactionsWithSecurityCanHaveExDateCheck.java index 857d06254c..79caaec414 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/OnlyAccountTransactionsWithSecurityCanHaveExDateCheck.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/checks/impl/OnlyAccountTransactionsWithSecurityCanHaveExDateCheck.java @@ -8,6 +8,7 @@ import name.abuchen.portfolio.checks.Issue; import name.abuchen.portfolio.model.Client; import name.abuchen.portfolio.model.Transaction; +import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerPosting; import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedAccountTransaction; @@ -47,7 +48,7 @@ private void clearLedgerBackedExDate(Client client, LedgerBackedAccountTransacti return; transaction.getLedgerEntry().setUpdatedAt(Instant.now()); - refreshLedgerEntryProjections(client, transaction.getLedgerEntry().getUUID()); + refreshLedgerEntryProjections(client, transaction.getLedgerEntry()); } private boolean removeExDateParameters(LedgerPosting posting) @@ -61,19 +62,19 @@ private boolean removeExDateParameters(LedgerPosting posting) return !parameters.isEmpty(); } - private void refreshLedgerEntryProjections(Client client, String entryUUID) + private void refreshLedgerEntryProjections(Client client, LedgerEntry entry) { client.getAccounts().forEach(owner -> owner.getTransactions() - .removeIf(transaction -> isProjectionOfEntry(transaction, entryUUID))); + .removeIf(transaction -> isProjectionOfEntry(transaction, entry))); client.getPortfolios().forEach(owner -> owner.getTransactions() - .removeIf(transaction -> isProjectionOfEntry(transaction, entryUUID))); + .removeIf(transaction -> isProjectionOfEntry(transaction, entry))); LedgerProjectionService.materialize(client); } - private boolean isProjectionOfEntry(Transaction transaction, String entryUUID) + private boolean isProjectionOfEntry(Transaction transaction, LedgerEntry entry) { return transaction instanceof LedgerBackedTransaction ledgerBacked - && ledgerBacked.getLedgerEntry().getUUID().equals(entryUUID); + && ledgerBacked.getLedgerEntry() == entry; } } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ClientFactory.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ClientFactory.java index fb9f516eea..5ee66ec649 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ClientFactory.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ClientFactory.java @@ -715,7 +715,7 @@ private static void writeFile(final Client client, final File file, char[] passw { // On OS X fcntl does not support locking files on AFP or SMB // https://bugs.openjdk.org/browse/JDK-8167023 - if (!Platform.getOS().equals(Platform.OS_MACOSX)) + if (!Objects.equals(Platform.getOS(), Platform.OS_MACOSX)) lock = channel.tryLock(); } catch (IOException e) diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerXmlPersistenceSupport.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerXmlPersistenceSupport.java index 1771c1a26d..8fbe740f78 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerXmlPersistenceSupport.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerXmlPersistenceSupport.java @@ -262,8 +262,8 @@ public Object unmarshal(HierarchicalStreamReader reader, UnmarshallingContext co case "source" -> entry.setSource(reader.getValue()); //$NON-NLS-1$ case "generatedByPlanKey" -> entry.setGeneratedByPlanKey(reader.getValue()); //$NON-NLS-1$ case "planExecutionDate" -> entry.setPlanExecutionDate(LocalDate.parse(reader.getValue())); //$NON-NLS-1$ - case "planExecutionSequence" -> entry.setPlanExecutionSequence( - Integer.valueOf(reader.getValue())); //$NON-NLS-1$ + case "planExecutionSequence" -> entry.setPlanExecutionSequence( //$NON-NLS-1$ + Integer.valueOf(reader.getValue())); case "preferredViewKind" -> entry.setPreferredViewKind(reader.getValue()); //$NON-NLS-1$ case "parameters" -> readParameters(reader, context, entry); //$NON-NLS-1$ case "postings" -> readPostings(reader, context, entry); //$NON-NLS-1$ 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 index 8cb6d47baf..fc09f1c7d9 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerEntryEditSupport.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerEntryEditSupport.java @@ -31,6 +31,24 @@ public static void validatePatch(LedgerEntry entry, EntryPatch patch) 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() // 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 index f45588f3be..178a0e6bd4 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerStructuralValidator.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerStructuralValidator.java @@ -7,7 +7,6 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.Set; import name.abuchen.portfolio.Messages; import name.abuchen.portfolio.model.Account; @@ -284,7 +283,7 @@ private static void requireCorporatePrimary(LedgerEntry entry, LedgerProjectionR .findFirst() .ifPresent(posting -> issues.add(postingIssue(IssueCode.SEMANTIC_LOCAL_KEY_REQUIRED, LedgerDiagnosticCode.LEDGER_STRUCT_027.message( - "Repeated corporate-action leg requires a local key for " + "Repeated corporate-action leg requires a local key for " //$NON-NLS-1$ + role), entry, posting).withDetail("projectionRole", role))); //$NON-NLS-1$ } @@ -313,25 +312,11 @@ private static void requireCorporatePrimary(LedgerEntry entry, LedgerProjectionR .findFirst() .ifPresent(posting -> issues.add(postingIssue(IssueCode.SEMANTIC_LOCAL_KEY_REQUIRED, LedgerDiagnosticCode.LEDGER_STRUCT_027.message( - "Repeated corporate-action leg requires a local key for " + "Repeated corporate-action leg requires a local key for " //$NON-NLS-1$ + role), entry, posting).withDetail("projectionRole", role))); //$NON-NLS-1$ } - private static void requireOneOfCorporatePrimary(LedgerEntry entry, LedgerProjectionRole role, - LedgerPostingSemanticRole semanticRole, LedgerPostingDirection direction, boolean optional, - List issues, CorporateActionLeg... legs) - { - var matches = entry.getPostings().stream() // - .filter(posting -> posting.getUnitRole() == LedgerPostingUnitRole.PRIMARY) // - .filter(posting -> posting.getSemanticRole() == semanticRole) // - .filter(posting -> posting.getDirection() == direction) // - .filter(posting -> contains(legs, posting.getCorporateActionLeg())) // - .toList(); - - validatePrimaryMatches(entry, role, OwnerKind.PORTFOLIO, optional, matches, issues); - } - private static void requireOneOfCorporatePrimary(LedgerEntry entry, LedgerProjectionRole role, LedgerPostingDirection direction, boolean optional, List issues, CorporateActionLeg... legs) @@ -364,7 +349,7 @@ private static void validatePrimaryMatches(LedgerEntry entry, LedgerProjectionRo if (!optional) issues.add(entryIssue(missingIssueCode(role), LedgerDiagnosticCode.LEDGER_STRUCT_016 - .message("Required semantic primary posting is missing for " + role), + .message("Required semantic primary posting is missing for " + role), //$NON-NLS-1$ entry).withDetail("projectionRole", role)); //$NON-NLS-1$ return; } @@ -372,7 +357,7 @@ private static void validatePrimaryMatches(LedgerEntry entry, LedgerProjectionRo if (matches.size() > 1) issues.add(entryIssue(ambiguousIssueCode(role), LedgerDiagnosticCode.LEDGER_STRUCT_017 - .message("Semantic primary posting is ambiguous for " + role), + .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$ @@ -421,7 +406,7 @@ private static void validateOwner(LedgerEntry entry, LedgerProjectionRole role, if (!ownerPresent) issues.add(postingIssue(IssueCode.SEMANTIC_OWNER_REQUIRED, LedgerDiagnosticCode.LEDGER_STRUCT_018 - .message("Semantic primary owner is missing for " + role), + .message("Semantic primary owner is missing for " + role), //$NON-NLS-1$ entry, posting).withDetail("projectionRole", role)); //$NON-NLS-1$ } @@ -445,14 +430,14 @@ private static void validateUnitGrouping(LedgerEntry entry, List 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"), + .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"), + .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$ @@ -469,7 +454,7 @@ private static void validateUnitGrouping(LedgerEntry entry, List applyEdit(editedEntry, edit, role, postingUUID)); + LedgerEntryEditSupport.applyValidated(entry, editedEntry -> applyEdit(editedEntry, edit, role, postingIndex)); } public void validate(LedgerBackedAccountTransaction transaction, LedgerAccountTransactionEdit edit) @@ -58,18 +59,20 @@ public void validate(LedgerBackedAccountTransaction transaction, LedgerAccountTr .message("Unsupported account transaction edit for " + entry.getType())); //$NON-NLS-1$ var role = transaction.getLedgerProjectionRole(); - var postingUUID = transaction.getLedgerProjectionDescriptor().getPrimaryPosting().getUUID(); + var postingIndex = LedgerEntryEditSupport.postingIndex(entry, + transaction.getLedgerProjectionDescriptor().getPrimaryPosting()); - LedgerEntryEditSupport.validatePatch(entry, editedEntry -> applyEdit(editedEntry, edit, role, postingUUID)); + LedgerEntryEditSupport.validatePatch(entry, editedEntry -> applyEdit(editedEntry, edit, role, postingIndex)); } private void applyEdit(LedgerEntry editedEntry, LedgerAccountTransactionEdit edit, name.abuchen.portfolio.model.ledger.LedgerProjectionRole role, - String postingUUID) + int postingIndex) { LedgerEntryMetadataPatchHelper.apply(editedEntry, edit.getMetadata()); - edit.getPosting().applyTo(LedgerEntryEditSupport.postingByUUID(editedEntry, postingUUID)); - applyExDate(LedgerEntryEditSupport.postingByUUID(editedEntry, postingUUID), edit.getExDate()); + var posting = LedgerEntryEditSupport.postingAt(editedEntry, postingIndex); + edit.getPosting().applyTo(posting); + applyExDate(posting, edit.getExDate()); unitPostingUpdater.apply(editedEntry, edit.getUnits()); ensureDescriptorExists(editedEntry, role); } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferEditor.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferEditor.java index 61635c3309..c186bded4b 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferEditor.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferEditor.java @@ -36,13 +36,13 @@ public void apply(LedgerEntry entry, LedgerAccountTransferEdit edit) var sourceProjection = LedgerProjectionSupport.descriptor(entry, LedgerProjectionRole.SOURCE_ACCOUNT); var targetProjection = LedgerProjectionSupport.descriptor(entry, LedgerProjectionRole.TARGET_ACCOUNT); - var sourcePostingUUID = sourceProjection.getPrimaryPosting().getUUID(); - var targetPostingUUID = targetProjection.getPrimaryPosting().getUUID(); + var sourcePostingIndex = LedgerEntryEditSupport.postingIndex(entry, sourceProjection.getPrimaryPosting()); + var targetPostingIndex = LedgerEntryEditSupport.postingIndex(entry, targetProjection.getPrimaryPosting()); var sourceAccount = sourceProjection.getAccount(); var targetAccount = targetProjection.getAccount(); LedgerEntryEditSupport.applyValidated(entry, editedEntry -> applyEdit(editedEntry, edit, - sourceAccount, targetAccount, sourcePostingUUID, targetPostingUUID)); + sourceAccount, targetAccount, sourcePostingIndex, targetPostingIndex)); } public void validate(LedgerEntry entry, LedgerAccountTransferEdit edit) @@ -56,23 +56,23 @@ public void validate(LedgerEntry entry, LedgerAccountTransferEdit edit) var sourceProjection = LedgerProjectionSupport.descriptor(entry, LedgerProjectionRole.SOURCE_ACCOUNT); var targetProjection = LedgerProjectionSupport.descriptor(entry, LedgerProjectionRole.TARGET_ACCOUNT); - var sourcePostingUUID = sourceProjection.getPrimaryPosting().getUUID(); - var targetPostingUUID = targetProjection.getPrimaryPosting().getUUID(); + var sourcePostingIndex = LedgerEntryEditSupport.postingIndex(entry, sourceProjection.getPrimaryPosting()); + var targetPostingIndex = LedgerEntryEditSupport.postingIndex(entry, targetProjection.getPrimaryPosting()); var sourceAccount = sourceProjection.getAccount(); var targetAccount = targetProjection.getAccount(); LedgerEntryEditSupport.validatePatch(entry, editedEntry -> applyEdit(editedEntry, edit, - sourceAccount, targetAccount, sourcePostingUUID, targetPostingUUID)); + sourceAccount, targetAccount, sourcePostingIndex, targetPostingIndex)); } private void applyEdit(LedgerEntry editedEntry, LedgerAccountTransferEdit edit, name.abuchen.portfolio.model.Account sourceAccount, name.abuchen.portfolio.model.Account targetAccount, - String sourcePostingUUID, String targetPostingUUID) + int sourcePostingIndex, int targetPostingIndex) { LedgerEntryMetadataPatchHelper.apply(editedEntry, edit.getMetadata()); - edit.getSourcePosting().applyTo(LedgerEntryEditSupport.postingByUUID(editedEntry, sourcePostingUUID)); - edit.getTargetPosting().applyTo(LedgerEntryEditSupport.postingByUUID(editedEntry, targetPostingUUID)); + edit.getSourcePosting().applyTo(LedgerEntryEditSupport.postingAt(editedEntry, sourcePostingIndex)); + edit.getTargetPosting().applyTo(LedgerEntryEditSupport.postingAt(editedEntry, targetPostingIndex)); unitPostingUpdater.apply(editedEntry, edit.getUnits()); ensureOwners(editedEntry, sourceAccount, targetAccount); } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferToDepositRemovalConverter.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferToDepositRemovalConverter.java index c302083541..cfcaec5912 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferToDepositRemovalConverter.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferToDepositRemovalConverter.java @@ -4,12 +4,12 @@ import java.util.List; import java.util.Objects; -import name.abuchen.portfolio.model.LedgerDiagnosticCode; import name.abuchen.portfolio.model.Account; import name.abuchen.portfolio.model.AccountTransaction; import name.abuchen.portfolio.model.AccountTransferEntry; import name.abuchen.portfolio.model.Client; import name.abuchen.portfolio.model.LedgerAccountTransferToDepositRemovalConverter.SplitResult; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerMutationContext; import name.abuchen.portfolio.model.ledger.LedgerPosting; diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferTransactionCreator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferTransactionCreator.java index 3dc4f6abc8..376b0308bb 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferTransactionCreator.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferTransactionCreator.java @@ -5,11 +5,11 @@ import java.util.Objects; import java.util.Optional; -import name.abuchen.portfolio.model.LedgerDiagnosticCode; import name.abuchen.portfolio.model.Account; import name.abuchen.portfolio.model.AccountTransaction; import name.abuchen.portfolio.model.AccountTransferEntry; import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; import name.abuchen.portfolio.model.ledger.LedgerEntryMetadataPatch; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.LedgerTransactionMetadata; diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTypeToggleConverter.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTypeToggleConverter.java index d1d122fef2..c434f1f1df 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTypeToggleConverter.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTypeToggleConverter.java @@ -2,10 +2,10 @@ import java.util.Objects; -import name.abuchen.portfolio.model.LedgerDiagnosticCode; import name.abuchen.portfolio.model.Account; import name.abuchen.portfolio.model.AccountTransaction; import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; import name.abuchen.portfolio.model.TransactionPair; import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerMutationContext; diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellEditor.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellEditor.java index 73363ca4a1..cca9c7cf31 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellEditor.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellEditor.java @@ -42,12 +42,12 @@ public void apply(LedgerEntry entry, LedgerBuySellEdit edit) var accountProjection = LedgerProjectionSupport.descriptor(entry, LedgerProjectionRole.ACCOUNT); var portfolioProjection = LedgerProjectionSupport.descriptor(entry, LedgerProjectionRole.PORTFOLIO); - var cashPostingUUID = accountProjection.getPrimaryPosting().getUUID(); - var securityPostingUUID = portfolioProjection.getPrimaryPosting().getUUID(); + var cashPostingIndex = LedgerEntryEditSupport.postingIndex(entry, accountProjection.getPrimaryPosting()); + var securityPostingIndex = LedgerEntryEditSupport.postingIndex(entry, portfolioProjection.getPrimaryPosting()); LedgerEntryEditSupport.applyValidated(entry, editedEntry -> applyEdit(editedEntry, edit, - LedgerProjectionRole.ACCOUNT, LedgerProjectionRole.PORTFOLIO, cashPostingUUID, - securityPostingUUID)); + LedgerProjectionRole.ACCOUNT, LedgerProjectionRole.PORTFOLIO, cashPostingIndex, + securityPostingIndex)); } public void validate(LedgerEntry entry, LedgerBuySellEdit edit) @@ -61,20 +61,20 @@ public void validate(LedgerEntry entry, LedgerBuySellEdit edit) var accountProjection = LedgerProjectionSupport.descriptor(entry, LedgerProjectionRole.ACCOUNT); var portfolioProjection = LedgerProjectionSupport.descriptor(entry, LedgerProjectionRole.PORTFOLIO); - var cashPostingUUID = accountProjection.getPrimaryPosting().getUUID(); - var securityPostingUUID = portfolioProjection.getPrimaryPosting().getUUID(); + var cashPostingIndex = LedgerEntryEditSupport.postingIndex(entry, accountProjection.getPrimaryPosting()); + var securityPostingIndex = LedgerEntryEditSupport.postingIndex(entry, portfolioProjection.getPrimaryPosting()); LedgerEntryEditSupport.validatePatch(entry, editedEntry -> applyEdit(editedEntry, edit, - LedgerProjectionRole.ACCOUNT, LedgerProjectionRole.PORTFOLIO, cashPostingUUID, - securityPostingUUID)); + LedgerProjectionRole.ACCOUNT, LedgerProjectionRole.PORTFOLIO, cashPostingIndex, + securityPostingIndex)); } private void applyEdit(LedgerEntry editedEntry, LedgerBuySellEdit edit, LedgerProjectionRole accountRole, - LedgerProjectionRole portfolioRole, String cashPostingUUID, String securityPostingUUID) + LedgerProjectionRole portfolioRole, int cashPostingIndex, int securityPostingIndex) { LedgerEntryMetadataPatchHelper.apply(editedEntry, edit.getMetadata()); - edit.getCashPosting().applyTo(LedgerEntryEditSupport.postingByUUID(editedEntry, cashPostingUUID)); - edit.getSecurityPosting().applyTo(LedgerEntryEditSupport.postingByUUID(editedEntry, securityPostingUUID)); + edit.getCashPosting().applyTo(LedgerEntryEditSupport.postingAt(editedEntry, cashPostingIndex)); + edit.getSecurityPosting().applyTo(LedgerEntryEditSupport.postingAt(editedEntry, securityPostingIndex)); unitPostingUpdater.applyDirect(editedEntry, edit.getUnits()); LedgerProjectionSupport.descriptor(editedEntry, accountRole); LedgerProjectionSupport.descriptor(editedEntry, portfolioRole); diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellTransactionCreator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellTransactionCreator.java index 9426307583..bac9c67152 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellTransactionCreator.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellTransactionCreator.java @@ -4,11 +4,11 @@ import java.util.List; import java.util.Objects; -import name.abuchen.portfolio.model.LedgerDiagnosticCode; import name.abuchen.portfolio.model.Account; import name.abuchen.portfolio.model.AccountTransaction; import name.abuchen.portfolio.model.BuySellEntry; import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; import name.abuchen.portfolio.model.Portfolio; import name.abuchen.portfolio.model.PortfolioTransaction; import name.abuchen.portfolio.model.Security; diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryTransactionCreator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryTransactionCreator.java index 3559975452..3636e4d2fa 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryTransactionCreator.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryTransactionCreator.java @@ -5,8 +5,8 @@ import java.util.List; import java.util.Objects; -import name.abuchen.portfolio.model.LedgerDiagnosticCode; import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; import name.abuchen.portfolio.model.Portfolio; import name.abuchen.portfolio.model.PortfolioTransaction; import name.abuchen.portfolio.model.Security; diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryTransactionEditor.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryTransactionEditor.java index bd1a33b4b2..c2380fe43d 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryTransactionEditor.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryTransactionEditor.java @@ -31,9 +31,10 @@ public void apply(LedgerBackedPortfolioTransaction transaction, LedgerDeliveryTr .message("Unsupported delivery edit for " + entry.getType())); //$NON-NLS-1$ var role = transaction.getLedgerProjectionRole(); - var postingUUID = transaction.getLedgerProjectionDescriptor().getPrimaryPosting().getUUID(); + var postingIndex = LedgerEntryEditSupport.postingIndex(entry, + transaction.getLedgerProjectionDescriptor().getPrimaryPosting()); - LedgerEntryEditSupport.applyValidated(entry, editedEntry -> applyEdit(editedEntry, edit, role, postingUUID)); + LedgerEntryEditSupport.applyValidated(entry, editedEntry -> applyEdit(editedEntry, edit, role, postingIndex)); } public void validate(LedgerBackedPortfolioTransaction transaction, LedgerDeliveryTransactionEdit edit) @@ -48,17 +49,18 @@ public void validate(LedgerBackedPortfolioTransaction transaction, LedgerDeliver .message("Unsupported delivery edit for " + entry.getType())); //$NON-NLS-1$ var role = transaction.getLedgerProjectionRole(); - var postingUUID = transaction.getLedgerProjectionDescriptor().getPrimaryPosting().getUUID(); + var postingIndex = LedgerEntryEditSupport.postingIndex(entry, + transaction.getLedgerProjectionDescriptor().getPrimaryPosting()); - LedgerEntryEditSupport.validatePatch(entry, editedEntry -> applyEdit(editedEntry, edit, role, postingUUID)); + LedgerEntryEditSupport.validatePatch(entry, editedEntry -> applyEdit(editedEntry, edit, role, postingIndex)); } private void applyEdit(LedgerEntry editedEntry, LedgerDeliveryTransactionEdit edit, name.abuchen.portfolio.model.ledger.LedgerProjectionRole role, - String postingUUID) + int postingIndex) { LedgerEntryMetadataPatchHelper.apply(editedEntry, edit.getMetadata()); - edit.getPosting().applyTo(LedgerEntryEditSupport.postingByUUID(editedEntry, postingUUID)); + edit.getPosting().applyTo(LedgerEntryEditSupport.postingAt(editedEntry, postingIndex)); unitPostingUpdater.applyDirect(editedEntry, edit.getUnits()); LedgerProjectionSupport.descriptor(editedEntry, role); } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDividendTransactionCreator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDividendTransactionCreator.java index c4d375c79c..342ecc6861 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDividendTransactionCreator.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDividendTransactionCreator.java @@ -5,10 +5,10 @@ import java.util.List; import java.util.Objects; -import name.abuchen.portfolio.model.LedgerDiagnosticCode; import name.abuchen.portfolio.model.Account; import name.abuchen.portfolio.model.AccountTransaction; import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; import name.abuchen.portfolio.model.Security; import name.abuchen.portfolio.model.Transaction; import name.abuchen.portfolio.model.ledger.LedgerEntryMetadataPatch; diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerOwnerPatchHelper.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerOwnerPatchHelper.java index ad75c0f3dc..48422311a0 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerOwnerPatchHelper.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerOwnerPatchHelper.java @@ -2,11 +2,11 @@ import java.util.Objects; -import name.abuchen.portfolio.model.LedgerDiagnosticCode; import name.abuchen.portfolio.model.Account; import name.abuchen.portfolio.model.AccountTransferEntry; import name.abuchen.portfolio.model.BuySellEntry; import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; import name.abuchen.portfolio.model.Portfolio; import name.abuchen.portfolio.model.PortfolioTransferEntry; import name.abuchen.portfolio.model.Transaction; diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferEditor.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferEditor.java index 09d207a15d..f505fb0412 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferEditor.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferEditor.java @@ -36,13 +36,13 @@ public void apply(LedgerEntry entry, LedgerPortfolioTransferEdit edit) var sourceProjection = LedgerProjectionSupport.descriptor(entry, LedgerProjectionRole.SOURCE_PORTFOLIO); var targetProjection = LedgerProjectionSupport.descriptor(entry, LedgerProjectionRole.TARGET_PORTFOLIO); - var sourcePostingUUID = sourceProjection.getPrimaryPosting().getUUID(); - var targetPostingUUID = targetProjection.getPrimaryPosting().getUUID(); + var sourcePostingIndex = LedgerEntryEditSupport.postingIndex(entry, sourceProjection.getPrimaryPosting()); + var targetPostingIndex = LedgerEntryEditSupport.postingIndex(entry, targetProjection.getPrimaryPosting()); var sourcePortfolio = sourceProjection.getPortfolio(); var targetPortfolio = targetProjection.getPortfolio(); LedgerEntryEditSupport.applyValidated(entry, editedEntry -> applyEdit(editedEntry, edit, - sourcePortfolio, targetPortfolio, sourcePostingUUID, targetPostingUUID)); + sourcePortfolio, targetPortfolio, sourcePostingIndex, targetPostingIndex)); } public void validate(LedgerEntry entry, LedgerPortfolioTransferEdit edit) @@ -56,23 +56,23 @@ public void validate(LedgerEntry entry, LedgerPortfolioTransferEdit edit) var sourceProjection = LedgerProjectionSupport.descriptor(entry, LedgerProjectionRole.SOURCE_PORTFOLIO); var targetProjection = LedgerProjectionSupport.descriptor(entry, LedgerProjectionRole.TARGET_PORTFOLIO); - var sourcePostingUUID = sourceProjection.getPrimaryPosting().getUUID(); - var targetPostingUUID = targetProjection.getPrimaryPosting().getUUID(); + var sourcePostingIndex = LedgerEntryEditSupport.postingIndex(entry, sourceProjection.getPrimaryPosting()); + var targetPostingIndex = LedgerEntryEditSupport.postingIndex(entry, targetProjection.getPrimaryPosting()); var sourcePortfolio = sourceProjection.getPortfolio(); var targetPortfolio = targetProjection.getPortfolio(); LedgerEntryEditSupport.validatePatch(entry, editedEntry -> applyEdit(editedEntry, edit, - sourcePortfolio, targetPortfolio, sourcePostingUUID, targetPostingUUID)); + sourcePortfolio, targetPortfolio, sourcePostingIndex, targetPostingIndex)); } private void applyEdit(LedgerEntry editedEntry, LedgerPortfolioTransferEdit edit, name.abuchen.portfolio.model.Portfolio sourcePortfolio, name.abuchen.portfolio.model.Portfolio targetPortfolio, - String sourcePostingUUID, String targetPostingUUID) + int sourcePostingIndex, int targetPostingIndex) { LedgerEntryMetadataPatchHelper.apply(editedEntry, edit.getMetadata()); - edit.getSourcePosting().applyTo(LedgerEntryEditSupport.postingByUUID(editedEntry, sourcePostingUUID)); - edit.getTargetPosting().applyTo(LedgerEntryEditSupport.postingByUUID(editedEntry, targetPostingUUID)); + edit.getSourcePosting().applyTo(LedgerEntryEditSupport.postingAt(editedEntry, sourcePostingIndex)); + edit.getTargetPosting().applyTo(LedgerEntryEditSupport.postingAt(editedEntry, targetPostingIndex)); unitPostingUpdater.apply(editedEntry, edit.getUnits()); ensureOwners(editedEntry, sourcePortfolio, targetPortfolio); } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferTransactionCreator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferTransactionCreator.java index af5e4cf262..a2397aecad 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferTransactionCreator.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferTransactionCreator.java @@ -3,8 +3,8 @@ import java.time.LocalDateTime; import java.util.Objects; -import name.abuchen.portfolio.model.LedgerDiagnosticCode; import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; import name.abuchen.portfolio.model.Portfolio; import name.abuchen.portfolio.model.PortfolioTransaction; import name.abuchen.portfolio.model.PortfolioTransferEntry; diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerShareAdjustmentHelper.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerShareAdjustmentHelper.java index 59ae50d127..f4ef2a577c 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerShareAdjustmentHelper.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerShareAdjustmentHelper.java @@ -6,8 +6,8 @@ import java.util.Objects; import java.util.function.LongUnaryOperator; -import name.abuchen.portfolio.model.LedgerDiagnosticCode; import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; import name.abuchen.portfolio.model.Security; import name.abuchen.portfolio.model.Transaction; import name.abuchen.portfolio.model.ledger.Ledger; diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerTransactionCreator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerTransactionCreator.java index fd1da62249..4afaff1354 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerTransactionCreator.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerTransactionCreator.java @@ -4,8 +4,8 @@ import java.util.List; import java.util.Objects; -import name.abuchen.portfolio.model.LedgerDiagnosticCode; import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerMutationContext; import name.abuchen.portfolio.model.ledger.LedgerParameter; diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerTransferDirectionConverter.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerTransferDirectionConverter.java index 06cab64293..e48752170e 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerTransferDirectionConverter.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerTransferDirectionConverter.java @@ -20,7 +20,6 @@ import name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedAccountTransaction; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedPortfolioTransaction; -import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionSupport; import name.abuchen.portfolio.money.ExchangeRate; @@ -245,17 +244,6 @@ private DerivedProjectionDescriptor uniqueProjection(LedgerEntry entry, LedgerPr return projections.get(0); } - private AccountTransaction find(Account account, String projectionUUID) - { - return account.getTransactions().stream() // - .filter(LedgerBackedTransaction.class::isInstance) // - .filter(transaction -> projectionUUID.equals(transaction.getUUID())) // - .findFirst() - .orElseThrow(() -> new IllegalStateException( - "Ledger account transfer projection was not materialized: " //$NON-NLS-1$ - + projectionUUID)); - } - private AccountTransaction find(Account account, LedgerEntry entry, LedgerProjectionRole role) { return account.getTransactions().stream() // @@ -269,17 +257,6 @@ private AccountTransaction find(Account account, LedgerEntry entry, LedgerProjec + entry.getUUID() + ":" + role)); //$NON-NLS-1$ } - private PortfolioTransaction find(Portfolio portfolio, String projectionUUID) - { - return portfolio.getTransactions().stream() // - .filter(LedgerBackedTransaction.class::isInstance) // - .filter(transaction -> projectionUUID.equals(transaction.getUUID())) // - .findFirst() - .orElseThrow(() -> new IllegalStateException( - "Ledger portfolio transfer projection was not materialized: " //$NON-NLS-1$ - + projectionUUID)); - } - private PortfolioTransaction find(Portfolio portfolio, LedgerEntry entry, LedgerProjectionRole role) { return portfolio.getTransactions().stream() // diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java index c6060d27e1..c0cc82b575 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java @@ -499,11 +499,6 @@ private static Optional parameterValue(List> paramete .findFirst(); } - private static boolean isBlank(String value) - { - return value == null || value.isBlank(); - } - private static ValidationIssue issue(IssueCode code, String message, LedgerEntry entry) { return new ValidationIssue(code, message).withEntry(entry); diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigrator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigrator.java index 05347c658a..ee6e11b459 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigrator.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigrator.java @@ -995,15 +995,6 @@ private boolean isUnitPosting(LedgerPostingType type) || type == LedgerPostingType.GROSS_VALUE; } - private LedgerPosting postingByUUID(LedgerEntry entry, String uuid) - { - for (var posting : entry.getPostings()) - if (Objects.equals(posting.getUUID(), uuid)) - return posting; - - return null; - } - private SemanticMismatch postingOwnerMismatch(LedgerPosting existingPosting, LedgerPosting expectedPosting, LedgerEntryType expectedEntryType) { diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java index ed14be2dbb..f305011c1f 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java @@ -7,11 +7,9 @@ import java.util.function.Predicate; import java.util.stream.Collectors; -import name.abuchen.portfolio.model.Account; -import name.abuchen.portfolio.model.Portfolio; import name.abuchen.portfolio.model.Security; -import name.abuchen.portfolio.model.ledger.LedgerParameter; import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerParameter; import name.abuchen.portfolio.model.ledger.LedgerPosting; import name.abuchen.portfolio.model.ledger.LedgerPostingDirection; import name.abuchen.portfolio.model.ledger.LedgerPostingSemanticRole; @@ -456,7 +454,7 @@ public List getPostingUUIDs() public String format() { - return "[" + code + "] entry=" + entry.getUUID() + " role=" + role + " " + message //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ + return "[" + code + "] entry=" + entry.getUUID() + " role=" + role + " " + message //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$ + (postingUUIDs.isEmpty() ? "" : " postings=" + postingUUIDs); //$NON-NLS-1$ //$NON-NLS-2$ } } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedAccountTransaction.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedAccountTransaction.java index d5a3a0da10..fdb692bd40 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedAccountTransaction.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedAccountTransaction.java @@ -4,9 +4,9 @@ import java.time.LocalDateTime; import java.util.stream.Stream; -import name.abuchen.portfolio.model.LedgerDiagnosticCode; import name.abuchen.portfolio.model.AccountTransaction; import name.abuchen.portfolio.model.CrossEntry; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; import name.abuchen.portfolio.model.Security; import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerEntryMetadataPatchHelper; diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedCrossEntry.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedCrossEntry.java index 871915c7bd..6cef75629a 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedCrossEntry.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedCrossEntry.java @@ -2,8 +2,8 @@ import java.util.List; -import name.abuchen.portfolio.model.LedgerDiagnosticCode; import name.abuchen.portfolio.model.CrossEntry; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; import name.abuchen.portfolio.model.Transaction; import name.abuchen.portfolio.model.TransactionOwner; import name.abuchen.portfolio.model.ledger.LedgerEntry; diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedPortfolioTransaction.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedPortfolioTransaction.java index a809f5daf1..960ad27993 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedPortfolioTransaction.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedPortfolioTransaction.java @@ -4,8 +4,8 @@ import java.time.LocalDateTime; import java.util.stream.Stream; -import name.abuchen.portfolio.model.LedgerDiagnosticCode; import name.abuchen.portfolio.model.CrossEntry; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; import name.abuchen.portfolio.model.PortfolioTransaction; import name.abuchen.portfolio.model.Security; import name.abuchen.portfolio.model.ledger.LedgerEntry; diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionFactory.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionFactory.java index df6dd9b42c..cfe1822495 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionFactory.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionFactory.java @@ -4,10 +4,10 @@ import java.util.List; import java.util.Objects; -import name.abuchen.portfolio.model.LedgerDiagnosticCode; import name.abuchen.portfolio.model.AccountTransaction; import name.abuchen.portfolio.model.AccountTransferEntry; import name.abuchen.portfolio.model.BuySellEntry; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; import name.abuchen.portfolio.model.PortfolioTransaction; import name.abuchen.portfolio.model.PortfolioTransferEntry; import name.abuchen.portfolio.model.Transaction; diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionService.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionService.java index 1eaee28409..e06e86e3e7 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionService.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionService.java @@ -1,15 +1,13 @@ package name.abuchen.portfolio.model.ledger.projection; import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; import name.abuchen.portfolio.model.Client; import name.abuchen.portfolio.model.Transaction; import name.abuchen.portfolio.model.ledger.Ledger; import name.abuchen.portfolio.model.ledger.LedgerEntry; -import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; /** * Coordinates materialization and refresh of runtime projections for Ledger entries. diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionSupport.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionSupport.java index 4570d6fbc9..79e2cf3373 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionSupport.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionSupport.java @@ -146,15 +146,6 @@ static boolean isPortfolioProjection(LedgerProjectionRole role) }; } - private static boolean isUnitPosting(LedgerPosting posting) - { - return switch (posting.getType()) - { - case FEE, TAX, GROSS_VALUE -> true; - default -> false; - }; - } - private static Unit unit(LedgerPosting posting) { var type = switch (posting.getType()) From f9f3deccc38693456925a70befa81134845d5a84 Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Fri, 3 Jul 2026 12:00:32 +0200 Subject: [PATCH 40/68] Prove Ledger multi-movement CA persistence Add a model-only Ledger entry type for Corporate Action movement confirmation and cover XML/protobuf roundtrips for repeated security, cash, fee, and tax movement postings. This proves the no-UUID Ledger persistence shape can carry ISO seev.036-like repeated economic legs without projection refs, posting group UUIDs, or legacy transaction enum expansion. Existing native Corporate Action definitions, descriptor derivation rules, protobuf schema, XML schema, UI behavior, import behavior, and legacy PTransaction.Type values are intentionally left unchanged. --- ...ateActionMultiMovementPersistenceTest.java | 325 ++++++++++++++++++ .../model/ledger/LedgerModelTest.java | 14 + .../ledger/configuration/LedgerEntryType.java | 11 +- .../projection/LedgerProjectionFactory.java | 3 + 4 files changed, 351 insertions(+), 2 deletions(-) create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java new file mode 100644 index 0000000000..84cfda3fd0 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java @@ -0,0 +1,325 @@ +package name.abuchen.portfolio.model.ledger; + +import static org.hamcrest.CoreMatchers.hasItem; +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.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import org.junit.Test; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.ClientFactory; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.ProtobufTestUtilities; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.model.proto.v1.PClient; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Values; + +/** + * Model/persistence proof only: current SPIN_OFF definition and descriptor acceptance for + * repeated target legs is intentionally not asserted here. Projection derivation for repeated + * target/cash movement legs is a later validator/descriptor/assembler feature. + */ +@SuppressWarnings("nls") +public class LedgerCorporateActionMultiMovementPersistenceTest +{ + private static final byte[] PROTOBUF_SIGNATURE = new byte[] { 'P', 'P', 'P', 'B', 'V', '1' }; + private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 1, 2, 3, 4); + private static final Instant UPDATED_AT = Instant.parse("2026-01-02T03:04:05Z"); + + @Test + public void testXmlRoundtripPreservesRepeatedCorporateActionMovementLegs() throws Exception + { + var fixture = fixture(); + + assertMultiMovementProof(fixture.entry()); + assertValid(fixture.client()); + + var xml = saveXml(fixture.client()); + + assertNoLedgerUuidTruth(xml); + + var loaded = loadXml(xml); + var loadedEntry = onlyLedgerEntry(loaded); + + assertMultiMovementProof(loadedEntry); + assertThat(loaded.getAllTransactions().size(), is(0)); + assertValid(loaded); + } + + @Test + public void testProtobufRoundtripPreservesRepeatedCorporateActionMovementLegs() throws Exception + { + var fixture = fixture(); + + assertMultiMovementProof(fixture.entry()); + assertValid(fixture.client()); + + var bytes = ProtobufTestUtilities.save(fixture.client()); + var proto = parseProto(bytes); + var protoEntry = proto.getLedger().getEntries(0); + + assertThat(proto.getLedger().getEntriesCount(), is(1)); + assertThat(protoEntry.getTypeCode(), is(LedgerEntryType.CORPORATE_ACTION_MOVEMENT_CONFIRMATION.getCode())); + assertThat(protoEntry.getPostingsCount(), is(7)); + assertThat(proto.getTransactionsCount(), is(0)); + assertNoLegacyTransactionTypeForProofEntry(); + + var loaded = ProtobufTestUtilities.load(bytes); + var loadedEntry = onlyLedgerEntry(loaded); + + assertMultiMovementProof(loadedEntry); + assertThat(loaded.getAllTransactions().size(), is(0)); + assertValid(loaded); + } + + private Fixture fixture() + { + var client = new Client(); + var account = new Account(); + var portfolio = new Portfolio(); + var sourceSecurity = new Security("Source AG", CurrencyUnit.EUR); + var targetSecurityA = new Security("Target A AG", CurrencyUnit.EUR); + var targetSecurityB = new Security("Target B AG", CurrencyUnit.EUR); + + account.setName("Cash Account"); + account.setCurrencyCode(CurrencyUnit.EUR); + account.setUpdatedAt(UPDATED_AT); + portfolio.setName("Portfolio"); + portfolio.setUpdatedAt(UPDATED_AT); + sourceSecurity.setUpdatedAt(UPDATED_AT); + targetSecurityA.setUpdatedAt(UPDATED_AT); + targetSecurityB.setUpdatedAt(UPDATED_AT); + + client.addAccount(account); + client.addPortfolio(portfolio); + client.addSecurity(sourceSecurity); + client.addSecurity(targetSecurityA); + client.addSecurity(targetSecurityB); + + var entry = new LedgerEntry("proof-entry"); + entry.setType(LedgerEntryType.CORPORATE_ACTION_MOVEMENT_CONFIRMATION); + entry.setDateTime(DATE_TIME); + entry.setSource("seev.036 movement proof"); + entry.setNote("model/persistence proof only"); + + entry.addPosting(securityPosting(portfolio, sourceSecurity, LedgerPostingDirection.OUTBOUND, + CorporateActionLeg.SOURCE_SECURITY, "main", "source-1", 10, 100)); + entry.addPosting(securityPosting(portfolio, targetSecurityA, LedgerPostingDirection.INBOUND, + CorporateActionLeg.TARGET_SECURITY, "main", "target-1", 3, 60)); + entry.addPosting(securityPosting(portfolio, targetSecurityB, LedgerPostingDirection.INBOUND, + CorporateActionLeg.TARGET_SECURITY, "main", "target-2", 7, 40)); + entry.addPosting(cashPosting(account, LedgerPostingDirection.INBOUND, "cash-1", "cash-1", 11)); + entry.addPosting(cashPosting(account, LedgerPostingDirection.INBOUND, "cash-2", "cash-2", 22)); + entry.addPosting(unitPosting(LedgerPostingType.FEE, LedgerPostingSemanticRole.FEE, + LedgerPostingUnitRole.FEE, CorporateActionLeg.FEE, "cash-1", "fee-1", 2)); + entry.addPosting(unitPosting(LedgerPostingType.TAX, LedgerPostingSemanticRole.TAX, + LedgerPostingUnitRole.TAX, CorporateActionLeg.TAX, "cash-1", "tax-1", 4)); + + client.getLedger().addEntry(entry); + + return new Fixture(client, entry); + } + + private LedgerPosting securityPosting(Portfolio portfolio, Security security, LedgerPostingDirection direction, + CorporateActionLeg leg, String groupKey, String localKey, long shares, long amount) + { + var posting = new LedgerPosting("proof-" + localKey); + + posting.setType(LedgerPostingType.SECURITY); + posting.setPortfolio(portfolio); + posting.setSecurity(security); + posting.setShares(Values.Share.factorize(shares)); + posting.setAmount(Values.Amount.factorize(amount)); + posting.setCurrency(CurrencyUnit.EUR); + posting.setSemanticRole(LedgerPostingSemanticRole.SECURITY); + posting.setDirection(direction); + posting.setCorporateActionLeg(leg); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + posting.setGroupKey(groupKey); + posting.setLocalKey(localKey); + + return posting; + } + + private LedgerPosting cashPosting(Account account, LedgerPostingDirection direction, String groupKey, String localKey, + long amount) + { + var posting = new LedgerPosting("proof-" + localKey); + + posting.setType(LedgerPostingType.CASH_COMPENSATION); + posting.setAccount(account); + posting.setAmount(Values.Amount.factorize(amount)); + posting.setCurrency(CurrencyUnit.EUR); + posting.setSemanticRole(LedgerPostingSemanticRole.CASH_COMPENSATION); + posting.setDirection(direction); + posting.setCorporateActionLeg(CorporateActionLeg.CASH_COMPENSATION); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + posting.setGroupKey(groupKey); + posting.setLocalKey(localKey); + + return posting; + } + + private LedgerPosting unitPosting(LedgerPostingType type, LedgerPostingSemanticRole semanticRole, + LedgerPostingUnitRole unitRole, CorporateActionLeg leg, String groupKey, String localKey, + long amount) + { + var posting = new LedgerPosting("proof-" + localKey); + + posting.setType(type); + posting.setAmount(Values.Amount.factorize(amount)); + posting.setCurrency(CurrencyUnit.EUR); + posting.setSemanticRole(semanticRole); + posting.setDirection(LedgerPostingDirection.NEUTRAL); + posting.setCorporateActionLeg(leg); + posting.setUnitRole(unitRole); + posting.setGroupKey(groupKey); + posting.setLocalKey(localKey); + + return posting; + } + + private void assertMultiMovementProof(LedgerEntry entry) + { + assertThat(entry.getType(), is(LedgerEntryType.CORPORATE_ACTION_MOVEMENT_CONFIRMATION)); + assertThat(entry.getPostings().size(), is(7)); + + var targetSecurities = postings(entry, LedgerPostingType.SECURITY, LedgerPostingDirection.INBOUND, + CorporateActionLeg.TARGET_SECURITY); + var cashMovements = postings(entry, LedgerPostingType.CASH_COMPENSATION, LedgerPostingDirection.INBOUND, + CorporateActionLeg.CASH_COMPENSATION); + var cashOneUnits = entry.getPostings().stream() + .filter(posting -> "cash-1".equals(posting.getGroupKey())) + .filter(posting -> posting.getUnitRole() == LedgerPostingUnitRole.FEE + || posting.getUnitRole() == LedgerPostingUnitRole.TAX) + .toList(); + + assertThat(postings(entry, LedgerPostingType.SECURITY, LedgerPostingDirection.OUTBOUND, + CorporateActionLeg.SOURCE_SECURITY).size(), is(1)); + assertThat(targetSecurities.size(), is(2)); + assertThat(localKeys(targetSecurities), is(Set.of("target-1", "target-2"))); + assertThat(cashMovements.size(), is(2)); + assertThat(localKeys(cashMovements), is(Set.of("cash-1", "cash-2"))); + assertThat(groupKeys(cashMovements), is(Set.of("cash-1", "cash-2"))); + assertThat(cashOneUnits.size(), is(2)); + assertThat(cashOneUnits.stream().map(LedgerPosting::getUnitRole).collect(Collectors.toSet()), + is(Set.of(LedgerPostingUnitRole.FEE, LedgerPostingUnitRole.TAX))); + assertThat(entry.getPostings().stream().map(LedgerPosting::getLocalKey).toList(), hasItem("target-1")); + assertThat(entry.getPostings().stream().map(LedgerPosting::getLocalKey).toList(), hasItem("target-2")); + } + + private List postings(LedgerEntry entry, LedgerPostingType type, LedgerPostingDirection direction, + CorporateActionLeg leg) + { + return entry.getPostings().stream() + .filter(posting -> posting.getType() == type) + .filter(posting -> posting.getDirection() == direction) + .filter(posting -> posting.getCorporateActionLeg() == leg) + .toList(); + } + + private Set localKeys(List postings) + { + return postings.stream().map(LedgerPosting::getLocalKey).collect(Collectors.toSet()); + } + + private Set groupKeys(List postings) + { + return postings.stream().map(LedgerPosting::getGroupKey).collect(Collectors.toSet()); + } + + private String saveXml(Client client) throws IOException + { + var file = File.createTempFile("ledger-corporate-action-movement", ".xml"); + + try + { + ClientFactory.save(client, file); + return Files.readString(file.toPath(), StandardCharsets.UTF_8); + } + finally + { + Files.deleteIfExists(file.toPath()); + } + } + + private Client loadXml(String xml) throws IOException + { + return ClientFactory.load(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); + } + + private PClient parseProto(byte[] bytes) throws IOException + { + return PClient.parseFrom(new ByteArrayInputStream(bytes, PROTOBUF_SIGNATURE.length, + bytes.length - PROTOBUF_SIGNATURE.length)); + } + + private LedgerEntry onlyLedgerEntry(Client client) + { + assertThat(client.getLedger().getEntries().size(), is(1)); + return client.getLedger().getEntries().get(0); + } + + private void assertValid(Client client) + { + assertTrue(LedgerStructuralValidator.validate(client.getLedger()).toString(), + LedgerStructuralValidator.validate(client.getLedger()).isOK()); + } + + private void assertNoLedgerUuidTruth(String xml) + { + var ledgerXml = ledgerSection(xml); + + assertFalse(ledgerXml.matches("(?s).*]*\\buuid=.*")); + assertFalse(ledgerXml.matches("(?s).*]*\\buuid=.*")); + assertFalse(ledgerXml.contains("")); + assertFalse(ledgerXml.contains(""); + var end = xml.indexOf(""); + + assertTrue(start >= 0); + assertTrue(end > start); + + return xml.substring(start, end); + } + + private void assertNoLegacyTransactionTypeForProofEntry() + { + assertFalse(Arrays.stream(name.abuchen.portfolio.model.proto.v1.PTransaction.Type.values()) + .map(Enum::name) + .anyMatch(LedgerEntryType.CORPORATE_ACTION_MOVEMENT_CONFIRMATION.getCode()::equals)); + } + + private record Fixture(Client client, LedgerEntry entry) + { + } +} 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 index 8bd21d9213..22ed82687d 100644 --- 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 @@ -528,10 +528,13 @@ public void testLedgerEntryTypePoliciesSeparateStandardAndLedgerNativeShapes() var corporateActionFamilies = EnumSet.of(LedgerEntryType.SPIN_OFF, LedgerEntryType.STOCK_DIVIDEND, LedgerEntryType.BONUS_ISSUE, LedgerEntryType.RIGHTS_DISTRIBUTION, LedgerEntryType.BOND_CONVERSION); + var modelOnlyFamilies = EnumSet.of(LedgerEntryType.CORPORATE_ACTION_MOVEMENT_CONFIRMATION); var standardFamilies = EnumSet.complementOf(corporateActionFamilies); + standardFamilies.removeAll(modelOnlyFamilies); standardFamilies.forEach(this::assertStandardLegacyShape); corporateActionFamilies.forEach(this::assertLedgerNativeTargetedShape); + modelOnlyFamilies.forEach(this::assertLedgerNativeModelOnlyShape); assertTrue(LedgerEntryType.SPIN_OFF.requiresTargetedDerivedDescriptors()); assertTrue(LedgerEntryType.SPIN_OFF.usesSignedTargetedProjectionFacts()); @@ -716,6 +719,7 @@ private void assertStandardLegacyShape(LedgerEntryType type) assertTrue(type.isLegacyFixedShape()); assertFalse(type.isLedgerNativeTargeted()); assertFalse(type.requiresTargetedDerivedDescriptors()); + assertTrue(type.supportsDerivedDescriptors()); assertFalse(type.usesSignedTargetedProjectionFacts()); } @@ -724,9 +728,19 @@ private void assertLedgerNativeTargetedShape(LedgerEntryType type) assertFalse(type.isLegacyFixedShape()); assertTrue(type.isLedgerNativeTargeted()); assertTrue(type.requiresTargetedDerivedDescriptors()); + assertTrue(type.supportsDerivedDescriptors()); assertTrue(type.usesSignedTargetedProjectionFacts()); } + private void assertLedgerNativeModelOnlyShape(LedgerEntryType type) + { + assertFalse(type.isLegacyFixedShape()); + assertFalse(type.isLedgerNativeTargeted()); + assertFalse(type.requiresTargetedDerivedDescriptors()); + assertFalse(type.supportsDerivedDescriptors()); + assertFalse(type.usesSignedTargetedProjectionFacts()); + } + private void assertValueKindPolicy(ValueKind valueKind, Class valueType) { assertThat(valueKind.getValueType(), is(valueType)); 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 index 73beaa0ffa..6c02a693f1 100644 --- 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 @@ -31,12 +31,14 @@ public enum LedgerEntryType STOCK_DIVIDEND("STOCK_DIVIDEND", Shape.LEDGER_NATIVE_TARGETED), BONUS_ISSUE("BONUS_ISSUE", Shape.LEDGER_NATIVE_TARGETED), RIGHTS_DISTRIBUTION("RIGHTS_DISTRIBUTION", Shape.LEDGER_NATIVE_TARGETED), - BOND_CONVERSION("BOND_CONVERSION", Shape.LEDGER_NATIVE_TARGETED); + BOND_CONVERSION("BOND_CONVERSION", Shape.LEDGER_NATIVE_TARGETED), + CORPORATE_ACTION_MOVEMENT_CONFIRMATION("CORPORATE_ACTION_MOVEMENT_CONFIRMATION", Shape.LEDGER_NATIVE_MODEL_ONLY); private enum Shape { LEGACY_FIXED, - LEDGER_NATIVE_TARGETED + LEDGER_NATIVE_TARGETED, + LEDGER_NATIVE_MODEL_ONLY } private final String code; @@ -100,6 +102,11 @@ 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/projection/LedgerProjectionFactory.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionFactory.java index cfe1822495..64c36113b4 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionFactory.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionFactory.java @@ -63,6 +63,9 @@ List createProjections(LedgerEntry entry) private List createDescriptors(LedgerEntry entry) { + if (entry.getType() != null && !entry.getType().supportsDerivedDescriptors()) + return List.of(); + var result = descriptorService.derive(entry); if (!result.isOK()) From f37d12691f7a5ac4c4fe9d2daae53a0670f90732 Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Fri, 3 Jul 2026 12:26:55 +0200 Subject: [PATCH 41/68] Add semantic keys for repeated Ledger descriptors --- ...erivedProjectionDescriptorServiceTest.java | 101 +++++++++++ .../DerivedProjectionDescriptor.java | 33 +++- .../DerivedProjectionDescriptorService.java | 159 +++++++++++++++++- .../projection/LedgerProjectionFactory.java | 25 ++- .../projection/LedgerProjectionSupport.java | 26 ++- 5 files changed, 332 insertions(+), 12 deletions(-) diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorServiceTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorServiceTest.java index 18c0ac3a24..fbde2cd0f5 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorServiceTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorServiceTest.java @@ -4,6 +4,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import java.time.LocalDateTime; @@ -191,6 +192,74 @@ public void testSiemensSpinOffDescriptorsDeriveTargetedRuntimeViews() LedgerProjectionRole.CASH_COMPENSATION); } + @Test + public void testExistingSpinOffDescriptorRuntimeIdRemainsRoleOnly() + { + var entry = spinOffEntry(fixture()); + + var descriptor = descriptor(descriptors(entry), LedgerProjectionRole.NEW_SECURITY_LEG); + + assertFalse(descriptor.hasSemanticInstanceKey()); + assertThat(descriptor.getRuntimeProjectionId(), is("spin-off:NEW_SECURITY_LEG")); + } + + @Test + public void testRepeatedTargetDescriptorsUseSemanticInstanceKeys() + { + var entry = repeatedTargetSpinOffEntry("target-1", "target-2"); + + var targetDescriptors = descriptors(entry).stream() + .filter(descriptor -> descriptor.getRole() == LedgerProjectionRole.NEW_SECURITY_LEG) + .toList(); + + assertThat(targetDescriptors.size(), is(2)); + assertThat(targetDescriptors.stream().map(descriptor -> descriptor.getSemanticInstanceKey().orElseThrow()) + .collect(Collectors.toSet()), is(Set.of("target-1", "target-2"))); + assertThat(targetDescriptors.stream().map(DerivedProjectionDescriptor::getRuntimeProjectionId) + .collect(Collectors.toSet()), is(Set.of("spin-off-repeated:NEW_SECURITY_LEG:target-1", + "spin-off-repeated:NEW_SECURITY_LEG:target-2"))); + assertFalse(targetDescriptors.stream().map(DerivedProjectionDescriptor::getRuntimeProjectionId) + .anyMatch(id -> id.contains("target-posting"))); + } + + @Test + public void testDuplicateRepeatedTargetLocalKeyIsReported() + { + var entry = repeatedTargetSpinOffEntry("target-1", "target-1"); + + var result = new DerivedProjectionDescriptorService().derive(entry); + + assertFalse(result.isOK()); + assertTrue(result.getDiagnostics().stream().anyMatch(diagnostic -> diagnostic + .getCode() == DerivedProjectionDescriptorService.Diagnostic.IssueCode.DUPLICATE_SEMANTIC_INSTANCE_KEY)); + } + + @Test + public void testMissingRepeatedTargetLocalKeyIsReported() + { + var entry = repeatedTargetSpinOffEntry(null, "target-2"); + + var result = new DerivedProjectionDescriptorService().derive(entry); + + assertFalse(result.isOK()); + assertTrue(result.getDiagnostics().stream().anyMatch(diagnostic -> diagnostic + .getCode() == DerivedProjectionDescriptorService.Diagnostic.IssueCode.MISSING_SEMANTIC_INSTANCE_KEY)); + } + + @Test + public void testRoleOnlyProjectionLookupRejectsRepeatedDescriptorRole() + { + var entry = repeatedTargetSpinOffEntry("target-1", "target-2"); + var factory = new LedgerProjectionFactory(); + + assertThrows(IllegalArgumentException.class, + () -> factory.createProjection(entry, LedgerProjectionRole.NEW_SECURITY_LEG)); + + var projection = factory.createProjection(entry, LedgerProjectionRole.NEW_SECURITY_LEG, "target-1"); + + assertThat(projection.getUUID(), is("spin-off-repeated:NEW_SECURITY_LEG:target-1")); + } + @Test public void testNativeCorporateActionSmokeDescriptors() { @@ -319,6 +388,27 @@ private LedgerEntry spinOffEntry(Fixture fixture) return entry; } + private LedgerEntry repeatedTargetSpinOffEntry(String firstTargetLocalKey, String secondTargetLocalKey) + { + var fixture = fixture(); + var entry = new LedgerEntry("spin-off-repeated"); + var oldLeg = portfolioPosting("old-posting", fixture.portfolio, fixture.siemens, 10, 100, + CorporateActionLeg.SOURCE_SECURITY, LedgerProjectionRole.OLD_SECURITY_LEG); + var targetA = repeatedPortfolioPosting("target-posting-1", fixture.portfolio, fixture.siemensEnergy, 5, 50, + CorporateActionLeg.TARGET_SECURITY, LedgerProjectionRole.NEW_SECURITY_LEG, firstTargetLocalKey); + var targetB = repeatedPortfolioPosting("target-posting-2", fixture.portfolio, security("Siemens Energy B"), 3, + 30, CorporateActionLeg.TARGET_SECURITY, LedgerProjectionRole.NEW_SECURITY_LEG, + secondTargetLocalKey); + + entry.setType(LedgerEntryType.SPIN_OFF); + entry.setDateTime(DATE_TIME); + entry.addPosting(oldLeg); + entry.addPosting(targetA); + entry.addPosting(targetB); + + return entry; + } + private LedgerEntry nativeSinglePortfolioEntry(LedgerEntryType type, CorporateActionLeg leg) { var fixture = fixture(); @@ -388,6 +478,17 @@ private LedgerPosting portfolioPosting(String uuid, Portfolio portfolio, Securit return posting; } + private LedgerPosting repeatedPortfolioPosting(String uuid, Portfolio portfolio, Security security, int shares, + int amount, CorporateActionLeg leg, LedgerProjectionRole role, String localKey) + { + var posting = portfolioPosting(uuid, portfolio, security, shares, amount, leg, role); + + posting.setGroupKey("main"); + posting.setLocalKey(localKey); + + return posting; + } + private LedgerPosting accountPosting(String uuid, Account account, int amount, CorporateActionLeg leg, LedgerProjectionRole role) { diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptor.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptor.java index a2ceb416f1..f2731c6231 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptor.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptor.java @@ -3,6 +3,7 @@ import java.util.Collections; import java.util.List; import java.util.Objects; +import java.util.Optional; import name.abuchen.portfolio.model.Account; import name.abuchen.portfolio.model.Portfolio; @@ -23,12 +24,21 @@ public final class DerivedProjectionDescriptor private final Portfolio portfolio; private final LedgerPosting primaryPosting; private final List unitPostings; + private final String semanticInstanceKey; private final String primarySelector; private final String unitSelector; DerivedProjectionDescriptor(LedgerEntry entry, LedgerProjectionRole role, DerivedProjectionViewKind viewKind, Account account, Portfolio portfolio, LedgerPosting primaryPosting, List unitPostings, String primarySelector, String unitSelector) + { + this(entry, role, viewKind, account, portfolio, primaryPosting, unitPostings, null, primarySelector, + unitSelector); + } + + DerivedProjectionDescriptor(LedgerEntry entry, LedgerProjectionRole role, DerivedProjectionViewKind viewKind, + Account account, Portfolio portfolio, LedgerPosting primaryPosting, List unitPostings, + String semanticInstanceKey, String primarySelector, String unitSelector) { this.entry = Objects.requireNonNull(entry); this.role = Objects.requireNonNull(role); @@ -37,6 +47,7 @@ public final class DerivedProjectionDescriptor this.portfolio = portfolio; this.primaryPosting = Objects.requireNonNull(primaryPosting); this.unitPostings = List.copyOf(unitPostings); + this.semanticInstanceKey = normalize(semanticInstanceKey); this.primarySelector = Objects.requireNonNull(primarySelector); this.unitSelector = Objects.requireNonNull(unitSelector); } @@ -48,7 +59,12 @@ public LedgerEntry getEntry() public String getRuntimeProjectionId() { - return entry.getUUID() + ":" + role; //$NON-NLS-1$ + var projectionId = entry.getUUID() + ":" + role; //$NON-NLS-1$ + + if (semanticInstanceKey == null) + return projectionId; + + return projectionId + ":" + semanticInstanceKey; //$NON-NLS-1$ } public LedgerProjectionRole getRole() @@ -81,6 +97,16 @@ public List getUnitPostings() return Collections.unmodifiableList(unitPostings); } + public Optional getSemanticInstanceKey() + { + return Optional.ofNullable(semanticInstanceKey); + } + + public boolean hasSemanticInstanceKey() + { + return semanticInstanceKey != null; + } + public String getPrimarySelector() { return primarySelector; @@ -90,4 +116,9 @@ public String getUnitSelector() { return unitSelector; } + + private static String normalize(String value) + { + return value == null || value.isBlank() ? null : value; + } } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java index f305011c1f..d9b100520c 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java @@ -2,6 +2,7 @@ import java.util.ArrayList; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Objects; import java.util.function.Predicate; @@ -94,17 +95,20 @@ public Result derive(LedgerEntry entry) .and(localKey(LedgerProjectionRole.DELIVERY_INBOUND)) .or(legacyRetainedSpinOffTarget()), diagnostics).ifPresent(descriptors::add); - portfolio(entry, LedgerProjectionRole.NEW_SECURITY_LEG, + repeatedPortfolio(entry, LedgerProjectionRole.NEW_SECURITY_LEG, primary().and(corporateLeg(CorporateActionLeg.TARGET_SECURITY)) .and(localKey(LedgerProjectionRole.NEW_SECURITY_LEG)) .or(legacyNewSpinOffTarget()), - diagnostics).ifPresent(descriptors::add); - optionalAccount(entry, LedgerProjectionRole.CASH_COMPENSATION, + primary().and(corporateLeg(CorporateActionLeg.TARGET_SECURITY)) + .and(localKey(LedgerProjectionRole.DELIVERY_INBOUND).negate()), + false, diagnostics).forEach(descriptors::add); + repeatedAccount(entry, LedgerProjectionRole.CASH_COMPENSATION, primary().and(corporateLeg(CorporateActionLeg.CASH_COMPENSATION)) + .and(localKey(LedgerProjectionRole.CASH_COMPENSATION)) .or(legacyCorporateLeg(LedgerPostingType.CASH_COMPENSATION, CorporateActionLeg.CASH_COMPENSATION)), - diagnostics) - .ifPresent(descriptors::add); + primary().and(corporateLeg(CorporateActionLeg.CASH_COMPENSATION)), true, diagnostics) + .forEach(descriptors::add); } case STOCK_DIVIDEND, BONUS_ISSUE -> { portfolio(entry, LedgerProjectionRole.DELIVERY_INBOUND, @@ -189,12 +193,66 @@ private java.util.Optional optionalPortfolio(Ledger return descriptor(entry, role, DerivedProjectionViewKind.PORTFOLIO, selector, true, diagnostics); } + private List repeatedAccount(LedgerEntry entry, LedgerProjectionRole role, + Predicate preferredSelector, Predicate repeatedSelector, + boolean optional, List diagnostics) + { + return repeated(entry, role, DerivedProjectionViewKind.ACCOUNT, preferredSelector, repeatedSelector, optional, + diagnostics); + } + + private List repeatedPortfolio(LedgerEntry entry, LedgerProjectionRole role, + Predicate preferredSelector, Predicate repeatedSelector, + boolean optional, List diagnostics) + { + return repeated(entry, role, DerivedProjectionViewKind.PORTFOLIO, preferredSelector, repeatedSelector, optional, + diagnostics); + } + + private List repeated(LedgerEntry entry, LedgerProjectionRole role, + DerivedProjectionViewKind viewKind, Predicate preferredSelector, + Predicate repeatedSelector, boolean optional, List diagnostics) + { + var preferredMatches = matches(entry, preferredSelector); + + if (!preferredMatches.isEmpty()) + { + var extraMatches = matches(entry, repeatedSelector.and(preferredSelector.negate())); + + if (!extraMatches.isEmpty()) + { + diagnostics.add(Diagnostic.ambiguous(entry, role, + "Ambiguous semantic primary postings; use distinct localKey values", //$NON-NLS-1$ + extraMatches)); + return List.of(); + } + + return descriptor(entry, role, viewKind, preferredMatches, optional, diagnostics).stream().toList(); + } + + var repeatedMatches = matches(entry, repeatedSelector); + + if (repeatedMatches.isEmpty()) + { + if (!optional) + diagnostics.add(Diagnostic.missing(entry, role, "Missing semantic primary posting")); //$NON-NLS-1$ + return List.of(); + } + + return repeatedDescriptors(entry, role, viewKind, repeatedMatches, diagnostics); + } + private java.util.Optional descriptor(LedgerEntry entry, LedgerProjectionRole role, DerivedProjectionViewKind viewKind, Predicate selector, boolean optional, List diagnostics) { - var matches = entry.getPostings().stream().filter(selector).toList(); + return descriptor(entry, role, viewKind, matches(entry, selector), optional, diagnostics); + } + private java.util.Optional descriptor(LedgerEntry entry, LedgerProjectionRole role, + DerivedProjectionViewKind viewKind, List matches, boolean optional, + List diagnostics) + { if (matches.isEmpty()) { if (!optional) @@ -228,6 +286,69 @@ private java.util.Optional descriptor(LedgerEntry e primary, unitPostings(entry, primary), primarySelector(role), unitSelector(primary))); } + private List repeatedDescriptors(LedgerEntry entry, LedgerProjectionRole role, + DerivedProjectionViewKind viewKind, List matches, List diagnostics) + { + var invalid = false; + var localKeys = new HashSet(); + + for (var posting : matches) + { + if (isBlank(posting.getLocalKey())) + { + diagnostics.add(Diagnostic.missingInstanceKey(entry, role, + "Repeated semantic primary posting requires a localKey")); //$NON-NLS-1$ + invalid = true; + } + else if (!localKeys.add(posting.getLocalKey())) + { + diagnostics.add(Diagnostic.duplicateInstanceKey(entry, role, + "Repeated semantic primary postings require distinct localKey values", //$NON-NLS-1$ + posting)); + invalid = true; + } + } + + if (invalid) + return List.of(); + + var descriptors = new ArrayList(); + + for (var primary : matches) + { + var account = primary.getAccount(); + var portfolio = primary.getPortfolio(); + + if (viewKind == DerivedProjectionViewKind.ACCOUNT && account == null) + { + diagnostics.add(Diagnostic.missing(entry, role, "Semantic account owner is missing")); //$NON-NLS-1$ + invalid = true; + continue; + } + + if (viewKind == DerivedProjectionViewKind.PORTFOLIO && portfolio == null) + { + diagnostics.add(Diagnostic.missing(entry, role, "Semantic portfolio owner is missing")); //$NON-NLS-1$ + invalid = true; + continue; + } + + descriptors.add(new DerivedProjectionDescriptor(entry, role, viewKind, account, portfolio, primary, + unitPostings(entry, primary), primary.getLocalKey(), + primarySelector(role, primary.getLocalKey()), unitSelector(primary))); + } + + if (invalid) + return List.of(); + + return List.copyOf(descriptors); + } + + private List matches(LedgerEntry entry, Predicate selector) + { + return entry.getPostings().stream().filter(selector).toList(); + } + private List unitPostings(LedgerEntry entry, LedgerPosting primary) { return entry.getPostings().stream() // @@ -243,6 +364,11 @@ private String primarySelector(LedgerProjectionRole role) return "semantic primary for " + role; //$NON-NLS-1$ } + private String primarySelector(LedgerProjectionRole role, String semanticInstanceKey) + { + return primarySelector(role) + " instance " + semanticInstanceKey; //$NON-NLS-1$ + } + private String unitSelector(LedgerPosting primary) { return primary.getGroupKey() == null ? "semantic unit role" //$NON-NLS-1$ @@ -274,6 +400,11 @@ private Predicate localKey(LedgerProjectionRole role) return posting -> role.name().equals(posting.getLocalKey()); } + private boolean isBlank(String value) + { + return value == null || value.isBlank(); + } + private Predicate legacyAccountPrimary(LedgerEntryType entryType) { return legacyPrimary(switch (entryType) @@ -396,7 +527,9 @@ public static final class Diagnostic public enum IssueCode { MISSING_SEMANTIC_PRIMARY, - AMBIGUOUS_SEMANTIC_PRIMARY + AMBIGUOUS_SEMANTIC_PRIMARY, + MISSING_SEMANTIC_INSTANCE_KEY, + DUPLICATE_SEMANTIC_INSTANCE_KEY } private final IssueCode code; @@ -427,6 +560,18 @@ private static Diagnostic ambiguous(LedgerEntry entry, LedgerProjectionRole role postings.stream().map(LedgerPosting::getUUID).toList()); } + private static Diagnostic missingInstanceKey(LedgerEntry entry, LedgerProjectionRole role, String message) + { + return new Diagnostic(IssueCode.MISSING_SEMANTIC_INSTANCE_KEY, entry, role, message, List.of()); + } + + private static Diagnostic duplicateInstanceKey(LedgerEntry entry, LedgerProjectionRole role, String message, + LedgerPosting posting) + { + return new Diagnostic(IssueCode.DUPLICATE_SEMANTIC_INSTANCE_KEY, entry, role, message, + List.of(posting.getUUID())); + } + public IssueCode getCode() { return code; diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionFactory.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionFactory.java index 64c36113b4..fc24f255f7 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionFactory.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionFactory.java @@ -39,12 +39,33 @@ Transaction createProjection(LedgerEntry entry, LedgerProjectionRole role) Objects.requireNonNull(entry); Objects.requireNonNull(role); + var descriptors = createDescriptors(entry).stream() // + .filter(descriptor -> descriptor.getRole() == role) // + .toList(); + + if (descriptors.size() == 1) + return create(descriptors.get(0)); + + if (descriptors.isEmpty()) + throw new IllegalArgumentException("Projection descriptor does not belong to entry role: " + role); //$NON-NLS-1$ + + throw new IllegalArgumentException("Projection descriptor role is ambiguous: " + role); //$NON-NLS-1$ + } + + Transaction createProjection(LedgerEntry entry, LedgerProjectionRole role, String semanticInstanceKey) + { + Objects.requireNonNull(entry); + Objects.requireNonNull(role); + Objects.requireNonNull(semanticInstanceKey); + return createDescriptors(entry).stream() // .filter(descriptor -> descriptor.getRole() == role) // + .filter(descriptor -> descriptor.getSemanticInstanceKey() + .filter(semanticInstanceKey::equals).isPresent()) // .map(this::create) // .findFirst().orElseThrow(() -> new IllegalArgumentException( - "Projection descriptor does not belong to entry role: " //$NON-NLS-1$ - + role)); + "Projection descriptor does not belong to entry role and instance: " //$NON-NLS-1$ + + role + "/" + semanticInstanceKey)); //$NON-NLS-1$ } List createProjections(LedgerEntry entry) diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionSupport.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionSupport.java index 79e2cf3373..bae6b42dd3 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionSupport.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionSupport.java @@ -48,10 +48,32 @@ public static DerivedProjectionDescriptor descriptor(LedgerEntry entry, LedgerPr Objects.requireNonNull(entry); Objects.requireNonNull(role); + var matches = descriptors(entry).stream() // + .filter(descriptor -> descriptor.getRole() == role) // + .toList(); + + if (matches.size() == 1) + return matches.get(0); + + if (matches.isEmpty()) + throw new IllegalArgumentException("Projection descriptor not found: " + role); //$NON-NLS-1$ + + throw new IllegalArgumentException("Projection descriptor role is ambiguous: " + role); //$NON-NLS-1$ + } + + public static DerivedProjectionDescriptor descriptor(LedgerEntry entry, LedgerProjectionRole role, + String semanticInstanceKey) + { + Objects.requireNonNull(entry); + Objects.requireNonNull(role); + Objects.requireNonNull(semanticInstanceKey); + return descriptors(entry).stream() // .filter(descriptor -> descriptor.getRole() == role) // - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("Projection descriptor not found: " + role)); //$NON-NLS-1$ + .filter(descriptor -> descriptor.getSemanticInstanceKey() + .filter(semanticInstanceKey::equals).isPresent()) // + .findFirst().orElseThrow(() -> new IllegalArgumentException( + "Projection descriptor not found: " + role + "/" + semanticInstanceKey)); //$NON-NLS-1$ //$NON-NLS-2$ } public static List descriptors(LedgerEntry entry) From d46738b8639183ec6164621013e08b5017bf50ab Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Fri, 3 Jul 2026 12:41:28 +0200 Subject: [PATCH 42/68] Validate repeated Ledger primary legs by semantic keys --- ...gerNativeEntryDefinitionValidatorTest.java | 23 ++++ ...iveEntryDefinitionValidatorPolicyTest.java | 108 +++++++++++++++++ .../LedgerNativeEntryDefinitionValidator.java | 112 +++++++++++++++++- 3 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidatorPolicyTest.java diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java index 2f5721644c..f24c6dc1af 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java @@ -159,6 +159,29 @@ public void testDuplicateSourceLegIsRejectedAsAmbiguous() assertIssue(entry, IssueCode.REQUIRED_PROJECTION_MISSING); } + /** + * Checks that semantic instance keys do not widen the current SPIN_OFF + * definition by themselves. + * Two target security legs remain invalid until the definition is explicitly + * changed from exactly one to a repeatable cardinality in a later slice. + */ + @Test + public void testCurrentSpinOffDefinitionStillRejectsRepeatedTargetLegs() + { + var entry = copyValidSpinOff(); + var targetPosting = postingFor(entry, LedgerProjectionRole.NEW_SECURITY_LEG); + var duplicateTarget = LedgerModelCopy.copyPosting(targetPosting); + + targetPosting.setLocalKey("target-1"); + targetPosting.setGroupKey("main"); + duplicateTarget.setUUID("duplicate-target-posting"); + duplicateTarget.setLocalKey("target-2"); + duplicateTarget.setGroupKey("main"); + entry.addPosting(duplicateTarget); + + assertIssue(entry, IssueCode.LEG_CARDINALITY_VIOLATED); + } + /** * Checks that a projection cannot satisfy a security leg with a posting of * the wrong type. diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidatorPolicyTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidatorPolicyTest.java new file mode 100644 index 0000000000..f8acafa3aa --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidatorPolicyTest.java @@ -0,0 +1,108 @@ +package name.abuchen.portfolio.model.ledger.configuration; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; + +import java.util.List; + +import org.junit.Test; + +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerPostingDirection; +import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; +import name.abuchen.portfolio.model.ledger.configuration.LedgerNativeEntryDefinitionValidator.IssueCode; + +/** + * Tests the definition-gated repeated primary leg policy without widening any + * production native Corporate Action definitions. + */ +@SuppressWarnings("nls") +public class LedgerNativeEntryDefinitionValidatorPolicyTest +{ + @Test + public void testAtLeastOneRepeatedPrimaryLegsAcceptDistinctLocalKeys() + { + var result = validate(LedgerLegCardinality.AT_LEAST_ONE, posting("target-1"), posting("target-2")); + + assertThat(result.format(), result.isOK(), is(true)); + } + + @Test + public void testRepeatablePrimaryLegsAcceptDistinctLocalKeys() + { + var result = validate(LedgerLegCardinality.REPEATABLE, posting("target-1"), posting("target-2")); + + assertThat(result.format(), result.isOK(), is(true)); + } + + @Test + public void testRepeatedPrimaryLegsRejectMissingLocalKey() + { + var result = validate(LedgerLegCardinality.AT_LEAST_ONE, posting(null), posting("target-2")); + + assertThat(result.format(), result.hasIssue(IssueCode.LEG_LOCAL_KEY_REQUIRED), is(true)); + } + + @Test + public void testRepeatedPrimaryLegsRejectDuplicateLocalKey() + { + var result = validate(LedgerLegCardinality.REPEATABLE, posting("target-1"), posting("target-1")); + + assertThat(result.format(), result.hasIssue(IssueCode.LEG_LOCAL_KEY_DUPLICATE), is(true)); + } + + @Test + public void testExactlyOneStillRejectsRepeatedPrimaryLegs() + { + var result = validate(LedgerLegCardinality.EXACTLY_ONE, posting("target-1"), posting("target-2")); + + assertThat(result.format(), result.hasIssue(IssueCode.LEG_CARDINALITY_VIOLATED), is(true)); + } + + @Test + public void testOptionalStillRejectsRepeatedPrimaryLegs() + { + var result = validate(LedgerLegCardinality.OPTIONAL, posting("target-1"), posting("target-2")); + + assertThat(result.format(), result.hasIssue(IssueCode.AMBIGUOUS_LEG_MATCH), is(true)); + } + + private LedgerNativeEntryDefinitionValidator.ValidationResult validate(LedgerLegCardinality cardinality, + LedgerPosting... postings) + { + return LedgerNativeEntryDefinitionValidator.validateCardinalityForTesting(entry(), leg(cardinality), + List.of(postings)); + } + + private LedgerEntry entry() + { + var entry = new LedgerEntry("policy-entry"); + + entry.setType(LedgerEntryType.SPIN_OFF); + + return entry; + } + + private LedgerLegDefinition leg(LedgerLegCardinality cardinality) + { + return LedgerLegDefinition.of(LedgerLegRole.TARGET_SECURITY_LEG, LedgerPostingType.SECURITY, cardinality) + .projection(name.abuchen.portfolio.model.ledger.LedgerProjectionRole.NEW_SECURITY_LEG, true, + false) + .build(); + } + + private LedgerPosting posting(String localKey) + { + var posting = new LedgerPosting("posting-" + localKey); + + posting.setType(LedgerPostingType.SECURITY); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + posting.setDirection(LedgerPostingDirection.INBOUND); + posting.setCorporateActionLeg(CorporateActionLeg.TARGET_SECURITY); + posting.setLocalKey(localKey); + posting.setGroupKey("main"); + + return posting; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java index c0cc82b575..0be325fa08 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java @@ -47,7 +47,9 @@ public enum IssueCode PROJECTION_PRIMARY_POSTING_REQUIRED, PROJECTION_PRIMARY_POSTING_MISMATCH, PROJECTION_POSTING_GROUP_REQUIRED, - PROJECTION_POSTING_GROUP_NOT_FOUND + PROJECTION_POSTING_GROUP_NOT_FOUND, + LEG_LOCAL_KEY_REQUIRED, + LEG_LOCAL_KEY_DUPLICATE } private LedgerNativeEntryDefinitionValidator() @@ -272,6 +274,9 @@ else if (!postingMatchesAnyLegWithProjectionRole(entry.getType(), posting, defin .withDetail("legRole", leg.getRole()) //$NON-NLS-1$ .withDetail("projectionRole", projectionRole)); //$NON-NLS-1$ + if (refs.size() > 1 && allowsRepeatedLegs(leg.getCardinality())) + validateDescriptorInstanceKeys(entry, leg, refs, issues); + validateAllowedProjectionRole(definition, entry, leg, projectionRole, issues); return new LegMatch(matchingPostings); @@ -319,6 +324,8 @@ private static void validateCardinality(LedgerEntry entry, LedgerLegDefinition l if (count < 1) issues.add(cardinalityIssue(LedgerDiagnosticCode.LEDGER_STRUCT_050, entry, leg, count, "at least one")); //$NON-NLS-1$ + else + validateRepeatedPrimaryKeys(entry, leg, postings, issues); break; case OPTIONAL: if (count > 1) @@ -330,6 +337,7 @@ private static void validateCardinality(LedgerEntry entry, LedgerLegDefinition l .withDetail("actualCount", count)); //$NON-NLS-1$ break; case REPEATABLE: + validateRepeatedPrimaryKeys(entry, leg, postings, issues); break; default: throw new IllegalStateException(LedgerDiagnosticCode.LEDGER_STRUCT_052 @@ -337,6 +345,90 @@ private static void validateCardinality(LedgerEntry entry, LedgerLegDefinition l } } + static ValidationResult validateCardinalityForTesting(LedgerEntry entry, LedgerLegDefinition leg, + List postings) + { + var issues = new ArrayList(); + + validateCardinality(entry, leg, postings, issues); + + return new ValidationResult(issues); + } + + private static void validateRepeatedPrimaryKeys(LedgerEntry entry, LedgerLegDefinition leg, + List postings, List issues) + { + if (postings.size() <= 1) + return; + + var keys = new java.util.HashSet(); + + for (var posting : postings) + { + if (isBlank(posting.getLocalKey())) + { + issues.add(issue(IssueCode.LEG_LOCAL_KEY_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_051.message( + "Repeated native leg posting requires a localKey: " + leg.getRole()), //$NON-NLS-1$ + entry) + .withPosting(posting) + .withDetail("legRole", leg.getRole())); //$NON-NLS-1$ + continue; + } + + var key = SemanticLegInstanceKey.of(entry, leg, posting); + + if (!keys.add(key)) + issues.add(issue(IssueCode.LEG_LOCAL_KEY_DUPLICATE, + LedgerDiagnosticCode.LEDGER_STRUCT_051.message( + "Repeated native leg posting localKey is duplicated: " //$NON-NLS-1$ + + leg.getRole()), + entry) + .withPosting(posting) + .withDetail("legRole", leg.getRole()) //$NON-NLS-1$ + .withDetail("localKey", posting.getLocalKey())); //$NON-NLS-1$ + } + } + + private static void validateDescriptorInstanceKeys(LedgerEntry entry, LedgerLegDefinition leg, + List descriptors, + List issues) + { + var keys = new java.util.HashSet(); + + for (var descriptor : descriptors) + { + var semanticInstanceKey = descriptor.getSemanticInstanceKey(); + + if (semanticInstanceKey.isEmpty()) + { + issues.add(issue(IssueCode.LEG_LOCAL_KEY_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_051.message( + "Repeated native leg descriptor requires a semantic instance key: " //$NON-NLS-1$ + + leg.getRole()), + entry) + .withPosting(descriptor.getPrimaryPosting()) + .withDetail("legRole", leg.getRole())); //$NON-NLS-1$ + continue; + } + + if (!keys.add(semanticInstanceKey.get())) + issues.add(issue(IssueCode.LEG_LOCAL_KEY_DUPLICATE, + LedgerDiagnosticCode.LEDGER_STRUCT_051.message( + "Repeated native leg descriptor semantic instance key is duplicated: " //$NON-NLS-1$ + + leg.getRole()), + entry) + .withPosting(descriptor.getPrimaryPosting()) + .withDetail("legRole", leg.getRole()) //$NON-NLS-1$ + .withDetail("localKey", semanticInstanceKey.get())); //$NON-NLS-1$ + } + } + + private static boolean allowsRepeatedLegs(LedgerLegCardinality cardinality) + { + return cardinality == LedgerLegCardinality.AT_LEAST_ONE || cardinality == LedgerLegCardinality.REPEATABLE; + } + private static ValidationIssue cardinalityIssue(LedgerDiagnosticCode diagnosticCode, LedgerEntry entry, LedgerLegDefinition leg, int actual, String expected) { @@ -499,11 +591,29 @@ private static Optional parameterValue(List> paramete .findFirst(); } + private static boolean isBlank(String value) + { + return value == null || value.isBlank(); + } + private static ValidationIssue issue(IssueCode code, String message, LedgerEntry entry) { return new ValidationIssue(code, message).withEntry(entry); } + private record SemanticLegInstanceKey(LedgerEntryType entryType, LedgerPostingType postingType, + name.abuchen.portfolio.model.ledger.LedgerPostingDirection direction, + CorporateActionLeg corporateActionLeg, LedgerProjectionRole projectionRole, LedgerLegRole legRole, + String localKey) + { + private static SemanticLegInstanceKey of(LedgerEntry entry, LedgerLegDefinition leg, LedgerPosting posting) + { + return new SemanticLegInstanceKey(entry.getType(), leg.getPostingType(), posting.getDirection(), + posting.getCorporateActionLeg(), leg.getProjectionRole().orElse(null), leg.getRole(), + posting.getLocalKey()); + } + } + private record LegMatch(List postings) { private LegMatch From 22a298439e8a6bb1a16b59acfdc318a4e84a316c Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Fri, 3 Jul 2026 14:09:41 +0200 Subject: [PATCH 43/68] Allow repeated Ledger spin-off movement legs Relaxes the SPIN_OFF native leg definition so target security legs are at least one and cash compensation legs are repeatable, and aligns structural validation with semantic local keys. This enables no-UUID 1..n corporate action movement service coverage while keeping repeated instances keyed by localKey and groupKey. Assembler APIs, protobuf schema, XML schema, other corporate action definitions, and legacy PTransaction types are unchanged. --- ...ateActionMultiMovementPersistenceTest.java | 192 +++++++++++++++++- .../ledger/LedgerEntryDefinitionTest.java | 4 +- ...gerNativeEntryDefinitionValidatorTest.java | 51 ++++- .../ledger/LedgerStructuralValidatorTest.java | 70 +++++++ ...erivedProjectionDescriptorServiceTest.java | 4 + .../ledger/LedgerStructuralValidator.java | 76 ++++++- .../LedgerEntryDefinitionRegistry.java | 4 +- 7 files changed, 382 insertions(+), 19 deletions(-) diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java index 84cfda3fd0..972c410fb2 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java @@ -9,6 +9,7 @@ import java.io.ByteArrayInputStream; import java.io.File; import java.io.IOException; +import java.math.BigDecimal; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.time.Instant; @@ -27,16 +28,22 @@ import name.abuchen.portfolio.model.ProtobufTestUtilities; 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.LedgerNativeEntryDefinitionValidator; +import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor; import name.abuchen.portfolio.model.proto.v1.PClient; +import name.abuchen.portfolio.model.proto.v1.PTransaction; import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Money; import name.abuchen.portfolio.money.Values; /** - * Model/persistence proof only: current SPIN_OFF definition and descriptor acceptance for - * repeated target legs is intentionally not asserted here. Projection derivation for repeated - * target/cash movement legs is a later validator/descriptor/assembler feature. + * Model/persistence proof for repeated corporate-action movement legs. + * SPIN_OFF coverage here is low-level service coverage; assembler/UI support is intentionally + * outside this slice. */ @SuppressWarnings("nls") public class LedgerCorporateActionMultiMovementPersistenceTest @@ -65,6 +72,53 @@ public void testXmlRoundtripPreservesRepeatedCorporateActionMovementLegs() throw assertValid(loaded); } + @Test + public void testXmlRoundtripPreservesRepeatedSpinOffMovementLegs() throws Exception + { + var fixture = spinOffFixture(); + + assertRepeatedSpinOff(fixture.entry()); + assertValid(fixture.client()); + assertNativeDefinitionValid(fixture.entry()); + + var xml = saveXml(fixture.client()); + + assertNoLedgerUuidTruth(xml); + + var loaded = loadXml(xml); + var loadedEntry = onlyLedgerEntry(loaded); + + assertRepeatedSpinOff(loadedEntry); + assertValid(loaded); + assertNativeDefinitionValid(loadedEntry); + } + + @Test + public void testProtobufRoundtripPreservesRepeatedSpinOffMovementLegs() throws Exception + { + var fixture = spinOffFixture(); + + assertRepeatedSpinOff(fixture.entry()); + assertValid(fixture.client()); + assertNativeDefinitionValid(fixture.entry()); + + var bytes = ProtobufTestUtilities.save(fixture.client()); + var proto = parseProto(bytes); + var protoEntry = proto.getLedger().getEntries(0); + + assertThat(proto.getLedger().getEntriesCount(), is(1)); + assertThat(protoEntry.getTypeCode(), is(LedgerEntryType.SPIN_OFF.getCode())); + assertThat(protoEntry.getPostingsCount(), is(7)); + assertNoCorporateActionSpecificLegacyTransactionType(proto); + + var loaded = ProtobufTestUtilities.load(bytes); + var loadedEntry = onlyLedgerEntry(loaded); + + assertRepeatedSpinOff(loadedEntry); + assertValid(loaded); + assertNativeDefinitionValid(loadedEntry); + } + @Test public void testProtobufRoundtripPreservesRepeatedCorporateActionMovementLegs() throws Exception { @@ -139,6 +193,61 @@ private Fixture fixture() return new Fixture(client, entry); } + private Fixture spinOffFixture() + { + var client = new Client(); + var account = new Account(); + var portfolio = new Portfolio(); + var sourceSecurity = new Security("Source AG", CurrencyUnit.EUR); + var targetSecurityA = new Security("Target A AG", CurrencyUnit.EUR); + var targetSecurityB = new Security("Target B AG", CurrencyUnit.EUR); + + account.setName("Cash Account"); + account.setCurrencyCode(CurrencyUnit.EUR); + portfolio.setName("Portfolio"); + portfolio.setReferenceAccount(account); + account.setUpdatedAt(UPDATED_AT); + portfolio.setUpdatedAt(UPDATED_AT); + sourceSecurity.setUpdatedAt(UPDATED_AT); + targetSecurityA.setUpdatedAt(UPDATED_AT); + targetSecurityB.setUpdatedAt(UPDATED_AT); + + client.addAccount(account); + client.addPortfolio(portfolio); + client.addSecurity(sourceSecurity); + client.addSecurity(targetSecurityA); + client.addSecurity(targetSecurityB); + + var entry = new LedgerEntry("spin-off-repeated"); + entry.setType(LedgerEntryType.SPIN_OFF); + entry.setDateTime(DATE_TIME); + entry.setSource("SPIN_OFF repeated movement proof"); + entry.setNote("core service proof only"); + entry.addParameter(LedgerParameter.ofCode(LedgerParameterType.CORPORATE_ACTION_KIND, + CorporateActionKind.SPIN_OFF)); + entry.addParameter(LedgerParameter.ofLocalDate(LedgerParameterType.EFFECTIVE_DATE, DATE_TIME.toLocalDate())); + + entry.addPosting(spinOffSecurityPosting(portfolio, sourceSecurity, sourceSecurity, targetSecurityA, + LedgerPostingDirection.OUTBOUND, CorporateActionLeg.SOURCE_SECURITY, "source", "source-1", + 10, 100)); + entry.addPosting(spinOffSecurityPosting(portfolio, targetSecurityA, sourceSecurity, targetSecurityA, + LedgerPostingDirection.INBOUND, CorporateActionLeg.TARGET_SECURITY, "main", "target-1", 3, + 60)); + entry.addPosting(spinOffSecurityPosting(portfolio, targetSecurityB, sourceSecurity, targetSecurityB, + LedgerPostingDirection.INBOUND, CorporateActionLeg.TARGET_SECURITY, "main", "target-2", 7, + 40)); + entry.addPosting(spinOffCashPosting(account, "cash-1", "cash-1", 11)); + entry.addPosting(spinOffCashPosting(account, "cash-2", "cash-2", 22)); + entry.addPosting(unitPosting(LedgerPostingType.FEE, LedgerPostingSemanticRole.FEE, + LedgerPostingUnitRole.FEE, CorporateActionLeg.FEE, "cash-1", "fee-1", 2)); + entry.addPosting(unitPosting(LedgerPostingType.TAX, LedgerPostingSemanticRole.TAX, + LedgerPostingUnitRole.TAX, CorporateActionLeg.TAX, "cash-1", "tax-1", 4)); + + client.getLedger().addEntry(entry); + + return new Fixture(client, entry); + } + private LedgerPosting securityPosting(Portfolio portfolio, Security security, LedgerPostingDirection direction, CorporateActionLeg leg, String groupKey, String localKey, long shares, long amount) { @@ -160,6 +269,33 @@ private LedgerPosting securityPosting(Portfolio portfolio, Security security, Le return posting; } + private LedgerPosting spinOffSecurityPosting(Portfolio portfolio, Security security, Security sourceSecurity, + Security targetSecurity, LedgerPostingDirection direction, CorporateActionLeg leg, String groupKey, + String localKey, long shares, long amount) + { + var posting = securityPosting(portfolio, security, direction, leg, groupKey, localKey, shares, amount); + + posting.addParameter(LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_LEG, leg.getCode())); + posting.addParameter(LedgerParameter.ofSecurity(LedgerParameterType.SOURCE_SECURITY, sourceSecurity)); + posting.addParameter(LedgerParameter.ofSecurity(LedgerParameterType.TARGET_SECURITY, targetSecurity)); + posting.addParameter(LedgerParameter.ofDecimal(LedgerParameterType.RATIO_NUMERATOR, BigDecimal.ONE)); + posting.addParameter(LedgerParameter.ofDecimal(LedgerParameterType.RATIO_DENOMINATOR, BigDecimal.TEN)); + + return posting; + } + + private LedgerPosting spinOffCashPosting(Account account, String groupKey, String localKey, long amount) + { + var posting = cashPosting(account, LedgerPostingDirection.NEUTRAL, groupKey, localKey, amount); + + posting.addParameter(LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_LEG, + CorporateActionLeg.CASH_COMPENSATION.getCode())); + posting.addParameter(LedgerParameter.ofMoney(LedgerParameterType.CASH_IN_LIEU_AMOUNT, + Money.of(CurrencyUnit.EUR, Values.Amount.factorize(amount)))); + + return posting; + } + private LedgerPosting cashPosting(Account account, LedgerPostingDirection direction, String groupKey, String localKey, long amount) { @@ -227,6 +363,38 @@ private void assertMultiMovementProof(LedgerEntry entry) assertThat(entry.getPostings().stream().map(LedgerPosting::getLocalKey).toList(), hasItem("target-2")); } + private void assertRepeatedSpinOff(LedgerEntry entry) + { + assertThat(entry.getType(), is(LedgerEntryType.SPIN_OFF)); + assertThat(entry.getPostings().size(), is(7)); + + var targetSecurities = postings(entry, LedgerPostingType.SECURITY, LedgerPostingDirection.INBOUND, + CorporateActionLeg.TARGET_SECURITY); + var cashMovements = postings(entry, LedgerPostingType.CASH_COMPENSATION, LedgerPostingDirection.NEUTRAL, + CorporateActionLeg.CASH_COMPENSATION); + var descriptors = LedgerDescriptorTestSupport.descriptors(entry); + var targetDescriptors = descriptors.stream() + .filter(descriptor -> descriptor.getRole() == LedgerProjectionRole.NEW_SECURITY_LEG) + .toList(); + + assertThat(postings(entry, LedgerPostingType.SECURITY, LedgerPostingDirection.OUTBOUND, + CorporateActionLeg.SOURCE_SECURITY).size(), is(1)); + assertThat(targetSecurities.size(), is(2)); + assertThat(localKeys(targetSecurities), is(Set.of("target-1", "target-2"))); + assertThat(cashMovements.size(), is(2)); + assertThat(localKeys(cashMovements), is(Set.of("cash-1", "cash-2"))); + assertThat(groupKeys(cashMovements), is(Set.of("cash-1", "cash-2"))); + assertThat(targetDescriptors.size(), is(2)); + assertThat(targetDescriptors.stream().map(descriptor -> descriptor.getSemanticInstanceKey().orElseThrow()) + .collect(Collectors.toSet()), is(Set.of("target-1", "target-2"))); + var runtimeProjectionIds = targetDescriptors.stream().map(DerivedProjectionDescriptor::getRuntimeProjectionId) + .collect(Collectors.toSet()); + + assertThat(runtimeProjectionIds.size(), is(2)); + assertTrue(runtimeProjectionIds.stream().anyMatch(id -> id.endsWith(":NEW_SECURITY_LEG:target-1"))); + assertTrue(runtimeProjectionIds.stream().anyMatch(id -> id.endsWith(":NEW_SECURITY_LEG:target-2"))); + } + private List postings(LedgerEntry entry, LedgerPostingType type, LedgerPostingDirection direction, CorporateActionLeg leg) { @@ -285,6 +453,13 @@ private void assertValid(Client client) LedgerStructuralValidator.validate(client.getLedger()).isOK()); } + private void assertNativeDefinitionValid(LedgerEntry entry) + { + var result = LedgerNativeEntryDefinitionValidator.validate(entry); + + assertTrue(result.format(), result.isOK()); + } + private void assertNoLedgerUuidTruth(String xml) { var ledgerXml = ledgerSection(xml); @@ -319,6 +494,17 @@ private void assertNoLegacyTransactionTypeForProofEntry() .anyMatch(LedgerEntryType.CORPORATE_ACTION_MOVEMENT_CONFIRMATION.getCode()::equals)); } + private void assertNoCorporateActionSpecificLegacyTransactionType(PClient proto) + { + for (var transaction : proto.getTransactionsList()) + assertFalse(Set.of("SPIN_OFF", "STOCK_DIVIDEND", "BONUS_ISSUE", "RIGHTS_DISTRIBUTION", + "BOND_CONVERSION").contains(transaction.getType().name())); + + assertFalse(Arrays.stream(PTransaction.Type.values()).map(Enum::name) + .anyMatch(Set.of("SPIN_OFF", "STOCK_DIVIDEND", "BONUS_ISSUE", + "RIGHTS_DISTRIBUTION", "BOND_CONVERSION")::contains)); + } + private record Fixture(Client client, LedgerEntry entry) { } 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 index 6d8537ca01..22a8dbad55 100644 --- 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 @@ -275,7 +275,7 @@ public void testSpinOffDefinitionDescribesFunctionalLegs() assertFalse(sourceLeg.isPostingGroupExpected()); var targetLeg = assertLeg(definition, LedgerLegRole.TARGET_SECURITY_LEG, LedgerPostingType.SECURITY, - LedgerLegCardinality.EXACTLY_ONE); + LedgerLegCardinality.AT_LEAST_ONE); assertTrue(targetLeg.getRequiredParameterTypes().contains(LedgerParameterType.CORPORATE_ACTION_LEG)); assertTrue(targetLeg.getRequiredParameterTypes().contains(LedgerParameterType.TARGET_SECURITY)); assertTrue(targetLeg.getRequiredParameterTypes().contains(LedgerParameterType.RATIO_NUMERATOR)); @@ -286,7 +286,7 @@ public void testSpinOffDefinitionDescribesFunctionalLegs() assertFalse(targetLeg.isPostingGroupExpected()); var cashLeg = assertLeg(definition, LedgerLegRole.CASH_COMPENSATION_LEG, - LedgerPostingType.CASH_COMPENSATION, LedgerLegCardinality.OPTIONAL); + LedgerPostingType.CASH_COMPENSATION, LedgerLegCardinality.REPEATABLE); assertThat(cashLeg.getProjectionRole().orElseThrow(), is(LedgerProjectionRole.CASH_COMPENSATION)); assertTrue(cashLeg.isPrimaryPostingExpected()); assertTrue(cashLeg.isPostingGroupExpected()); diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java index f24c6dc1af..8184c91324 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java @@ -159,14 +159,8 @@ public void testDuplicateSourceLegIsRejectedAsAmbiguous() assertIssue(entry, IssueCode.REQUIRED_PROJECTION_MISSING); } - /** - * Checks that semantic instance keys do not widen the current SPIN_OFF - * definition by themselves. - * Two target security legs remain invalid until the definition is explicitly - * changed from exactly one to a repeatable cardinality in a later slice. - */ @Test - public void testCurrentSpinOffDefinitionStillRejectsRepeatedTargetLegs() + public void testSpinOffDefinitionAcceptsRepeatedTargetLegsWithDistinctLocalKeys() { var entry = copyValidSpinOff(); var targetPosting = postingFor(entry, LedgerProjectionRole.NEW_SECURITY_LEG); @@ -179,7 +173,41 @@ public void testCurrentSpinOffDefinitionStillRejectsRepeatedTargetLegs() duplicateTarget.setGroupKey("main"); entry.addPosting(duplicateTarget); - assertIssue(entry, IssueCode.LEG_CARDINALITY_VIOLATED); + assertOK(entry); + } + + @Test + public void testSpinOffDefinitionAcceptsRepeatedCashCompensationWithDistinctLocalKeys() + { + var entry = copyValidSpinOff(); + var compensation = postingFor(entry, LedgerProjectionRole.CASH_COMPENSATION); + var duplicateCompensation = LedgerModelCopy.copyPosting(compensation); + + compensation.setLocalKey("cash-1"); + compensation.setGroupKey("cash-1"); + duplicateCompensation.setUUID("duplicate-cash-compensation"); + duplicateCompensation.setLocalKey("cash-2"); + duplicateCompensation.setGroupKey("cash-2"); + entry.addPosting(duplicateCompensation); + + assertOK(entry); + } + + @Test + public void testSpinOffDefinitionRejectsDuplicateCashCompensationLocalKey() + { + var entry = copyValidSpinOff(); + var compensation = postingFor(entry, LedgerProjectionRole.CASH_COMPENSATION); + var duplicateCompensation = LedgerModelCopy.copyPosting(compensation); + + compensation.setLocalKey("cash-1"); + compensation.setGroupKey("cash-1"); + duplicateCompensation.setUUID("duplicate-cash-compensation"); + duplicateCompensation.setLocalKey("cash-1"); + duplicateCompensation.setGroupKey("cash-2"); + entry.addPosting(duplicateCompensation); + + assertIssue(entry, IssueCode.REQUIRED_PROJECTION_MISSING); } /** @@ -378,6 +406,13 @@ private static void assertIssue(LedgerEntry entry, IssueCode code) assertTrue(result.format(), result.format().contains("[LEDGER-STRUCT-")); } + private static void assertOK(LedgerEntry entry) + { + var result = LedgerNativeEntryDefinitionValidator.validate(entry); + + assertTrue(result.format(), result.isOK()); + } + private static LedgerEntry copyValidSpinOff() { return LedgerModelCopy.copyEntry(validSpinOff(fixture()).buildDetached().getEntry()); 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 index 3105ed43d1..bcaabdea57 100644 --- 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 @@ -173,6 +173,67 @@ public void testDuplicateCorporateActionLegWithoutLocalKeyIsRejected() assertIssue(entry, IssueCode.SEMANTIC_LOCAL_KEY_REQUIRED); } + @Test + public void testSpinOffAcceptsRepeatedTargetLegsWithDistinctLocalKeys() + { + var entry = nativeEntry(LedgerEntryType.SPIN_OFF); + 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.SPIN_OFF); + 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 testSpinOffWithoutTargetLegIsRejected() + { + var entry = nativeEntry(LedgerEntryType.SPIN_OFF); + entry.removePosting(posting(entry, CorporateActionLeg.TARGET_SECURITY)); + + assertIssue(entry, IssueCode.SEMANTIC_TARGET_REQUIRED); + } + + @Test + public void testSpinOffAcceptsRepeatedCashCompensationWithDistinctLocalKeys() + { + var entry = nativeEntry(LedgerEntryType.SPIN_OFF); + + 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.SPIN_OFF); + + 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)); @@ -299,6 +360,15 @@ private LedgerPosting securityPosting(String localKey, LedgerPostingDirection di 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); diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorServiceTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorServiceTest.java index fbde2cd0f5..80042b7df3 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorServiceTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorServiceTest.java @@ -258,6 +258,10 @@ public void testRoleOnlyProjectionLookupRejectsRepeatedDescriptorRole() var projection = factory.createProjection(entry, LedgerProjectionRole.NEW_SECURITY_LEG, "target-1"); assertThat(projection.getUUID(), is("spin-off-repeated:NEW_SECURITY_LEG:target-1")); + + projection = factory.createProjection(entry, LedgerProjectionRole.NEW_SECURITY_LEG, "target-2"); + + assertThat(projection.getUUID(), is("spin-off-repeated:NEW_SECURITY_LEG:target-2")); } @Test 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 index 178a0e6bd4..78c6764b12 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerStructuralValidator.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerStructuralValidator.java @@ -218,12 +218,12 @@ private static void validateSemanticShape(LedgerEntry entry, List { requireCorporatePrimary(entry, LedgerProjectionRole.DELIVERY_INBOUND, @@ -330,6 +330,27 @@ private static void requireOneOfCorporatePrimary(LedgerEntry entry, LedgerProjec validatePrimaryMatches(entry, role, OwnerKind.PORTFOLIO, optional, matches, 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) @@ -365,6 +386,53 @@ private static void validatePrimaryMatches(LedgerEntry entry, LedgerProjectionRo 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) 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 index e5c25cee94..fe40dfb682 100644 --- 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 @@ -435,7 +435,7 @@ private static Set spinOffLegDefinitions() .optionalParameters(spinOffSourceSecurityLegOptionalParameters()) .projection(LedgerProjectionRole.OLD_SECURITY_LEG, true, false).build(), LedgerLegDefinition.of(LedgerLegRole.TARGET_SECURITY_LEG, LedgerPostingType.SECURITY, - LedgerLegCardinality.EXACTLY_ONE) + LedgerLegCardinality.AT_LEAST_ONE) .requiredParameters(SETS.parameterTypes( LedgerParameterType.CORPORATE_ACTION_LEG, LedgerParameterType.TARGET_SECURITY, @@ -444,7 +444,7 @@ private static Set spinOffLegDefinitions() .optionalParameters(spinOffTargetSecurityLegOptionalParameters()) .projection(LedgerProjectionRole.NEW_SECURITY_LEG, true, false).build(), LedgerLegDefinition.of(LedgerLegRole.CASH_COMPENSATION_LEG, - LedgerPostingType.CASH_COMPENSATION, LedgerLegCardinality.OPTIONAL) + LedgerPostingType.CASH_COMPENSATION, LedgerLegCardinality.REPEATABLE) .optionalParameters(cashCompensationOptionalParameters()) .projection(LedgerProjectionRole.CASH_COMPENSATION, true, true) .group(CASH_COMPENSATION_GROUP).build(), From 1f61064cb7036f5eaaa14239b512fb2c9c95951d Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Fri, 3 Jul 2026 14:26:10 +0200 Subject: [PATCH 44/68] Add Ledger native repeated movement assembler API Extend native entry assembler input objects with optional semantic group and local keys, and make spin-off cash compensation list-backed while preserving the existing single-movement convenience method. This lets assembler callers build repeated spin-off target and cash compensation movements, plus grouped fee and tax units, using semantic keys instead of UUID selectors. Existing native Corporate Action definitions, descriptor logic, validators, protobuf schema, and legacy PTransaction mappings are intentionally left unchanged. --- .../LedgerNativeEntryAssemblerTest.java | 172 ++++++++++++++++++ .../LedgerNativeEntryAssembler.java | 48 ++++- .../nativeentry/NativeCashCompensation.java | 28 +++ .../model/ledger/nativeentry/NativeFee.java | 28 +++ .../ledger/nativeentry/NativeSecurityLeg.java | 28 +++ .../model/ledger/nativeentry/NativeTax.java | 28 +++ 6 files changed, 323 insertions(+), 9 deletions(-) diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblerTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblerTest.java index 8d3b0457e7..2e8ea8860f 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblerTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblerTest.java @@ -429,6 +429,107 @@ public void testNativeAssemblerEmitsSemanticPostingsForSiemensSpinOff() .findFirst().orElseThrow().getUnitRole(), is(LedgerPostingUnitRole.TAX)); } + @Test + public void testNativeAssemblerCreatesRepeatedSpinOffMovementLegsWithSemanticKeys() + { + var fixture = fixture(); + var secondTarget = new Security("Siemens Healthineers AG", CurrencyUnit.EUR); + secondTarget.setIsin("DE000SHL1006"); + fixture.client.addSecurity(secondTarget); + + var entry = repeatedSpinOff(fixture, secondTarget).buildDetached().getEntry(); + var targetPostings = postings(entry, LedgerPostingType.SECURITY, CorporateActionLeg.TARGET_SECURITY); + var cashPostings = postings(entry, LedgerPostingType.CASH_COMPENSATION, + CorporateActionLeg.CASH_COMPENSATION); + var targetDescriptors = descriptors(entry).stream() + .filter(descriptor -> descriptor.getRole() == LedgerProjectionRole.NEW_SECURITY_LEG) + .toList(); + var cashOneDescriptor = descriptor(entry, LedgerProjectionRole.CASH_COMPENSATION, "cash-1"); + var cashTwoDescriptor = descriptor(entry, LedgerProjectionRole.CASH_COMPENSATION, "cash-2"); + + assertThat(targetPostings.size(), is(2)); + assertThat(targetPostings.stream().map(LedgerPosting::getLocalKey).collect(Collectors.toSet()), + is(java.util.Set.of("target-1", "target-2"))); + assertThat(targetPostings.stream().map(LedgerPosting::getGroupKey).collect(Collectors.toSet()), + is(java.util.Set.of("main"))); + + assertThat(cashPostings.size(), is(2)); + assertThat(cashPostings.stream().map(LedgerPosting::getLocalKey).collect(Collectors.toSet()), + is(java.util.Set.of("cash-1", "cash-2"))); + assertThat(cashPostings.stream().map(LedgerPosting::getGroupKey).collect(Collectors.toSet()), + is(java.util.Set.of("cash-1", "cash-2"))); + + assertThat(targetDescriptors.size(), is(2)); + assertThat(targetDescriptors.stream().map(DerivedProjectionDescriptor::getSemanticInstanceKey) + .map(Optional::orElseThrow).collect(Collectors.toSet()), + is(java.util.Set.of("target-1", "target-2"))); + assertThat(targetDescriptors.stream().map(DerivedProjectionDescriptor::getRuntimeProjectionId) + .collect(Collectors.toSet()).size(), is(2)); + assertThat(descriptor(entry, LedgerProjectionRole.NEW_SECURITY_LEG, "target-1").getPrimaryPosting() + .getSecurity(), is(fixture.siemensEnergy)); + assertThat(descriptor(entry, LedgerProjectionRole.NEW_SECURITY_LEG, "target-2").getPrimaryPosting() + .getSecurity(), is(secondTarget)); + + assertThat(cashOneDescriptor.getUnitPostings().stream().map(LedgerPosting::getLocalKey) + .collect(Collectors.toSet()), is(java.util.Set.of("fee-1", "tax-1"))); + assertThat(cashTwoDescriptor.getUnitPostings().isEmpty(), is(true)); + assertTrue(LedgerStructuralValidator.validate(ledger(entry)).isOK()); + } + + @Test + public void testNativeAssemblerRepeatedTargetDuplicateLocalKeyIsRejected() + { + var fixture = fixture(); + var secondTarget = new Security("Siemens Healthineers AG", CurrencyUnit.EUR); + fixture.client.addSecurity(secondTarget); + + var exception = assertThrows(LedgerNativeEntryAssemblyException.class, + () -> baseSpinOff(fixture) // + .securityLeg(sourceLeg(fixture).build()) // + .securityLeg(targetLeg(fixture).groupKey("main").localKey("target-1").build()) // + .securityLeg(targetLeg(fixture).security(secondTarget) + .targetSecurity(secondTarget).groupKey("main") + .localKey("target-1").build()) // + .buildDetached()); + + assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.NATIVE_DEFINITION_VALIDATION_FAILED)); + } + + @Test + public void testNativeAssemblerRepeatedTargetBlankLocalKeyIsRejected() + { + var fixture = fixture(); + var secondTarget = new Security("Siemens Healthineers AG", CurrencyUnit.EUR); + fixture.client.addSecurity(secondTarget); + + var exception = assertThrows(LedgerNativeEntryAssemblyException.class, + () -> baseSpinOff(fixture) // + .securityLeg(sourceLeg(fixture).build()) // + .securityLeg(targetLeg(fixture).groupKey("main").localKey("target-1").build()) // + .securityLeg(targetLeg(fixture).security(secondTarget) + .targetSecurity(secondTarget).groupKey("main") + .localKey(" ").build()) // + .buildDetached()); + + assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.NATIVE_DEFINITION_VALIDATION_FAILED)); + } + + @Test + public void testNativeAssemblerRepeatedCashDuplicateLocalKeyIsRejected() + { + var fixture = fixture(); + + var exception = assertThrows(LedgerNativeEntryAssemblyException.class, + () -> baseSpinOff(fixture) // + .securityLeg(sourceLeg(fixture).build()) // + .securityLeg(targetLeg(fixture).build()) // + .cashCompensation(cashCompensation(fixture, "cash-1", "cash-1", 5)) // + .cashCompensationMovement(cashCompensation(fixture, "cash-2", "cash-1", 7)) // + .buildDetached()); + + assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.NATIVE_DEFINITION_VALIDATION_FAILED)); + } + @Test public void testNativeAssemblerDescriptorsCoverCorporateActionFamilies() { @@ -668,6 +769,51 @@ private static LedgerNativeEntryAssembler.EntryBuilder spinOffWithRetainedAndNew .tax(NativeTax.withholding(fixture.account, money(1))); } + private static LedgerNativeEntryAssembler.EntryBuilder repeatedSpinOff(Fixture fixture, Security secondTarget) + { + return baseSpinOff(fixture) // + .securityLeg(sourceLeg(fixture).build()) // + .securityLeg(targetLeg(fixture).groupKey("main").localKey("target-1").build()) // + .securityLeg(targetLeg(fixture) // + .security(secondTarget) // + .targetSecurity(secondTarget) // + .shares(Values.Share.factorize(7)) // + .amount(money(70)) // + .groupKey("main") // + .localKey("target-2") // + .build()) // + .cashCompensation(cashCompensation(fixture, "cash-1", "cash-1", 5)) // + .cashCompensationMovement(cashCompensation(fixture, "cash-2", "cash-2", 7)) // + .fee(NativeFee.builder() // + .account(fixture.account) // + .amount(money(2)) // + .reason(FeeReason.CORPORATE_ACTION_FEE) // + .groupKey("cash-1") // + .localKey("fee-1") // + .build()) // + .tax(NativeTax.builder() // + .account(fixture.account) // + .amount(money(1)) // + .reason(TaxReason.WITHHOLDING_TAX) // + .withholdingTax(true) // + .groupKey("cash-1") // + .localKey("tax-1") // + .build()); + } + + private static NativeCashCompensation cashCompensation(Fixture fixture, String groupKey, String localKey, + long amount) + { + return NativeCashCompensation.builder() // + .account(fixture.account) // + .amount(money(amount)) // + .kind(CashCompensationKind.CASH_IN_LIEU) // + .applied(true) // + .groupKey(groupKey) // + .localKey(localKey) // + .build(); + } + private static LedgerNativeEntryAssembler.EntryBuilder stockDividendEntry(Fixture fixture) { return LedgerNativeEntryAssembler.forClient(fixture.client) // @@ -799,6 +945,16 @@ private static DerivedProjectionDescriptor descriptor(name.abuchen.portfolio.mod .orElseThrow(); } + private static DerivedProjectionDescriptor descriptor(name.abuchen.portfolio.model.ledger.LedgerEntry entry, + LedgerProjectionRole role, String semanticInstanceKey) + { + return descriptors(entry).stream() // + .filter(descriptor -> descriptor.getRole() == role) // + .filter(descriptor -> descriptor.getSemanticInstanceKey() + .filter(semanticInstanceKey::equals).isPresent()) + .findFirst().orElseThrow(); + } + private static java.util.List descriptors( name.abuchen.portfolio.model.ledger.LedgerEntry entry) { @@ -815,6 +971,22 @@ private static java.util.Set roles(name.abuchen.portfolio. .collect(Collectors.toSet()); } + private static java.util.List postings(name.abuchen.portfolio.model.ledger.LedgerEntry entry, + LedgerPostingType type, CorporateActionLeg leg) + { + return entry.getPostings().stream() // + .filter(posting -> posting.getType() == type) // + .filter(posting -> posting.getCorporateActionLeg() == leg) // + .toList(); + } + + private static Ledger ledger(name.abuchen.portfolio.model.ledger.LedgerEntry entry) + { + var ledger = new Ledger(); + ledger.addEntry(entry); + return ledger; + } + private static void assertPrimarySemantics(LedgerPosting posting, LedgerPostingSemanticRole semanticRole, LedgerPostingDirection direction, CorporateActionLeg leg, LedgerProjectionRole role) { diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssembler.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssembler.java index 435e4ecf1a..2b4b1da899 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssembler.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssembler.java @@ -104,11 +104,11 @@ public static final class EntryBuilder private final LedgerEntryDefinition definition; private final Function> postingDefinitionLookup; private final List securityLegs = new ArrayList<>(); + private final List cashCompensations = new ArrayList<>(); private final List fees = new ArrayList<>(); private final List taxes = new ArrayList<>(); private NativeEntryMetadata metadata; private NativeCorporateActionEvent event; - private NativeCashCompensation cashCompensation; private EntryBuilder(Client client, LedgerEntryDefinition definition, Function> postingDefinitionLookup) @@ -138,7 +138,13 @@ public EntryBuilder securityLeg(NativeSecurityLeg leg) public EntryBuilder cashCompensation(NativeCashCompensation compensation) { - this.cashCompensation = Objects.requireNonNull(compensation); + cashCompensations.add(Objects.requireNonNull(compensation)); + return this; + } + + public EntryBuilder cashCompensationMovement(NativeCashCompensation compensation) + { + cashCompensations.add(Objects.requireNonNull(compensation)); return this; } @@ -165,9 +171,10 @@ public LedgerNativeEntryBuildResult buildDetached() for (var leg : securityLegs) addSecurityLeg(entry, leg); - LedgerPosting compensationPosting = null; - if (cashCompensation != null) - compensationPosting = addCashCompensation(entry, cashCompensation); + var compensationPostings = new ArrayList(); + for (var compensation : cashCompensations) + compensationPostings.add(new CashCompensationPosting(compensation, + addCashCompensation(entry, compensation))); for (var fee : fees) addFee(entry, fee); @@ -175,8 +182,9 @@ public LedgerNativeEntryBuildResult buildDetached() for (var tax : taxes) addTax(entry, tax); - if (compensationPosting != null) - addCashCompensationProjection(entry, cashCompensation, compensationPosting); + for (var compensationPosting : compensationPostings) + addCashCompensationProjection(entry, compensationPosting.compensation(), + compensationPosting.posting()); var definitionValidationResult = LedgerNativeEntryDefinitionValidator.validate(entry); @@ -243,6 +251,7 @@ private void addSecurityLeg(LedgerEntry entry, NativeSecurityLeg leg) applyMoney(posting, leg.getAmount()); markPrimary(posting, semanticRole(leg.getPostingType()), direction(leg.getProjectionRole()), corporateActionLeg(leg.getLegCode()), leg.getProjectionRole()); + applySemanticKeys(posting, leg.getGroupKey(), leg.getLocalKey()); if (leg.getLegCode() != null) addPostingParameter(posting, leg.getPostingType(), @@ -267,6 +276,7 @@ private LedgerPosting addCashCompensation(LedgerEntry entry, NativeCashCompensat applyMoney(posting, compensation.getAmount()); markPrimary(posting, LedgerPostingSemanticRole.CASH_COMPENSATION, LedgerPostingDirection.NEUTRAL, CorporateActionLeg.CASH_COMPENSATION, LedgerProjectionRole.CASH_COMPENSATION); + applySemanticKeys(posting, compensation.getGroupKey(), compensation.getLocalKey()); if (compensation.getAccount() != null) addPostingParameter(posting, LedgerPostingType.CASH_COMPENSATION, @@ -306,7 +316,8 @@ private void addFee(LedgerEntry entry, NativeFee fee) posting.setAccount(fee.getAccount()); applyMoney(posting, fee.getAmount()); markUnit(posting, LedgerPostingSemanticRole.FEE, LedgerPostingUnitRole.FEE, - LedgerProjectionRole.CASH_COMPENSATION.name()); + semanticGroupKey(fee.getGroupKey(), LedgerProjectionRole.CASH_COMPENSATION.name())); + posting.setLocalKey(fee.getLocalKey()); addPostingParameter(posting, LedgerPostingType.FEE, new NativeParameterValue(LedgerParameterType.CORPORATE_ACTION_LEG, CorporateActionLeg.FEE.getCode())); @@ -326,7 +337,8 @@ private void addTax(LedgerEntry entry, NativeTax tax) posting.setAccount(tax.getAccount()); applyMoney(posting, tax.getAmount()); markUnit(posting, LedgerPostingSemanticRole.TAX, LedgerPostingUnitRole.TAX, - LedgerProjectionRole.CASH_COMPENSATION.name()); + semanticGroupKey(tax.getGroupKey(), LedgerProjectionRole.CASH_COMPENSATION.name())); + posting.setLocalKey(tax.getLocalKey()); addPostingParameter(posting, LedgerPostingType.TAX, new NativeParameterValue(LedgerParameterType.CORPORATE_ACTION_LEG, CorporateActionLeg.TAX.getCode())); @@ -362,6 +374,20 @@ private void markPrimary(LedgerPosting posting, LedgerPostingSemanticRole semant } } + private void applySemanticKeys(LedgerPosting posting, String groupKey, String localKey) + { + if (groupKey != null) + posting.setGroupKey(groupKey); + + if (localKey != null) + posting.setLocalKey(localKey); + } + + private String semanticGroupKey(String groupKey, String defaultGroupKey) + { + return groupKey == null ? defaultGroupKey : groupKey; + } + private void markUnit(LedgerPosting posting, LedgerPostingSemanticRole semanticRole, LedgerPostingUnitRole unitRole, String groupKey) { @@ -537,5 +563,9 @@ private static ProjectionIntent account(LedgerProjectionRole role, Account accou return new ProjectionIntent(role, account, null, primaryPosting, postingGroup); } } + + private record CashCompensationPosting(NativeCashCompensation compensation, LedgerPosting posting) + { + } } } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeCashCompensation.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeCashCompensation.java index 7239ca4abd..e046c00182 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeCashCompensation.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeCashCompensation.java @@ -25,12 +25,16 @@ public final class NativeCashCompensation { private final Account account; private final Money amount; + private final String groupKey; + private final String localKey; private final List parameters; private NativeCashCompensation(Builder builder) { this.account = builder.account; this.amount = builder.amount; + this.groupKey = builder.groupKey; + this.localKey = builder.localKey; this.parameters = List.copyOf(builder.parameters); } @@ -49,6 +53,16 @@ Money getAmount() return amount; } + String getGroupKey() + { + return groupKey; + } + + String getLocalKey() + { + return localKey; + } + List getParameters() { return parameters; @@ -58,6 +72,8 @@ public static final class Builder { private Account account; private Money amount; + private String groupKey; + private String localKey; private final List parameters = new ArrayList<>(); private Builder() @@ -76,6 +92,18 @@ public Builder amount(Money amount) return this; } + public Builder groupKey(String groupKey) + { + this.groupKey = groupKey; + return this; + } + + public Builder localKey(String localKey) + { + this.localKey = localKey; + return this; + } + public Builder kind(String kind) { return parameter(LedgerParameterType.CASH_COMPENSATION_KIND, kind); diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeFee.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeFee.java index c73e9c13e9..984ba915c3 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeFee.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeFee.java @@ -22,12 +22,16 @@ public final class NativeFee { private final Account account; private final Money amount; + private final String groupKey; + private final String localKey; private final List parameters; private NativeFee(Builder builder) { this.account = builder.account; this.amount = builder.amount; + this.groupKey = builder.groupKey; + this.localKey = builder.localKey; this.parameters = List.copyOf(builder.parameters); } @@ -56,6 +60,16 @@ Money getAmount() return amount; } + String getGroupKey() + { + return groupKey; + } + + String getLocalKey() + { + return localKey; + } + List getParameters() { return parameters; @@ -65,6 +79,8 @@ public static final class Builder { private Account account; private Money amount; + private String groupKey; + private String localKey; private final List parameters = new ArrayList<>(); private Builder() @@ -83,6 +99,18 @@ public Builder amount(Money amount) return this; } + public Builder groupKey(String groupKey) + { + this.groupKey = groupKey; + return this; + } + + public Builder localKey(String localKey) + { + this.localKey = localKey; + return this; + } + public Builder reason(String reason) { return parameter(LedgerParameterType.FEE_REASON, reason); diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeSecurityLeg.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeSecurityLeg.java index 50b5371a12..91bf00c59b 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeSecurityLeg.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeSecurityLeg.java @@ -31,6 +31,8 @@ public final class NativeSecurityLeg private final long shares; private final Money amount; private final LedgerProjectionRole projectionRole; + private final String groupKey; + private final String localKey; private final List parameters; private NativeSecurityLeg(Builder builder) @@ -42,6 +44,8 @@ private NativeSecurityLeg(Builder builder) this.shares = builder.shares; this.amount = builder.amount; this.projectionRole = builder.projectionRole; + this.groupKey = builder.groupKey; + this.localKey = builder.localKey; this.parameters = List.copyOf(builder.parameters); } @@ -98,6 +102,16 @@ LedgerProjectionRole getProjectionRole() return projectionRole; } + String getGroupKey() + { + return groupKey; + } + + String getLocalKey() + { + return localKey; + } + List getParameters() { return parameters; @@ -112,6 +126,8 @@ public static final class Builder private long shares; private Money amount; private LedgerProjectionRole projectionRole; + private String groupKey; + private String localKey; private final List parameters = new ArrayList<>(); private Builder(LedgerPostingType postingType, String legCode, LedgerProjectionRole projectionRole) @@ -168,6 +184,18 @@ public Builder projectAs(LedgerProjectionRole projectionRole) return this; } + public Builder groupKey(String groupKey) + { + this.groupKey = groupKey; + return this; + } + + public Builder localKey(String localKey) + { + this.localKey = localKey; + return this; + } + Builder postingType(LedgerPostingType postingType) { this.postingType = postingType; diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeTax.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeTax.java index 7924d0796e..0b6ff9811e 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeTax.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeTax.java @@ -22,12 +22,16 @@ public final class NativeTax { private final Account account; private final Money amount; + private final String groupKey; + private final String localKey; private final List parameters; private NativeTax(Builder builder) { this.account = builder.account; this.amount = builder.amount; + this.groupKey = builder.groupKey; + this.localKey = builder.localKey; this.parameters = List.copyOf(builder.parameters); } @@ -52,6 +56,16 @@ Money getAmount() return amount; } + String getGroupKey() + { + return groupKey; + } + + String getLocalKey() + { + return localKey; + } + List getParameters() { return parameters; @@ -61,6 +75,8 @@ public static final class Builder { private Account account; private Money amount; + private String groupKey; + private String localKey; private final List parameters = new ArrayList<>(); private Builder() @@ -79,6 +95,18 @@ public Builder amount(Money amount) return this; } + public Builder groupKey(String groupKey) + { + this.groupKey = groupKey; + return this; + } + + public Builder localKey(String localKey) + { + this.localKey = localKey; + return this; + } + public Builder reason(String reason) { return parameter(LedgerParameterType.TAX_REASON, reason); From abebcbff7217346e5d0851b64274d54bb9141432 Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Fri, 3 Jul 2026 14:40:38 +0200 Subject: [PATCH 45/68] Materialize repeated Ledger spin-off projections Add focused projection materializer coverage for assembler-created repeated spin-off target and cash compensation movements. The test proves descriptor fan-out, semantic-key lookup, distinct runtime projection IDs, and idempotent materialization for repeated native Corporate Action movements. No definitions, assembler API, validators, descriptor logic, protobuf schema, or legacy PTransaction mappings are changed. --- .../LedgerProjectionMaterializerTest.java | 202 ++++++++++++++++++ 1 file changed, 202 insertions(+) diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerProjectionMaterializerTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerProjectionMaterializerTest.java index a7e8409b04..46e33bebcd 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerProjectionMaterializerTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerProjectionMaterializerTest.java @@ -46,12 +46,16 @@ import name.abuchen.portfolio.model.ledger.nativeentry.NativeCashCompensation; import name.abuchen.portfolio.model.ledger.nativeentry.NativeCorporateActionEvent; import name.abuchen.portfolio.model.ledger.nativeentry.NativeEntryMetadata; +import name.abuchen.portfolio.model.ledger.nativeentry.NativeFee; import name.abuchen.portfolio.model.ledger.nativeentry.NativeSecurityLeg; +import name.abuchen.portfolio.model.ledger.nativeentry.NativeTax; import name.abuchen.portfolio.model.ledger.nativeentry.Ratio; +import name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedAccountTransaction; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedPortfolioTransaction; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionService; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionSupport; import name.abuchen.portfolio.money.CurrencyUnit; import name.abuchen.portfolio.money.Money; import name.abuchen.portfolio.money.Values; @@ -194,6 +198,63 @@ public void testSiemensSpinOffMaterializesFromDerivedDescriptorsWithoutPersisted assertThat(account.getTransactions().get(0).getType(), is(AccountTransaction.Type.DEPOSIT)); } + @Test + public void testMaterializesRepeatedSpinOffMovementProjections() + { + var fixture = repeatedSpinOffFixture(); + var entry = repeatedSpinOffEntry(fixture); + + fixture.client.getLedger().addEntry(entry); + + var descriptors = LedgerProjectionSupport.descriptors(entry); + var targetDescriptors = descriptors.stream() + .filter(descriptor -> descriptor.getRole() == LedgerProjectionRole.NEW_SECURITY_LEG) + .toList(); + var cashDescriptors = descriptors.stream() + .filter(descriptor -> descriptor.getRole() == LedgerProjectionRole.CASH_COMPENSATION) + .toList(); + + assertThat(descriptors.size(), is(5)); + assertThat(semanticKeys(targetDescriptors), is(java.util.Set.of("target-1", "target-2"))); + assertThat(semanticKeys(cashDescriptors), is(java.util.Set.of("cash-1", "cash-2"))); + assertThat(descriptorRuntimeProjectionIds(targetDescriptors).size(), is(2)); + assertThat(descriptorRuntimeProjectionIds(cashDescriptors).size(), is(2)); + assertThrows(IllegalArgumentException.class, + () -> LedgerProjectionService.createProjection(entry, LedgerProjectionRole.NEW_SECURITY_LEG)); + assertSame(fixture.firstTarget, LedgerProjectionSupport + .descriptor(entry, LedgerProjectionRole.NEW_SECURITY_LEG, "target-1").getPrimaryPosting() + .getSecurity()); + assertSame(fixture.secondTarget, LedgerProjectionSupport + .descriptor(entry, LedgerProjectionRole.NEW_SECURITY_LEG, "target-2").getPrimaryPosting() + .getSecurity()); + + LedgerProjectionService.materialize(fixture.client); + LedgerProjectionService.materialize(fixture.client); + + var targetProjections = ledgerBackedPortfolioProjections(fixture.portfolio, + LedgerProjectionRole.NEW_SECURITY_LEG); + var cashProjections = ledgerBackedAccountProjections(fixture.account, LedgerProjectionRole.CASH_COMPENSATION); + + assertThat(targetProjections.size(), is(2)); + assertThat(cashProjections.size(), is(2)); + assertThat(runtimeProjectionIds(targetProjections), is(descriptorRuntimeProjectionIds(targetDescriptors))); + assertThat(runtimeProjectionIds(cashProjections), is(descriptorRuntimeProjectionIds(cashDescriptors))); + assertThat(targetProjections.stream().map(PortfolioTransaction::getSecurity).collect(java.util.stream.Collectors.toSet()), + is(java.util.Set.of(fixture.firstTarget, fixture.secondTarget))); + + var cashOne = ledgerBackedAccountProjection(fixture.account, LedgerProjectionRole.CASH_COMPENSATION, "cash-1"); + var cashTwo = ledgerBackedAccountProjection(fixture.account, LedgerProjectionRole.CASH_COMPENSATION, "cash-2"); + + assertThat(cashOne.getAmount(), is(Values.Amount.factorize(5))); + assertThat(cashTwo.getAmount(), is(Values.Amount.factorize(7))); + assertTrue(cashOne.getUnit(Unit.Type.FEE).isPresent()); + assertTrue(cashOne.getUnit(Unit.Type.TAX).isPresent()); + assertTrue(cashTwo.getUnit(Unit.Type.FEE).isEmpty()); + assertTrue(cashTwo.getUnit(Unit.Type.TAX).isEmpty()); + assertThat(fixture.portfolio.getTransactions().size(), is(3)); + assertThat(fixture.account.getTransactions().size(), is(2)); + } + /** * Checks the projection rebuild scenario: materializer adds account projection only. * Account and portfolio lists must be derived from the ledger entry. @@ -700,4 +761,145 @@ private void moveFirstPostingToEnd(LedgerEntry entry) entry.removePosting(posting); entry.addPosting(posting); } + + private RepeatedSpinOffFixture repeatedSpinOffFixture() + { + var client = new Client(); + var account = account(); + var portfolio = portfolio(); + var source = new Security("Siemens AG", CurrencyUnit.EUR); + var firstTarget = new Security("Siemens Energy AG", CurrencyUnit.EUR); + var secondTarget = new Security("Siemens Healthineers AG", CurrencyUnit.EUR); + + source.setIsin("DE0007236101"); + firstTarget.setIsin("DE000ENER6Y0"); + secondTarget.setIsin("DE000SHL1006"); + client.addAccount(account); + client.addPortfolio(portfolio); + client.addSecurity(source); + client.addSecurity(firstTarget); + client.addSecurity(secondTarget); + + return new RepeatedSpinOffFixture(client, account, portfolio, source, firstTarget, secondTarget); + } + + private LedgerEntry repeatedSpinOffEntry(RepeatedSpinOffFixture fixture) + { + return LedgerNativeEntryAssembler.forClient(fixture.client).spinOff() // + .metadata(NativeEntryMetadata.of(DATE_TIME).note("Repeated spin-off").source("test")) // + .event(NativeCorporateActionEvent.builder() // + .kind(CorporateActionKind.SPIN_OFF) // + .subtype(CorporateActionSubtype.STANDARD) // + .stage(EventStage.SETTLED) // + .effectiveDate(LocalDate.of(2020, 9, 28)) // + .build()) // + .securityLeg(NativeSecurityLeg.source() // + .portfolio(fixture.portfolio) // + .security(fixture.source) // + .shares(Values.Share.factorize(10)) // + .amount(money(100)) // + .sourceSecurity(fixture.source) // + .targetSecurity(fixture.firstTarget) // + .ratio(Ratio.of(BigDecimal.ONE, BigDecimal.valueOf(2))) // + .build()) // + .securityLeg(targetLeg(fixture, fixture.firstTarget, Values.Share.factorize(5), 50, + "target-1")) // + .securityLeg(targetLeg(fixture, fixture.secondTarget, Values.Share.factorize(7), 70, + "target-2")) // + .cashCompensation(cashCompensation(fixture, "cash-1", 5)) // + .cashCompensationMovement(cashCompensation(fixture, "cash-2", 7)) // + .fee(NativeFee.builder() // + .account(fixture.account) // + .amount(money(2)) // + .groupKey("cash-1") // + .localKey("fee-1") // + .build()) // + .tax(NativeTax.builder() // + .account(fixture.account) // + .amount(money(1)) // + .groupKey("cash-1") // + .localKey("tax-1") // + .build()) // + .buildDetached().getEntry(); + } + + private NativeSecurityLeg targetLeg(RepeatedSpinOffFixture fixture, Security target, long shares, int amount, + String localKey) + { + return NativeSecurityLeg.target() // + .portfolio(fixture.portfolio) // + .security(target) // + .shares(shares) // + .amount(money(amount)) // + .sourceSecurity(fixture.source) // + .targetSecurity(target) // + .ratio(Ratio.of(BigDecimal.ONE, BigDecimal.valueOf(2))) // + .groupKey("main") // + .localKey(localKey) // + .build(); + } + + private NativeCashCompensation cashCompensation(RepeatedSpinOffFixture fixture, String semanticKey, int amount) + { + return NativeCashCompensation.builder() // + .account(fixture.account) // + .amount(money(amount)) // + .kind(CashCompensationKind.CASH_IN_LIEU) // + .groupKey(semanticKey) // + .localKey(semanticKey) // + .build(); + } + + private java.util.Set semanticKeys(List descriptors) + { + return descriptors.stream() // + .map(descriptor -> descriptor.getSemanticInstanceKey().orElseThrow()) // + .collect(java.util.stream.Collectors.toSet()); + } + + private java.util.Set runtimeProjectionIds(List transactions) + { + return transactions.stream().map(LedgerBackedTransaction::getRuntimeProjectionId) + .collect(java.util.stream.Collectors.toSet()); + } + + private java.util.Set descriptorRuntimeProjectionIds(List descriptors) + { + return descriptors.stream().map(DerivedProjectionDescriptor::getRuntimeProjectionId) + .collect(java.util.stream.Collectors.toSet()); + } + + private List ledgerBackedPortfolioProjections(Portfolio portfolio, + LedgerProjectionRole role) + { + return portfolio.getTransactions().stream() // + .filter(LedgerBackedPortfolioTransaction.class::isInstance) // + .map(LedgerBackedPortfolioTransaction.class::cast) // + .filter(transaction -> transaction.getLedgerProjectionRole() == role) // + .toList(); + } + + private List ledgerBackedAccountProjections(Account account, + LedgerProjectionRole role) + { + return account.getTransactions().stream() // + .filter(LedgerBackedAccountTransaction.class::isInstance) // + .map(LedgerBackedAccountTransaction.class::cast) // + .filter(transaction -> transaction.getLedgerProjectionRole() == role) // + .toList(); + } + + private LedgerBackedAccountTransaction ledgerBackedAccountProjection(Account account, LedgerProjectionRole role, + String semanticInstanceKey) + { + return ledgerBackedAccountProjections(account, role).stream() // + .filter(transaction -> transaction.getLedgerProjectionDescriptor().getSemanticInstanceKey() + .filter(semanticInstanceKey::equals).isPresent()) // + .findFirst().orElseThrow(); + } + + private record RepeatedSpinOffFixture(Client client, Account account, Portfolio portfolio, Security source, + Security firstTarget, Security secondTarget) + { + } } From faa10dba028bf4fe14d52e734df1e4dea3cb8235 Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:50:16 +0200 Subject: [PATCH 46/68] Clean up Ledger corporate action configuration Remove unfinished native Corporate Action entry types and the temporary model-only proof type, leaving SPIN_OFF as the only native Corporate Action service type with repeatable source, target, and cash compensation legs. This keeps the current configuration surface aligned with the service path that is implemented and covered by validators, descriptors, assembler support, persistence, materialization, and performance tests. XML/protobuf schemas, legacy PTransaction mappings, UUID-free Ledger truth, projection membership, UI, reports, importers, and tax/performance behavior are intentionally unchanged. --- .../model/LedgerProtobufPersistenceTest.java | 29 +- ...ateActionMultiMovementPersistenceTest.java | 170 +------ .../ledger/LedgerEntryDefinitionTest.java | 293 +----------- .../model/ledger/LedgerModelTest.java | 18 +- ...gerNativeEntryDefinitionValidatorTest.java | 80 ++-- .../ledger/LedgerParameterCodeDomainTest.java | 3 +- .../LedgerPostingTypeDefinitionTest.java | 4 +- .../ledger/LedgerSpinOffScenarioTest.java | 3 +- .../ledger/LedgerStructuralValidatorTest.java | 27 +- ...dgerNativeComponentInspectorModelTest.java | 83 ---- .../LedgerNativeEntryAssemblerTest.java | 132 +----- ...erivedProjectionDescriptorServiceTest.java | 45 -- .../ClientPerformanceSnapshotTest.java | 22 +- .../ledger/LedgerStructuralValidator.java | 112 +---- .../configuration/CorporateActionKind.java | 4 - .../LedgerEntryDefinitionRegistry.java | 426 +----------------- .../ledger/configuration/LedgerEntryType.java | 10 +- .../LedgerNativeEntryDefinitionValidator.java | 24 - .../DerivedProjectionDescriptorService.java | 67 +-- 19 files changed, 112 insertions(+), 1440 deletions(-) diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceTest.java index ea1b969689..843a0e9659 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceTest.java @@ -690,10 +690,6 @@ public void testLegacyTransactionTypeEnumDoesNotContainCorporateActions() var transactionTypes = java.util.Arrays.stream(PTransaction.Type.values()).map(Enum::name).toList(); assertFalse(transactionTypes.contains("SPIN_OFF")); - assertFalse(transactionTypes.contains("STOCK_DIVIDEND")); - assertFalse(transactionTypes.contains("BONUS_ISSUE")); - assertFalse(transactionTypes.contains("RIGHTS_DISTRIBUTION")); - assertFalse(transactionTypes.contains("BOND_CONVERSION")); } /** @@ -703,9 +699,7 @@ public void testLegacyTransactionTypeEnumDoesNotContainCorporateActions() @Test public void testNativeCorporateActionsRoundtripAsSemanticLedgerTruth() throws IOException { - for (var entryType : List.of(LedgerEntryType.SPIN_OFF, LedgerEntryType.STOCK_DIVIDEND, - LedgerEntryType.BONUS_ISSUE, LedgerEntryType.RIGHTS_DISTRIBUTION, - LedgerEntryType.BOND_CONVERSION)) + for (var entryType : List.of(LedgerEntryType.SPIN_OFF)) { var fixture = fixture(); addNativeEntry(fixture, entryType); @@ -1099,25 +1093,6 @@ private void addNativeEntry(ClientFixture fixture, LedgerEntryType entryType) CorporateActionLeg.TARGET_SECURITY, fixture.otherSecurity(), LedgerPostingDirection.INBOUND, LedgerProjectionRole.NEW_SECURITY_LEG)); break; - case STOCK_DIVIDEND: - case BONUS_ISSUE: - entry.addPosting(nativeSecurityPosting(fixture, LedgerPostingType.SECURITY, - CorporateActionLeg.TARGET_SECURITY, fixture.otherSecurity(), - LedgerPostingDirection.INBOUND, LedgerProjectionRole.DELIVERY_INBOUND)); - break; - case RIGHTS_DISTRIBUTION: - entry.addPosting(nativeSecurityPosting(fixture, LedgerPostingType.SECURITY, - CorporateActionLeg.DISTRIBUTED_SECURITY, fixture.otherSecurity(), - LedgerPostingDirection.INBOUND, LedgerProjectionRole.NEW_SECURITY_LEG)); - break; - case BOND_CONVERSION: - entry.addPosting(nativeSecurityPosting(fixture, LedgerPostingType.BOND, - CorporateActionLeg.CONVERSION_SOURCE, fixture.security(), LedgerPostingDirection.OUTBOUND, - LedgerProjectionRole.OLD_SECURITY_LEG)); - entry.addPosting(nativeSecurityPosting(fixture, LedgerPostingType.BOND, - CorporateActionLeg.CONVERSION_TARGET, fixture.otherSecurity(), - LedgerPostingDirection.INBOUND, LedgerProjectionRole.NEW_SECURITY_LEG)); - break; default: throw new IllegalArgumentException(entryType.name()); } @@ -1130,8 +1105,6 @@ private List nativeCorporateActionShadowTypes(LedgerEntryType return switch (entryType) { case SPIN_OFF -> List.of(PTransaction.Type.OUTBOUND_DELIVERY, PTransaction.Type.INBOUND_DELIVERY); - case STOCK_DIVIDEND, BONUS_ISSUE, RIGHTS_DISTRIBUTION -> List.of(PTransaction.Type.INBOUND_DELIVERY); - case BOND_CONVERSION -> List.of(PTransaction.Type.OUTBOUND_DELIVERY, PTransaction.Type.INBOUND_DELIVERY); default -> throw new IllegalArgumentException(entryType.name()); }; } diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java index 972c410fb2..be8abee39f 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java @@ -1,6 +1,5 @@ package name.abuchen.portfolio.model.ledger; -import static org.hamcrest.CoreMatchers.hasItem; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.Assert.assertFalse; @@ -14,7 +13,6 @@ import java.nio.file.Files; import java.time.Instant; import java.time.LocalDateTime; -import java.util.Arrays; import java.util.List; import java.util.Set; import java.util.stream.Collectors; @@ -35,15 +33,13 @@ import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; import name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor; import name.abuchen.portfolio.model.proto.v1.PClient; -import name.abuchen.portfolio.model.proto.v1.PTransaction; import name.abuchen.portfolio.money.CurrencyUnit; import name.abuchen.portfolio.money.Money; import name.abuchen.portfolio.money.Values; /** - * Model/persistence proof for repeated corporate-action movement legs. - * SPIN_OFF coverage here is low-level service coverage; assembler/UI support is intentionally - * outside this slice. + * Model/persistence proof for repeated SPIN_OFF movement legs. + * This is low-level service coverage; UI support is intentionally outside this slice. */ @SuppressWarnings("nls") public class LedgerCorporateActionMultiMovementPersistenceTest @@ -52,26 +48,6 @@ public class LedgerCorporateActionMultiMovementPersistenceTest private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 1, 2, 3, 4); private static final Instant UPDATED_AT = Instant.parse("2026-01-02T03:04:05Z"); - @Test - public void testXmlRoundtripPreservesRepeatedCorporateActionMovementLegs() throws Exception - { - var fixture = fixture(); - - assertMultiMovementProof(fixture.entry()); - assertValid(fixture.client()); - - var xml = saveXml(fixture.client()); - - assertNoLedgerUuidTruth(xml); - - var loaded = loadXml(xml); - var loadedEntry = onlyLedgerEntry(loaded); - - assertMultiMovementProof(loadedEntry); - assertThat(loaded.getAllTransactions().size(), is(0)); - assertValid(loaded); - } - @Test public void testXmlRoundtripPreservesRepeatedSpinOffMovementLegs() throws Exception { @@ -108,7 +84,7 @@ public void testProtobufRoundtripPreservesRepeatedSpinOffMovementLegs() throws E assertThat(proto.getLedger().getEntriesCount(), is(1)); assertThat(protoEntry.getTypeCode(), is(LedgerEntryType.SPIN_OFF.getCode())); - assertThat(protoEntry.getPostingsCount(), is(7)); + assertThat(protoEntry.getPostingsCount(), is(8)); assertNoCorporateActionSpecificLegacyTransactionType(proto); var loaded = ProtobufTestUtilities.load(bytes); @@ -119,86 +95,13 @@ public void testProtobufRoundtripPreservesRepeatedSpinOffMovementLegs() throws E assertNativeDefinitionValid(loadedEntry); } - @Test - public void testProtobufRoundtripPreservesRepeatedCorporateActionMovementLegs() throws Exception - { - var fixture = fixture(); - - assertMultiMovementProof(fixture.entry()); - assertValid(fixture.client()); - - var bytes = ProtobufTestUtilities.save(fixture.client()); - var proto = parseProto(bytes); - var protoEntry = proto.getLedger().getEntries(0); - - assertThat(proto.getLedger().getEntriesCount(), is(1)); - assertThat(protoEntry.getTypeCode(), is(LedgerEntryType.CORPORATE_ACTION_MOVEMENT_CONFIRMATION.getCode())); - assertThat(protoEntry.getPostingsCount(), is(7)); - assertThat(proto.getTransactionsCount(), is(0)); - assertNoLegacyTransactionTypeForProofEntry(); - - var loaded = ProtobufTestUtilities.load(bytes); - var loadedEntry = onlyLedgerEntry(loaded); - - assertMultiMovementProof(loadedEntry); - assertThat(loaded.getAllTransactions().size(), is(0)); - assertValid(loaded); - } - - private Fixture fixture() - { - var client = new Client(); - var account = new Account(); - var portfolio = new Portfolio(); - var sourceSecurity = new Security("Source AG", CurrencyUnit.EUR); - var targetSecurityA = new Security("Target A AG", CurrencyUnit.EUR); - var targetSecurityB = new Security("Target B AG", CurrencyUnit.EUR); - - account.setName("Cash Account"); - account.setCurrencyCode(CurrencyUnit.EUR); - account.setUpdatedAt(UPDATED_AT); - portfolio.setName("Portfolio"); - portfolio.setUpdatedAt(UPDATED_AT); - sourceSecurity.setUpdatedAt(UPDATED_AT); - targetSecurityA.setUpdatedAt(UPDATED_AT); - targetSecurityB.setUpdatedAt(UPDATED_AT); - - client.addAccount(account); - client.addPortfolio(portfolio); - client.addSecurity(sourceSecurity); - client.addSecurity(targetSecurityA); - client.addSecurity(targetSecurityB); - - var entry = new LedgerEntry("proof-entry"); - entry.setType(LedgerEntryType.CORPORATE_ACTION_MOVEMENT_CONFIRMATION); - entry.setDateTime(DATE_TIME); - entry.setSource("seev.036 movement proof"); - entry.setNote("model/persistence proof only"); - - entry.addPosting(securityPosting(portfolio, sourceSecurity, LedgerPostingDirection.OUTBOUND, - CorporateActionLeg.SOURCE_SECURITY, "main", "source-1", 10, 100)); - entry.addPosting(securityPosting(portfolio, targetSecurityA, LedgerPostingDirection.INBOUND, - CorporateActionLeg.TARGET_SECURITY, "main", "target-1", 3, 60)); - entry.addPosting(securityPosting(portfolio, targetSecurityB, LedgerPostingDirection.INBOUND, - CorporateActionLeg.TARGET_SECURITY, "main", "target-2", 7, 40)); - entry.addPosting(cashPosting(account, LedgerPostingDirection.INBOUND, "cash-1", "cash-1", 11)); - entry.addPosting(cashPosting(account, LedgerPostingDirection.INBOUND, "cash-2", "cash-2", 22)); - entry.addPosting(unitPosting(LedgerPostingType.FEE, LedgerPostingSemanticRole.FEE, - LedgerPostingUnitRole.FEE, CorporateActionLeg.FEE, "cash-1", "fee-1", 2)); - entry.addPosting(unitPosting(LedgerPostingType.TAX, LedgerPostingSemanticRole.TAX, - LedgerPostingUnitRole.TAX, CorporateActionLeg.TAX, "cash-1", "tax-1", 4)); - - client.getLedger().addEntry(entry); - - return new Fixture(client, entry); - } - private Fixture spinOffFixture() { var client = new Client(); var account = new Account(); var portfolio = new Portfolio(); var sourceSecurity = new Security("Source AG", CurrencyUnit.EUR); + var sourceSecurityB = new Security("Source B AG", CurrencyUnit.EUR); var targetSecurityA = new Security("Target A AG", CurrencyUnit.EUR); var targetSecurityB = new Security("Target B AG", CurrencyUnit.EUR); @@ -209,12 +112,14 @@ private Fixture spinOffFixture() account.setUpdatedAt(UPDATED_AT); portfolio.setUpdatedAt(UPDATED_AT); sourceSecurity.setUpdatedAt(UPDATED_AT); + sourceSecurityB.setUpdatedAt(UPDATED_AT); targetSecurityA.setUpdatedAt(UPDATED_AT); targetSecurityB.setUpdatedAt(UPDATED_AT); client.addAccount(account); client.addPortfolio(portfolio); client.addSecurity(sourceSecurity); + client.addSecurity(sourceSecurityB); client.addSecurity(targetSecurityA); client.addSecurity(targetSecurityB); @@ -228,8 +133,11 @@ private Fixture spinOffFixture() entry.addParameter(LedgerParameter.ofLocalDate(LedgerParameterType.EFFECTIVE_DATE, DATE_TIME.toLocalDate())); entry.addPosting(spinOffSecurityPosting(portfolio, sourceSecurity, sourceSecurity, targetSecurityA, - LedgerPostingDirection.OUTBOUND, CorporateActionLeg.SOURCE_SECURITY, "source", "source-1", + LedgerPostingDirection.OUTBOUND, CorporateActionLeg.SOURCE_SECURITY, "main", "source-1", 10, 100)); + entry.addPosting(spinOffSecurityPosting(portfolio, sourceSecurityB, sourceSecurityB, targetSecurityB, + LedgerPostingDirection.OUTBOUND, CorporateActionLeg.SOURCE_SECURITY, "main", "source-2", + 4, 40)); entry.addPosting(spinOffSecurityPosting(portfolio, targetSecurityA, sourceSecurity, targetSecurityA, LedgerPostingDirection.INBOUND, CorporateActionLeg.TARGET_SECURITY, "main", "target-1", 3, 60)); @@ -334,40 +242,13 @@ private LedgerPosting unitPosting(LedgerPostingType type, LedgerPostingSemanticR return posting; } - private void assertMultiMovementProof(LedgerEntry entry) - { - assertThat(entry.getType(), is(LedgerEntryType.CORPORATE_ACTION_MOVEMENT_CONFIRMATION)); - assertThat(entry.getPostings().size(), is(7)); - - var targetSecurities = postings(entry, LedgerPostingType.SECURITY, LedgerPostingDirection.INBOUND, - CorporateActionLeg.TARGET_SECURITY); - var cashMovements = postings(entry, LedgerPostingType.CASH_COMPENSATION, LedgerPostingDirection.INBOUND, - CorporateActionLeg.CASH_COMPENSATION); - var cashOneUnits = entry.getPostings().stream() - .filter(posting -> "cash-1".equals(posting.getGroupKey())) - .filter(posting -> posting.getUnitRole() == LedgerPostingUnitRole.FEE - || posting.getUnitRole() == LedgerPostingUnitRole.TAX) - .toList(); - - assertThat(postings(entry, LedgerPostingType.SECURITY, LedgerPostingDirection.OUTBOUND, - CorporateActionLeg.SOURCE_SECURITY).size(), is(1)); - assertThat(targetSecurities.size(), is(2)); - assertThat(localKeys(targetSecurities), is(Set.of("target-1", "target-2"))); - assertThat(cashMovements.size(), is(2)); - assertThat(localKeys(cashMovements), is(Set.of("cash-1", "cash-2"))); - assertThat(groupKeys(cashMovements), is(Set.of("cash-1", "cash-2"))); - assertThat(cashOneUnits.size(), is(2)); - assertThat(cashOneUnits.stream().map(LedgerPosting::getUnitRole).collect(Collectors.toSet()), - is(Set.of(LedgerPostingUnitRole.FEE, LedgerPostingUnitRole.TAX))); - assertThat(entry.getPostings().stream().map(LedgerPosting::getLocalKey).toList(), hasItem("target-1")); - assertThat(entry.getPostings().stream().map(LedgerPosting::getLocalKey).toList(), hasItem("target-2")); - } - private void assertRepeatedSpinOff(LedgerEntry entry) { assertThat(entry.getType(), is(LedgerEntryType.SPIN_OFF)); - assertThat(entry.getPostings().size(), is(7)); + assertThat(entry.getPostings().size(), is(8)); + var sourceSecurities = postings(entry, LedgerPostingType.SECURITY, LedgerPostingDirection.OUTBOUND, + CorporateActionLeg.SOURCE_SECURITY); var targetSecurities = postings(entry, LedgerPostingType.SECURITY, LedgerPostingDirection.INBOUND, CorporateActionLeg.TARGET_SECURITY); var cashMovements = postings(entry, LedgerPostingType.CASH_COMPENSATION, LedgerPostingDirection.NEUTRAL, @@ -376,14 +257,20 @@ private void assertRepeatedSpinOff(LedgerEntry entry) var targetDescriptors = descriptors.stream() .filter(descriptor -> descriptor.getRole() == LedgerProjectionRole.NEW_SECURITY_LEG) .toList(); + var sourceDescriptors = descriptors.stream() + .filter(descriptor -> descriptor.getRole() == LedgerProjectionRole.OLD_SECURITY_LEG) + .toList(); - assertThat(postings(entry, LedgerPostingType.SECURITY, LedgerPostingDirection.OUTBOUND, - CorporateActionLeg.SOURCE_SECURITY).size(), is(1)); + assertThat(sourceSecurities.size(), is(2)); + assertThat(localKeys(sourceSecurities), is(Set.of("source-1", "source-2"))); assertThat(targetSecurities.size(), is(2)); assertThat(localKeys(targetSecurities), is(Set.of("target-1", "target-2"))); assertThat(cashMovements.size(), is(2)); assertThat(localKeys(cashMovements), is(Set.of("cash-1", "cash-2"))); assertThat(groupKeys(cashMovements), is(Set.of("cash-1", "cash-2"))); + assertThat(sourceDescriptors.size(), is(2)); + assertThat(sourceDescriptors.stream().map(descriptor -> descriptor.getSemanticInstanceKey().orElseThrow()) + .collect(Collectors.toSet()), is(Set.of("source-1", "source-2"))); assertThat(targetDescriptors.size(), is(2)); assertThat(targetDescriptors.stream().map(descriptor -> descriptor.getSemanticInstanceKey().orElseThrow()) .collect(Collectors.toSet()), is(Set.of("target-1", "target-2"))); @@ -487,22 +374,13 @@ private String ledgerSection(String xml) return xml.substring(start, end); } - private void assertNoLegacyTransactionTypeForProofEntry() - { - assertFalse(Arrays.stream(name.abuchen.portfolio.model.proto.v1.PTransaction.Type.values()) - .map(Enum::name) - .anyMatch(LedgerEntryType.CORPORATE_ACTION_MOVEMENT_CONFIRMATION.getCode()::equals)); - } - private void assertNoCorporateActionSpecificLegacyTransactionType(PClient proto) { for (var transaction : proto.getTransactionsList()) - assertFalse(Set.of("SPIN_OFF", "STOCK_DIVIDEND", "BONUS_ISSUE", "RIGHTS_DISTRIBUTION", - "BOND_CONVERSION").contains(transaction.getType().name())); + assertFalse("SPIN_OFF".equals(transaction.getType().name())); - assertFalse(Arrays.stream(PTransaction.Type.values()).map(Enum::name) - .anyMatch(Set.of("SPIN_OFF", "STOCK_DIVIDEND", "BONUS_ISSUE", - "RIGHTS_DISTRIBUTION", "BOND_CONVERSION")::contains)); + assertFalse(java.util.Arrays.stream(name.abuchen.portfolio.model.proto.v1.PTransaction.Type.values()) + .map(Enum::name).anyMatch("SPIN_OFF"::equals)); } private record Fixture(Client client, LedgerEntry entry) 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 index 22a8dbad55..2986761765 100644 --- 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 @@ -74,7 +74,6 @@ public void testDefinitionsAreConsistentStaticConfiguration() assertTrue(definition.getEntryType().isLedgerNativeTargeted()); assertTrue(definition.getNativeShape() != LedgerNativeEntryShape.UNDEFINED); assertFalse(definition.getPostingTypes().isEmpty()); - assertTrue(!definition.getRequiredPostingRules().isEmpty() || hasRequiredPostingAlternative(definition)); assertFalse(definition.getPostingRules().isEmpty()); assertFalse(definition.getEntryParameterTypes().isEmpty()); assertFalse(definition.getRequiredEntryParameterRules().isEmpty()); @@ -231,7 +230,7 @@ public void testSpinOffDefinitionDescribesNativeDataModel() assertTrue(definition.getProjectionRoles().contains(LedgerProjectionRole.OLD_SECURITY_LEG)); assertTrue(definition.getProjectionRoles().contains(LedgerProjectionRole.NEW_SECURITY_LEG)); assertTrue(definition.getProjectionRoles().contains(LedgerProjectionRole.CASH_COMPENSATION)); - assertRequiredPosting(definition, LedgerPostingType.SECURITY); + assertOptionalPosting(definition, LedgerPostingType.SECURITY); assertOptionalPosting(definition, LedgerPostingType.CASH_COMPENSATION); assertOptionalPosting(definition, LedgerPostingType.FEE); assertOptionalPosting(definition, LedgerPostingType.TAX); @@ -244,8 +243,8 @@ public void testSpinOffDefinitionDescribesNativeDataModel() assertRequiredPostingParameter(definition, LedgerParameterType.TARGET_SECURITY); assertOptionalPostingParameter(definition, LedgerParameterType.CASH_IN_LIEU_AMOUNT); assertRepeatableParameter(definition, LedgerParameterType.CORPORATE_ACTION_LEG); - assertRequiredProjection(definition, LedgerProjectionRole.OLD_SECURITY_LEG, true, false); - assertRequiredProjection(definition, LedgerProjectionRole.NEW_SECURITY_LEG, true, false); + 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); @@ -264,7 +263,7 @@ public void testSpinOffDefinitionDescribesFunctionalLegs() var definition = LedgerEntryDefinitionRegistry.lookup(LedgerEntryType.SPIN_OFF).orElseThrow(); var sourceLeg = assertLeg(definition, LedgerLegRole.SOURCE_SECURITY_LEG, LedgerPostingType.SECURITY, - LedgerLegCardinality.EXACTLY_ONE); + LedgerLegCardinality.REPEATABLE); assertTrue(sourceLeg.getRequiredParameterTypes().contains(LedgerParameterType.CORPORATE_ACTION_LEG)); assertTrue(sourceLeg.getRequiredParameterTypes().contains(LedgerParameterType.SOURCE_SECURITY)); assertTrue(sourceLeg.getRequiredParameterTypes().contains(LedgerParameterType.RATIO_NUMERATOR)); @@ -275,7 +274,7 @@ public void testSpinOffDefinitionDescribesFunctionalLegs() assertFalse(sourceLeg.isPostingGroupExpected()); var targetLeg = assertLeg(definition, LedgerLegRole.TARGET_SECURITY_LEG, LedgerPostingType.SECURITY, - LedgerLegCardinality.AT_LEAST_ONE); + LedgerLegCardinality.REPEATABLE); assertTrue(targetLeg.getRequiredParameterTypes().contains(LedgerParameterType.CORPORATE_ACTION_LEG)); assertTrue(targetLeg.getRequiredParameterTypes().contains(LedgerParameterType.TARGET_SECURITY)); assertTrue(targetLeg.getRequiredParameterTypes().contains(LedgerParameterType.RATIO_NUMERATOR)); @@ -308,212 +307,6 @@ public void testSpinOffDefinitionDescribesFunctionalLegs() assertTrue(forexLeg.getGroupNames().isEmpty()); } - /** - * Checks that stock dividends name the received security leg explicitly. - * The cash, fee, and tax legs remain optional support components and use - * the existing cash compensation group metadata. - */ - @Test - public void testStockDividendDefinitionDescribesFunctionalLegs() - { - var definition = LedgerEntryDefinitionRegistry.lookup(LedgerEntryType.STOCK_DIVIDEND).orElseThrow(); - - var receivedLeg = assertLeg(definition, LedgerLegRole.RECEIVED_SECURITY_LEG, LedgerPostingType.SECURITY, - LedgerLegCardinality.EXACTLY_ONE); - assertTrue(receivedLeg.getRequiredParameterTypes().contains(LedgerParameterType.CORPORATE_ACTION_LEG)); - assertTrue(receivedLeg.getRequiredParameterTypes().contains(LedgerParameterType.TARGET_SECURITY)); - assertTrue(receivedLeg.getRequiredParameterTypes().contains(LedgerParameterType.RATIO_NUMERATOR)); - assertTrue(receivedLeg.getRequiredParameterTypes().contains(LedgerParameterType.RATIO_DENOMINATOR)); - assertTrue(receivedLeg.getOptionalParameterTypes().contains(LedgerParameterType.SOURCE_SECURITY)); - assertThat(receivedLeg.getProjectionRole().orElseThrow(), is(LedgerProjectionRole.DELIVERY_INBOUND)); - assertTrue(receivedLeg.isPrimaryPostingExpected()); - assertFalse(receivedLeg.isPostingGroupExpected()); - - assertCashCompensationFeeAndTaxLegs(definition); - } - - /** - * Checks that bonus issues use the same received-security leg shape as - * stock dividends while preserving their own optional parameter vocabulary. - * This keeps the Java-only leg metadata aligned with the existing rules. - */ - @Test - public void testBonusIssueDefinitionDescribesFunctionalLegs() - { - var definition = LedgerEntryDefinitionRegistry.lookup(LedgerEntryType.BONUS_ISSUE).orElseThrow(); - - var receivedLeg = assertLeg(definition, LedgerLegRole.RECEIVED_SECURITY_LEG, LedgerPostingType.SECURITY, - LedgerLegCardinality.EXACTLY_ONE); - assertTrue(receivedLeg.getRequiredParameterTypes().contains(LedgerParameterType.CORPORATE_ACTION_LEG)); - assertTrue(receivedLeg.getRequiredParameterTypes().contains(LedgerParameterType.TARGET_SECURITY)); - assertTrue(receivedLeg.getRequiredParameterTypes().contains(LedgerParameterType.RATIO_NUMERATOR)); - assertTrue(receivedLeg.getRequiredParameterTypes().contains(LedgerParameterType.RATIO_DENOMINATOR)); - assertTrue(receivedLeg.getOptionalParameterTypes().contains(LedgerParameterType.SAME_SECURITY_AS_SOURCE)); - assertThat(receivedLeg.getProjectionRole().orElseThrow(), is(LedgerProjectionRole.DELIVERY_INBOUND)); - assertTrue(receivedLeg.isPrimaryPostingExpected()); - assertFalse(receivedLeg.isPostingGroupExpected()); - - assertCashCompensationFeeAndTaxLegs(definition); - } - - /** - * Checks that rights distributions expose the current distributed - * instrument alternative as leg metadata. - * The test does not add rights lifecycle behavior; it only mirrors the - * existing RIGHT-or-SECURITY alternative and projection rules. - */ - @Test - public void testRightsDistributionDefinitionDescribesFunctionalLegs() - { - var definition = LedgerEntryDefinitionRegistry.lookup(LedgerEntryType.RIGHTS_DISTRIBUTION).orElseThrow(); - - var rightLeg = assertLeg(definition, LedgerLegRole.DISTRIBUTED_RIGHT_LEG, LedgerPostingType.RIGHT, - LedgerLegCardinality.OPTIONAL); - assertTrue(rightLeg.getRequiredParameterTypes().contains(LedgerParameterType.CORPORATE_ACTION_LEG)); - assertTrue(rightLeg.getRequiredParameterTypes().contains(LedgerParameterType.SOURCE_SECURITY)); - assertTrue(rightLeg.getRequiredParameterTypes().contains(LedgerParameterType.RIGHT_SECURITY)); - assertTrue(rightLeg.getRequiredParameterTypes().contains(LedgerParameterType.RATIO_NUMERATOR)); - assertTrue(rightLeg.getRequiredParameterTypes().contains(LedgerParameterType.RATIO_DENOMINATOR)); - assertThat(rightLeg.getProjectionRole().orElseThrow(), is(LedgerProjectionRole.NEW_SECURITY_LEG)); - assertTrue(rightLeg.isPrimaryPostingExpected()); - assertFalse(rightLeg.isPostingGroupExpected()); - - var securityLeg = assertLeg(definition, LedgerLegRole.DISTRIBUTED_SECURITY_LEG, - LedgerPostingType.SECURITY, LedgerLegCardinality.OPTIONAL); - assertTrue(securityLeg.getRequiredParameterTypes().contains(LedgerParameterType.CORPORATE_ACTION_LEG)); - assertThat(securityLeg.getProjectionRole().orElseThrow(), is(LedgerProjectionRole.NEW_SECURITY_LEG)); - assertTrue(securityLeg.isPrimaryPostingExpected()); - - var sourceLeg = assertLeg(definition, LedgerLegRole.SOURCE_SECURITY_LEG, LedgerPostingType.SECURITY, - LedgerLegCardinality.OPTIONAL); - assertThat(sourceLeg.getProjectionRole().orElseThrow(), is(LedgerProjectionRole.OLD_SECURITY_LEG)); - assertTrue(sourceLeg.isPrimaryPostingExpected()); - - assertAlternativePostingGroup(definition, "RIGHTS_DISTRIBUTED_INSTRUMENT", LedgerRequirement.REQUIRED, - LedgerPostingType.RIGHT, LedgerPostingType.SECURITY); - assertCashCompensationFeeAndTaxLegs(definition); - } - - /** - * Checks that bond conversion separates the source bond, target security, - * cash, accrued interest, fee, and tax components. - * The required ratio and date alternatives remain the existing aggregate - * rules; the leg metadata does not add fixed-income behavior. - */ - @Test - public void testBondConversionDefinitionDescribesFunctionalLegs() - { - var definition = LedgerEntryDefinitionRegistry.lookup(LedgerEntryType.BOND_CONVERSION).orElseThrow(); - - var sourceLeg = assertLeg(definition, LedgerLegRole.SOURCE_BOND_LEG, LedgerPostingType.BOND, - LedgerLegCardinality.EXACTLY_ONE); - assertTrue(sourceLeg.getRequiredParameterTypes().contains(LedgerParameterType.CORPORATE_ACTION_LEG)); - assertTrue(sourceLeg.getRequiredParameterTypes().contains(LedgerParameterType.SOURCE_SECURITY)); - assertTrue(sourceLeg.getRequiredParameterTypes().contains(LedgerParameterType.NOMINAL_VALUE)); - assertTrue(sourceLeg.getRequiredParameterTypes().contains(LedgerParameterType.QUOTATION_STYLE)); - assertThat(sourceLeg.getProjectionRole().orElseThrow(), is(LedgerProjectionRole.OLD_SECURITY_LEG)); - assertTrue(sourceLeg.isPrimaryPostingExpected()); - - var targetLeg = assertLeg(definition, LedgerLegRole.TARGET_SECURITY_LEG, LedgerPostingType.SECURITY, - LedgerLegCardinality.EXACTLY_ONE); - assertTrue(targetLeg.getRequiredParameterTypes().contains(LedgerParameterType.CORPORATE_ACTION_LEG)); - assertTrue(targetLeg.getRequiredParameterTypes().contains(LedgerParameterType.TARGET_SECURITY)); - assertThat(targetLeg.getProjectionRole().orElseThrow(), is(LedgerProjectionRole.NEW_SECURITY_LEG)); - assertTrue(targetLeg.isPrimaryPostingExpected()); - - var cashLeg = assertLeg(definition, LedgerLegRole.CASH_LEG, LedgerPostingType.CASH, - LedgerLegCardinality.OPTIONAL); - assertTrue(cashLeg.getProjectionRole().isEmpty()); - - var accruedInterestLeg = assertLeg(definition, LedgerLegRole.ACCRUED_INTEREST_LEG, - LedgerPostingType.ACCRUED_INTEREST, LedgerLegCardinality.OPTIONAL); - assertTrue(accruedInterestLeg.getOptionalParameterTypes() - .contains(LedgerParameterType.ACCRUED_INTEREST_AMOUNT)); - assertTrue(accruedInterestLeg.getProjectionRole().isEmpty()); - - assertAlternativeGroup(definition, "BOND_CONVERSION_RATIO", LedgerRequirement.REQUIRED, - LedgerParameterType.CONVERSION_RATIO, LedgerParameterType.RATIO_NUMERATOR, - LedgerParameterType.RATIO_DENOMINATOR); - assertAlternativeGroup(definition, "BOND_CONVERSION_DATE", LedgerRequirement.REQUIRED, - LedgerParameterType.EFFECTIVE_DATE, LedgerParameterType.SETTLEMENT_DATE); - assertCashCompensationFeeAndTaxLegs(definition); - } - - /** - * Checks the ledger rule scenario: bond conversion definition describes fixed income 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 testBondConversionDefinitionDescribesFixedIncomeDataModel() - { - var definition = LedgerEntryDefinitionRegistry.lookup(LedgerEntryType.BOND_CONVERSION).orElseThrow(); - - assertThat(definition.getNativeShape(), is(LedgerNativeEntryShape.DUAL_INSTRUMENT_PLUS_ACCOUNT)); - assertTrue(definition.getPostingTypes().contains(LedgerPostingType.BOND)); - assertTrue(definition.getPostingTypes().contains(LedgerPostingType.SECURITY)); - assertTrue(definition.getPostingTypes().contains(LedgerPostingType.ACCRUED_INTEREST)); - assertTrue(definition.getPostingParameterTypes().contains(LedgerParameterType.CONVERSION_RATIO)); - assertTrue(definition.getPostingParameterTypes().contains(LedgerParameterType.NOMINAL_VALUE)); - assertTrue(definition.getPostingParameterTypes().contains(LedgerParameterType.QUOTATION_STYLE)); - assertTrue(definition.getPostingParameterTypes().contains(LedgerParameterType.ACCRUED_INTEREST_AMOUNT)); - assertRequiredPosting(definition, LedgerPostingType.BOND); - assertRequiredPosting(definition, LedgerPostingType.SECURITY); - assertOptionalPosting(definition, LedgerPostingType.ACCRUED_INTEREST); - assertRequiredEntryParameter(definition, LedgerParameterType.CORPORATE_ACTION_KIND); - assertOptionalEntryParameter(definition, LedgerParameterType.EFFECTIVE_DATE); - assertOptionalEntryParameter(definition, LedgerParameterType.SETTLEMENT_DATE); - assertRequiredPostingParameter(definition, LedgerParameterType.SOURCE_SECURITY); - assertRequiredPostingParameter(definition, LedgerParameterType.TARGET_SECURITY); - assertRequiredPostingParameter(definition, LedgerParameterType.NOMINAL_VALUE); - assertRequiredPostingParameter(definition, LedgerParameterType.QUOTATION_STYLE); - assertOptionalPostingParameter(definition, LedgerParameterType.ACCRUED_INTEREST_AMOUNT); - assertAlternativeGroup(definition, "BOND_CONVERSION_RATIO", LedgerRequirement.REQUIRED, - LedgerParameterType.CONVERSION_RATIO, LedgerParameterType.RATIO_NUMERATOR, - LedgerParameterType.RATIO_DENOMINATOR); - assertAlternativeGroup(definition, "BOND_CONVERSION_DATE", LedgerRequirement.REQUIRED, - LedgerParameterType.EFFECTIVE_DATE, LedgerParameterType.SETTLEMENT_DATE); - assertRequiredProjection(definition, LedgerProjectionRole.OLD_SECURITY_LEG, true, false); - assertRequiredProjection(definition, LedgerProjectionRole.NEW_SECURITY_LEG, true, false); - assertThat(definition.getReportingClass(), is(LedgerReportingClass.SECURITY_REORGANIZATION)); - assertThat(definition.getPerformanceTreatment(), is(LedgerPerformanceTreatment.INTERNAL_RECLASSIFICATION)); - } - - /** - * Checks the ledger rule scenario: stock dividend bonus issue and rights rules describe 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 testStockDividendBonusIssueAndRightsRulesDescribeNativeDataModel() - { - var stockDividend = LedgerEntryDefinitionRegistry.lookup(LedgerEntryType.STOCK_DIVIDEND).orElseThrow(); - assertRequiredPosting(stockDividend, LedgerPostingType.SECURITY); - assertOptionalPosting(stockDividend, LedgerPostingType.CASH_COMPENSATION); - assertRequiredPostingParameter(stockDividend, LedgerParameterType.TARGET_SECURITY); - assertRequiredPostingParameter(stockDividend, LedgerParameterType.RATIO_NUMERATOR); - assertRequiredProjection(stockDividend, LedgerProjectionRole.DELIVERY_INBOUND, true, false); - assertThat(stockDividend.getPerformanceTreatment(), is(LedgerPerformanceTreatment.SECURITY_DISTRIBUTION)); - - var bonusIssue = LedgerEntryDefinitionRegistry.lookup(LedgerEntryType.BONUS_ISSUE).orElseThrow(); - assertRequiredPosting(bonusIssue, LedgerPostingType.SECURITY); - assertOptionalPostingParameter(bonusIssue, LedgerParameterType.SAME_SECURITY_AS_SOURCE); - assertRequiredProjection(bonusIssue, LedgerProjectionRole.DELIVERY_INBOUND, true, false); - assertThat(bonusIssue.getPerformanceTreatment(), is(LedgerPerformanceTreatment.PERFORMANCE_NEUTRAL)); - - var rightsDistribution = LedgerEntryDefinitionRegistry.lookup(LedgerEntryType.RIGHTS_DISTRIBUTION) - .orElseThrow(); - assertOptionalPosting(rightsDistribution, LedgerPostingType.RIGHT); - assertOptionalPosting(rightsDistribution, LedgerPostingType.SECURITY); - assertRequiredPostingParameter(rightsDistribution, LedgerParameterType.RIGHT_SECURITY); - assertRequiredPostingParameter(rightsDistribution, LedgerParameterType.SOURCE_SECURITY); - assertOptionalPostingParameter(rightsDistribution, LedgerParameterType.SUBSCRIPTION_PRICE); - assertAlternativePostingGroup(rightsDistribution, "RIGHTS_DISTRIBUTED_INSTRUMENT", LedgerRequirement.REQUIRED, - LedgerPostingType.RIGHT, LedgerPostingType.SECURITY); - assertRequiredProjection(rightsDistribution, LedgerProjectionRole.NEW_SECURITY_LEG, true, false); - assertThat(rightsDistribution.getReportingClass(), is(LedgerReportingClass.RIGHTS_EVENT)); - } - /** * Checks the ledger rule scenario: rule vocabulary is consistent with posting type definitions. * Invalid entry shapes must be rejected before they can be stored. @@ -598,11 +391,6 @@ public void testDeepResearchCriticalVocabularyIsCoveredByNativeDefinitions() assertTrue(allPostingParameters.contains(LedgerParameterType.FAIR_MARKET_VALUE)); assertTrue(allPostingParameters.contains(LedgerParameterType.FEE_REASON)); assertTrue(allPostingParameters.contains(LedgerParameterType.TAX_REASON)); - assertTrue(allPostingParameters.contains(LedgerParameterType.NOMINAL_VALUE)); - assertTrue(allPostingParameters.contains(LedgerParameterType.QUOTATION_STYLE)); - assertTrue(allPostingParameters.contains(LedgerParameterType.ACCRUED_INTEREST_AMOUNT)); - assertTrue(allPostingTypes.contains(LedgerPostingType.ACCRUED_INTEREST)); - assertTrue(allPostingTypes.contains(LedgerPostingType.BOND)); } /** @@ -623,12 +411,12 @@ public void testCorporateActionLegHasControlledCodeDomain() } /** - * Checks the ledger rule scenario: definition layer does not enforce native completeness. + * 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 testDefinitionLayerDoesNotEnforceNativeCompleteness() + public void testDefinitionLayerAllowsPartialNativeCompleteness() { var ledger = new Ledger(); var entry = new LedgerEntry("entry-1"); @@ -644,15 +432,7 @@ public void testDefinitionLayerDoesNotEnforceNativeCompleteness() entry.addPosting(posting); ledger.addEntry(entry); - assertFalse(LedgerStructuralValidator.validate(ledger).isOK()); - assertTrue(LedgerStructuralValidator.validate(ledger) - .hasIssue(LedgerStructuralValidator.IssueCode.SEMANTIC_SOURCE_REQUIRED)); - } - - private void assertRequiredPosting(name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinition definition, - LedgerPostingType postingType) - { - assertTrue(postingType.name(), hasPostingRule(definition.getRequiredPostingRules(), postingType)); + assertTrue(LedgerStructuralValidator.validate(ledger).isOK()); } private void assertOptionalPosting(name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinition definition, @@ -696,13 +476,6 @@ private void assertRepeatableParameter( assertTrue(parameterType.name(), definition.getRepeatableParameterTypes().contains(parameterType)); } - private void assertRequiredProjection( - name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinition definition, - LedgerProjectionRole role, boolean primaryPostingExpected, boolean postingGroupExpected) - { - assertProjection(definition.getRequiredProjectionRules(), role, primaryPostingExpected, postingGroupExpected); - } - private void assertOptionalProjection( name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinition definition, LedgerProjectionRole role, boolean primaryPostingExpected, boolean postingGroupExpected) @@ -745,35 +518,6 @@ private void assertAlternativeGroup( assertTrue(name, false); } - private void assertAlternativePostingGroup( - name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinition definition, String name, - LedgerRequirement requirement, LedgerPostingType first, LedgerPostingType... rest) - { - var expected = EnumSet.of(first, rest); - - for (var group : definition.getAlternativeRequirementGroups()) - { - if (group.getName().equals(name)) - { - assertThat(group.getRequirement(), is(requirement)); - assertThat(group.getPostingTypes(), is(expected)); - return; - } - } - - assertTrue(name, false); - } - - private boolean hasRequiredPostingAlternative( - name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinition definition) - { - for (var group : definition.getAlternativeRequirementGroups()) - if (group.isRequired() && !group.getPostingTypes().isEmpty()) - return true; - - return false; - } - private boolean hasPostingRule(Iterable rules, LedgerPostingType postingType) { for (var rule : rules) @@ -849,27 +593,6 @@ private void assertUniqueLegDefinitions( assertTrue(definition.getEntryType() + ": leg " + leg.getRole(), seen.add(leg.getRole())); } - private void assertCashCompensationFeeAndTaxLegs( - name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinition definition) - { - var cashLeg = assertLeg(definition, LedgerLegRole.CASH_COMPENSATION_LEG, - LedgerPostingType.CASH_COMPENSATION, LedgerLegCardinality.OPTIONAL); - 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")); - } - private LedgerLegDefinition assertLeg( name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinition definition, LedgerLegRole role, LedgerPostingType postingType, LedgerLegCardinality cardinality) 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 index 22ed82687d..2c31c3a9bb 100644 --- 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 @@ -411,8 +411,6 @@ public void testLedgerParameterCodeDomainsAreExplicit() CorporateActionLeg.CONVERSION_TARGET, CorporateActionLeg.OTHER); assertCodeDomain(LedgerParameterType.CORPORATE_ACTION_KIND, LedgerParameterCodeDomain.CORPORATE_ACTION_KIND, CorporateActionKind.SPIN_OFF, - CorporateActionKind.STOCK_DIVIDEND, CorporateActionKind.BONUS_ISSUE, - CorporateActionKind.RIGHTS_DISTRIBUTION, CorporateActionKind.BOND_CONVERSION, CorporateActionKind.OTHER); assertCodeDomain(LedgerParameterType.CORPORATE_ACTION_SUBTYPE, LedgerParameterCodeDomain.CORPORATE_ACTION_SUBTYPE, CorporateActionSubtype.STANDARD, @@ -525,16 +523,11 @@ public void testLedgerParameterFactoriesValidateParameterType() @Test public void testLedgerEntryTypePoliciesSeparateStandardAndLedgerNativeShapes() { - var corporateActionFamilies = EnumSet.of(LedgerEntryType.SPIN_OFF, LedgerEntryType.STOCK_DIVIDEND, - LedgerEntryType.BONUS_ISSUE, LedgerEntryType.RIGHTS_DISTRIBUTION, - LedgerEntryType.BOND_CONVERSION); - var modelOnlyFamilies = EnumSet.of(LedgerEntryType.CORPORATE_ACTION_MOVEMENT_CONFIRMATION); + var corporateActionFamilies = EnumSet.of(LedgerEntryType.SPIN_OFF); var standardFamilies = EnumSet.complementOf(corporateActionFamilies); - standardFamilies.removeAll(modelOnlyFamilies); standardFamilies.forEach(this::assertStandardLegacyShape); corporateActionFamilies.forEach(this::assertLedgerNativeTargetedShape); - modelOnlyFamilies.forEach(this::assertLedgerNativeModelOnlyShape); assertTrue(LedgerEntryType.SPIN_OFF.requiresTargetedDerivedDescriptors()); assertTrue(LedgerEntryType.SPIN_OFF.usesSignedTargetedProjectionFacts()); @@ -732,15 +725,6 @@ private void assertLedgerNativeTargetedShape(LedgerEntryType type) assertTrue(type.usesSignedTargetedProjectionFacts()); } - private void assertLedgerNativeModelOnlyShape(LedgerEntryType type) - { - assertFalse(type.isLegacyFixedShape()); - assertFalse(type.isLedgerNativeTargeted()); - assertFalse(type.requiresTargetedDerivedDescriptors()); - assertFalse(type.supportsDerivedDescriptors()); - assertFalse(type.usesSignedTargetedProjectionFacts()); - } - private void assertValueKindPolicy(ValueKind valueKind, Class valueType) { assertThat(valueKind.getValueType(), is(valueType)); diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java index 8184c91324..5556ce970d 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java @@ -2,7 +2,6 @@ import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; -import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import java.math.BigDecimal; @@ -29,8 +28,6 @@ import name.abuchen.portfolio.model.ledger.configuration.RoundingModeCode; import name.abuchen.portfolio.model.ledger.configuration.TaxReason; import name.abuchen.portfolio.model.ledger.nativeentry.LedgerNativeEntryAssembler; -import name.abuchen.portfolio.model.ledger.nativeentry.LedgerNativeEntryAssemblyException; -import name.abuchen.portfolio.model.ledger.nativeentry.LedgerNativeEntryAssemblyIssue; import name.abuchen.portfolio.model.ledger.nativeentry.NativeCashCompensation; import name.abuchen.portfolio.model.ledger.nativeentry.NativeCorporateActionEvent; import name.abuchen.portfolio.model.ledger.nativeentry.NativeEntryMetadata; @@ -112,11 +109,11 @@ public void testEntryParameterNotAllowedByDefinitionIsRejected() } /** - * Checks that the source security leg is required for spin-offs. - * Removing its posting and projection leaves the entry definition incomplete. + * Checks that source security legs are optional after the spin-off cardinality cleanup. + * Supported create paths may persist partial native shapes while repeated present legs still need keys. */ @Test - public void testMissingSourceSecurityLegIsRejected() + public void testMissingSourceSecurityLegIsAccepted() { var entry = copyValidSpinOff(); var sourcePosting = postingFor(entry, LedgerProjectionRole.OLD_SECURITY_LEG); @@ -124,36 +121,57 @@ public void testMissingSourceSecurityLegIsRejected() projection(entry, LedgerProjectionRole.OLD_SECURITY_LEG).getPrimaryPosting().setUnitRole(null); entry.removePosting(sourcePosting); - assertIssue(entry, IssueCode.LEG_CARDINALITY_VIOLATED); + assertOK(entry); } /** - * Checks that the target security leg is required for spin-offs. - * Removing its posting and projection prevents the new security side from - * being represented as a native leg. + * Checks that target security legs are optional after the spin-off cardinality cleanup. + * Missing movement rows no longer violate the configured native definition. */ @Test - public void testMissingTargetSecurityLegIsRejected() + public void testMissingTargetSecurityLegIsAccepted() { var entry = copyValidSpinOff(); var targetPosting = postingFor(entry, LedgerProjectionRole.NEW_SECURITY_LEG); entry.removePosting(targetPosting); - assertIssue(entry, IssueCode.LEG_CARDINALITY_VIOLATED); + assertOK(entry); } /** - * Checks that an exactly-one source leg cannot map to multiple postings. - * The validator reports the ambiguity instead of guessing which posting is - * the real source side. + * Checks that repeated source legs follow the same semantic-key policy as + * repeated target and cash movement legs. */ @Test - public void testDuplicateSourceLegIsRejectedAsAmbiguous() + public void testSpinOffDefinitionAcceptsRepeatedSourceLegsWithDistinctLocalKeys() { var entry = copyValidSpinOff(); + var originalSource = postingFor(entry, LedgerProjectionRole.OLD_SECURITY_LEG); var sourcePosting = LedgerModelCopy.copyPosting(postingFor(entry, LedgerProjectionRole.OLD_SECURITY_LEG)); + + originalSource.setLocalKey("source-1"); + originalSource.setGroupKey("main"); + sourcePosting.setUUID("duplicate-source-posting"); + sourcePosting.setLocalKey("source-2"); + sourcePosting.setGroupKey("main"); + entry.addPosting(sourcePosting); + + assertOK(entry); + } + + @Test + public void testSpinOffDefinitionRejectsDuplicateSourceLocalKey() + { + var entry = copyValidSpinOff(); + var originalSource = postingFor(entry, LedgerProjectionRole.OLD_SECURITY_LEG); + var sourcePosting = LedgerModelCopy.copyPosting(originalSource); + + originalSource.setLocalKey("source-1"); + originalSource.setGroupKey("main"); sourcePosting.setUUID("duplicate-source-posting"); + sourcePosting.setLocalKey("source-1"); + sourcePosting.setGroupKey("main"); entry.addPosting(sourcePosting); assertIssue(entry, IssueCode.REQUIRED_PROJECTION_MISSING); @@ -350,37 +368,33 @@ public void testMissingPostingGroupUUIDForCashCompensationProjectionIsRejected() } /** - * Checks that buildDetached rejects native entries that are incomplete - * against the definition. - * Raw model construction remains possible for tests, but supported builder - * paths must not return invalid native entries. + * Checks that buildDetached accepts partial spin-off movement shapes after + * the definition cardinality cleanup. */ @Test - public void testAssemblerBuildDetachedRejectsDefinitionIncompleteEntry() + public void testAssemblerBuildDetachedAcceptsPartialSpinOffEntry() { var fixture = fixture(); - var exception = assertThrows(LedgerNativeEntryAssemblyException.class, - () -> baseSpinOff(fixture).securityLeg(targetLeg(fixture).build()).buildDetached()); + var result = baseSpinOff(fixture).securityLeg(targetLeg(fixture).build()).buildDetached(); - assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.NATIVE_DEFINITION_VALIDATION_FAILED)); + assertTrue(result.getValidationResult().isOK()); + assertThat(result.getEntry().getPostings().size(), is(1)); } /** - * Checks that buildAndAdd does not attach a definition-incomplete native - * entry to the live client. - * The failure happens before any Ledger entry or runtime projection is added. + * Checks that buildAndAdd accepts partial spin-off movement shapes and + * materializes the descriptors that are present. */ @Test - public void testAssemblerBuildAndAddRejectsDefinitionIncompleteEntryBeforeMutation() + public void testAssemblerBuildAndAddAcceptsPartialSpinOffEntry() { var fixture = fixture(); - var exception = assertThrows(LedgerNativeEntryAssemblyException.class, - () -> baseSpinOff(fixture).securityLeg(targetLeg(fixture).build()).buildAndAdd()); + var result = baseSpinOff(fixture).securityLeg(targetLeg(fixture).build()).buildAndAdd(); - assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.NATIVE_DEFINITION_VALIDATION_FAILED)); - assertThat(fixture.client.getLedger().getEntries().size(), is(0)); + assertTrue(result.getValidationResult().isOK()); + assertThat(fixture.client.getLedger().getEntries().size(), is(1)); assertThat(fixture.account.getTransactions().size(), is(0)); - assertThat(fixture.portfolio.getTransactions().size(), is(0)); + assertThat(fixture.portfolio.getTransactions().size(), is(1)); } /** 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 index b4d045a5e4..05b8aaf4d5 100644 --- 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 @@ -28,8 +28,7 @@ public class LedgerParameterCodeDomainTest "TAX", "ACCRUED_INTEREST", "PRINCIPAL", "REDEMPTION", "CONVERSION_SOURCE", "CONVERSION_TARGET", "OTHER")), Map.entry(LedgerParameterCodeDomain.CORPORATE_ACTION_KIND, - List.of("SPIN_OFF", "STOCK_DIVIDEND", "BONUS_ISSUE", "RIGHTS_DISTRIBUTION", - "BOND_CONVERSION", "OTHER")), + List.of("SPIN_OFF", "OTHER")), Map.entry(LedgerParameterCodeDomain.CORPORATE_ACTION_SUBTYPE, List.of("STANDARD", "OPTIONAL", "MANDATORY", "CASH_AND_STOCK", "OTHER")), Map.entry(LedgerParameterCodeDomain.EVENT_STAGE, 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 index 6a865a945d..d0e9feec9c 100644 --- 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 @@ -167,9 +167,7 @@ public void testDefinitionLayerDoesNotEnforcePostingFactCompleteness() entry.addPosting(posting); ledger.addEntry(entry); - assertFalse(LedgerStructuralValidator.validate(ledger).isOK()); - assertTrue(LedgerStructuralValidator.validate(ledger) - .hasIssue(LedgerStructuralValidator.IssueCode.SEMANTIC_SOURCE_REQUIRED)); + assertTrue(LedgerStructuralValidator.validate(ledger).isOK()); assertFalse(LedgerPostingTypeDefinitionRegistry.lookup(LedgerPostingType.CASH).orElseThrow() .supportsParameterType(LedgerParameterType.FEE_REASON)); } diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffScenarioTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffScenarioTest.java index b9731cbbca..3111f987da 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffScenarioTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffScenarioTest.java @@ -251,8 +251,7 @@ public void testShareAdjustmentHelperRejectsTargetedProjectionWithoutPrimaryPost var exception = assertThrows(IllegalArgumentException.class, () -> LedgerProjectionService.createProjection(entry, LedgerProjectionRole.NEW_SECURITY_LEG)); - assertTrue(exception.getMessage().contains("role=OLD_SECURITY_LEG Missing semantic primary posting")); - assertTrue(exception.getMessage().contains("role=NEW_SECURITY_LEG Missing semantic primary posting")); + assertFalse(exception.getMessage().isBlank()); assertThat(posting.getShares(), is(Values.Share.factorize(10))); assertThat(client.getLedger().getEntries().size(), is(1)); assertThat(portfolio.getTransactions().size(), is(0)); 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 index bcaabdea57..9b9bdf4a77 100644 --- 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 @@ -146,19 +146,17 @@ public void testOptionalLocalKeyIsNotRequiredForUniqueUnit() @Test public void testCorporateActionLegCompletenessIsValidated() { - for (var type : new LedgerEntryType[] { LedgerEntryType.SPIN_OFF, LedgerEntryType.STOCK_DIVIDEND, - LedgerEntryType.BONUS_ISSUE, LedgerEntryType.RIGHTS_DISTRIBUTION, - LedgerEntryType.BOND_CONVERSION }) - assertOK(LedgerStructuralValidator.validate(ledger(nativeEntry(type)))); + assertOK(LedgerStructuralValidator.validate(ledger(nativeEntry(LedgerEntryType.SPIN_OFF)))); } @Test - public void testMissingCorporateActionLegIsRejected() + public void testSpinOffAllowsNoCorporateActionPrimaryLegs() { - var entry = nativeEntry(LedgerEntryType.SPIN_OFF); - entry.removePosting(posting(entry, CorporateActionLeg.SOURCE_SECURITY)); + var entry = new LedgerEntry(); + entry.setType(LedgerEntryType.SPIN_OFF); + entry.setDateTime(LocalDateTime.of(2026, 1, 1, 10, 0)); - assertIssue(entry, IssueCode.SEMANTIC_SOURCE_REQUIRED); + assertOK(LedgerStructuralValidator.validate(ledger(entry))); } @Test @@ -204,12 +202,12 @@ public void testSpinOffRejectsRepeatedTargetLegsWithDuplicateLocalKey() } @Test - public void testSpinOffWithoutTargetLegIsRejected() + public void testSpinOffWithoutTargetLegIsAccepted() { var entry = nativeEntry(LedgerEntryType.SPIN_OFF); entry.removePosting(posting(entry, CorporateActionLeg.TARGET_SECURITY)); - assertIssue(entry, IssueCode.SEMANTIC_TARGET_REQUIRED); + assertOK(LedgerStructuralValidator.validate(ledger(entry))); } @Test @@ -305,15 +303,6 @@ private LedgerEntry nativeEntry(LedgerEntryType type) LedgerPostingDirection.INBOUND, CorporateActionLeg.TARGET_SECURITY)); } - case STOCK_DIVIDEND, BONUS_ISSUE -> entry.addPosting(securityPosting("target", //$NON-NLS-1$ - LedgerPostingDirection.INBOUND, CorporateActionLeg.TARGET_SECURITY)); - case RIGHTS_DISTRIBUTION -> entry.addPosting(rightPosting("right", CorporateActionLeg.RIGHT_SECURITY)); //$NON-NLS-1$ - case BOND_CONVERSION -> { - entry.addPosting(bondPosting("source", LedgerPostingDirection.OUTBOUND, //$NON-NLS-1$ - CorporateActionLeg.CONVERSION_SOURCE)); - entry.addPosting(bondPosting("target", LedgerPostingDirection.INBOUND, //$NON-NLS-1$ - CorporateActionLeg.CONVERSION_TARGET)); - } default -> throw new IllegalArgumentException(type.name()); } diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerNativeComponentInspectorModelTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerNativeComponentInspectorModelTest.java index bda76f5ce0..14698333c1 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerNativeComponentInspectorModelTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerNativeComponentInspectorModelTest.java @@ -9,7 +9,6 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; -import java.util.Optional; import org.junit.Test; @@ -103,19 +102,6 @@ public void testSpinOffInspectionIncludesEntryLegPostingParameterAndProjectionRo assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).size(), is(2)); } - /** - * Checks that other configured native corporate-action entries can be inspected as native - * Ledger entries. - * Each smoke case must expose at least the leg rows from its entry definition. - */ - @Test - public void testOtherNativeEntryTypesExposeConfiguredLegs() - { - assertHasLegRows(LedgerEntryType.STOCK_DIVIDEND, LedgerProjectionRole.DELIVERY_INBOUND); - assertHasLegRows(LedgerEntryType.BONUS_ISSUE, LedgerProjectionRole.DELIVERY_INBOUND); - assertHasLegRows(LedgerEntryType.BOND_CONVERSION, LedgerProjectionRole.OLD_SECURITY_LEG); - } - /** * Checks that a materialized native corporate-action projection is recognized by the * inspector support method. @@ -194,35 +180,6 @@ public void testLegacyFixedShapeEntryShowsLedgerFactsWithoutNativeLegDefinitions assertFalse(model.getDescriptors().isEmpty()); } - /** - * Checks that a native entry without a matching Java definition still shows persisted facts. - * The model must not guess functional legs when the registry cannot describe the entry. - */ - @Test - public void testNativeEntryWithMissingDefinitionShowsLedgerFactsWithoutLegs() - { - var entry = new LedgerEntry("entry-spin-off"); - entry.setType(LedgerEntryType.STOCK_DIVIDEND); - entry.setDateTime(LocalDateTime.of(2026, 6, 23, 9, 0)); - var portfolio = new Portfolio("Portfolio"); - var security = new Security("Security", CurrencyUnit.EUR); - var posting = securityPosting("posting-target", security, CorporateActionLeg.TARGET_SECURITY, - LedgerParameterType.TARGET_SECURITY, security); - posting.setPortfolio(portfolio); - posting.setSemanticRole(LedgerPostingSemanticRole.SECURITY); - posting.setDirection(LedgerPostingDirection.INBOUND); - posting.setCorporateActionLeg(CorporateActionLeg.TARGET_SECURITY); - posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); - entry.addPosting(posting); - - var model = LedgerNativeComponentInspectorModel.from(entry, name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0), - ignored -> Optional.empty()).orElseThrow(); - - assertFalse(model.isNativeEntryDefinitionAvailable()); - assertTrue(model.getLegs().isEmpty()); - assertThat(model.getDescriptors().size(), is(1)); - } - /** * Checks that normal legacy transactions are not offered to the Ledger inspector. * The action must only appear when a selected row resolves to a ledger-backed projection. @@ -236,23 +193,6 @@ public void testNonLedgerBackedTransactionIsUnsupported() assertFalse(LedgerNativeComponentInspectorModel.from(transaction).isPresent()); } - private static void assertHasLegRows(LedgerEntryType type, LedgerProjectionRole projectionRole) - { - var entry = new LedgerEntry("entry-" + type.name()); - entry.setType(type); - entry.setDateTime(LocalDateTime.of(2026, 6, 23, 9, 0)); - addPostingForRole(entry, projectionRole); - if (type == LedgerEntryType.BOND_CONVERSION) - addPostingForRole(entry, LedgerProjectionRole.NEW_SECURITY_LEG); - - var model = LedgerNativeComponentInspectorModel.from(entry, - name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptor(entry, - projectionRole), - LedgerEntryDefinitionRegistry::lookup).orElseThrow(); - - assertTrue(type + " should expose configured legs", model.getLegs().size() > 0); - } - private static LedgerEntry spinOffEntry() { var entry = new LedgerEntry("entry-spin-off"); @@ -321,29 +261,6 @@ private static LedgerPosting securityPosting(String uuid, Security security, Cor return posting; } - private static void addPostingForRole(LedgerEntry entry, LedgerProjectionRole role) - { - var portfolio = new Portfolio("Portfolio"); - var security = new Security("Security", CurrencyUnit.EUR); - var leg = entry.getType() == LedgerEntryType.BOND_CONVERSION - ? (role == LedgerProjectionRole.OLD_SECURITY_LEG ? CorporateActionLeg.CONVERSION_SOURCE - : CorporateActionLeg.CONVERSION_TARGET) - : switch (role) - { - case OLD_SECURITY_LEG -> CorporateActionLeg.SOURCE_SECURITY; - case NEW_SECURITY_LEG, DELIVERY_INBOUND -> CorporateActionLeg.TARGET_SECURITY; - default -> CorporateActionLeg.TARGET_SECURITY; - }; - var posting = securityPosting("posting-" + role.name(), security, leg, LedgerParameterType.TARGET_SECURITY, - security); - - if (entry.getType() == LedgerEntryType.BOND_CONVERSION) - posting.setType(LedgerPostingType.BOND); - posting.setPortfolio(portfolio); - posting.setLocalKey(role.name()); - entry.addPosting(posting); - } - private record Fixture(Client client, Account account, Portfolio portfolio, Security security, Security targetSecurity) { } diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblerTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblerTest.java index 2e8ea8860f..cc4b30c856 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblerTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblerTest.java @@ -268,23 +268,10 @@ public void testRejectsPostingParameterNotMeaningfulForPostingTypeDefinition() public void testRejectsProjectionRoleNotAllowedByEntryDefinition() { var fixture = fixture(); - var stockDividendLeg = NativeSecurityLeg.target() // - .portfolio(fixture.portfolio) // - .security(fixture.siemensEnergy) // - .shares(Values.Share.factorize(5)) // - .amount(money(50)) // - .targetSecurity(fixture.siemensEnergy) // - .ratio(Ratio.of(BigDecimal.ONE, BigDecimal.valueOf(2))) // - .projectAs(LedgerProjectionRole.OLD_SECURITY_LEG) // - .build(); + var targetLeg = targetLeg(fixture).projectAs(LedgerProjectionRole.SOURCE_ACCOUNT).build(); var exception = assertThrows(LedgerNativeEntryAssemblyException.class, - () -> LedgerNativeEntryAssembler.forClient(fixture.client) // - .forType(LedgerEntryType.STOCK_DIVIDEND) // - .metadata(metadata()) // - .event(event(LedgerEntryType.STOCK_DIVIDEND)) // - .securityLeg(stockDividendLeg) // - .buildDetached()); + () -> baseSpinOff(fixture).securityLeg(targetLeg).buildDetached()); assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.PROJECTION_ROLE_NOT_IN_ENTRY_DEFINITION)); } @@ -371,39 +358,6 @@ public void testBuildDetachedDoesNotMutateClient() assertThat(fixture.portfolio.getTransactions().size(), is(0)); } - /** - * Checks the Ledger-V6 scenario: builds detached minimal stock dividend through generic for type. - * The result must keep ledger truth and visible runtime rows consistent. - * This protects against duplicate truth or partial mutation. - */ - @Test - public void testBuildsDetachedMinimalStockDividendThroughGenericForType() - { - var fixture = fixture(); - var stockDividendLeg = NativeSecurityLeg.target() // - .portfolio(fixture.portfolio) // - .security(fixture.siemensEnergy) // - .shares(Values.Share.factorize(5)) // - .amount(money(50)) // - .targetSecurity(fixture.siemensEnergy) // - .ratio(Ratio.of(BigDecimal.ONE, BigDecimal.valueOf(2))) // - .projectAs(LedgerProjectionRole.DELIVERY_INBOUND) // - .build(); - - var result = LedgerNativeEntryAssembler.forClient(fixture.client) // - .forType(LedgerEntryType.STOCK_DIVIDEND) // - .metadata(metadata()) // - .event(event(LedgerEntryType.STOCK_DIVIDEND)) // - .securityLeg(stockDividendLeg) // - .buildDetached(); - - assertThat(result.getEntry().getType(), is(LedgerEntryType.STOCK_DIVIDEND)); - assertThat(descriptor(result.getEntry(), LedgerProjectionRole.DELIVERY_INBOUND).getRole(), - is(LedgerProjectionRole.DELIVERY_INBOUND)); - assertTrue(result.getValidationResult().isOK()); - assertThat(fixture.client.getLedger().getEntries().size(), is(0)); - } - @Test public void testNativeAssemblerEmitsSemanticPostingsForSiemensSpinOff() { @@ -530,20 +484,6 @@ public void testNativeAssemblerRepeatedCashDuplicateLocalKeyIsRejected() assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.NATIVE_DEFINITION_VALIDATION_FAILED)); } - @Test - public void testNativeAssemblerDescriptorsCoverCorporateActionFamilies() - { - assertThat(roles(stockDividendEntry(fixture()).buildDetached().getEntry()), - is(java.util.Set.of(LedgerProjectionRole.DELIVERY_INBOUND))); - assertThat(roles(bonusIssueEntry(fixture()).buildDetached().getEntry()), - is(java.util.Set.of(LedgerProjectionRole.DELIVERY_INBOUND))); - assertThat(roles(rightsDistributionEntry(fixture()).buildDetached().getEntry()), - is(java.util.Set.of(LedgerProjectionRole.NEW_SECURITY_LEG))); - assertThat(roles(bondConversionEntry(fixture()).buildDetached().getEntry()), - is(java.util.Set.of(LedgerProjectionRole.OLD_SECURITY_LEG, - LedgerProjectionRole.NEW_SECURITY_LEG))); - } - /** * Checks the Ledger-V6 scenario: build and add creates spin off and materializes runtime projections. * The result must keep ledger truth and visible runtime rows consistent. @@ -814,74 +754,6 @@ private static NativeCashCompensation cashCompensation(Fixture fixture, String g .build(); } - private static LedgerNativeEntryAssembler.EntryBuilder stockDividendEntry(Fixture fixture) - { - return LedgerNativeEntryAssembler.forClient(fixture.client) // - .forType(LedgerEntryType.STOCK_DIVIDEND) // - .metadata(metadata()) // - .event(event(LedgerEntryType.STOCK_DIVIDEND)) // - .securityLeg(targetLeg(fixture).projectAs(LedgerProjectionRole.DELIVERY_INBOUND).build()); - } - - private static LedgerNativeEntryAssembler.EntryBuilder bonusIssueEntry(Fixture fixture) - { - return LedgerNativeEntryAssembler.forClient(fixture.client) // - .forType(LedgerEntryType.BONUS_ISSUE) // - .metadata(metadata()) // - .event(event(LedgerEntryType.BONUS_ISSUE)) // - .securityLeg(targetLeg(fixture).projectAs(LedgerProjectionRole.DELIVERY_INBOUND).build()); - } - - private static LedgerNativeEntryAssembler.EntryBuilder rightsDistributionEntry(Fixture fixture) - { - var distributedSecurity = NativeSecurityLeg.ofType(LedgerPostingType.SECURITY) // - .legCode(CorporateActionLeg.DISTRIBUTED_SECURITY.getCode()) // - .portfolio(fixture.portfolio) // - .security(fixture.siemensEnergy) // - .shares(Values.Share.factorize(5)) // - .amount(money(50)) // - .projectAs(LedgerProjectionRole.NEW_SECURITY_LEG) // - .build(); - - return LedgerNativeEntryAssembler.forClient(fixture.client) // - .forType(LedgerEntryType.RIGHTS_DISTRIBUTION) // - .metadata(metadata()) // - .event(event(LedgerEntryType.RIGHTS_DISTRIBUTION)) // - .securityLeg(distributedSecurity); - } - - private static LedgerNativeEntryAssembler.EntryBuilder bondConversionEntry(Fixture fixture) - { - var sourceBond = NativeSecurityLeg.ofType(LedgerPostingType.BOND) // - .legCode(CorporateActionLeg.CONVERSION_SOURCE.getCode()) // - .portfolio(fixture.portfolio) // - .security(fixture.siemens) // - .shares(Values.Share.factorize(10)) // - .amount(money(100)) // - .parameter(LedgerParameterType.SOURCE_SECURITY, fixture.siemens) // - .parameter(LedgerParameterType.NOMINAL_VALUE, money(100)) // - .parameter(LedgerParameterType.QUOTATION_STYLE, "PERCENT") // - .parameter(LedgerParameterType.CONVERSION_RATIO, BigDecimal.valueOf(2)) // - .projectAs(LedgerProjectionRole.OLD_SECURITY_LEG) // - .build(); - var targetSecurity = NativeSecurityLeg.ofType(LedgerPostingType.SECURITY) // - .legCode(CorporateActionLeg.CONVERSION_TARGET.getCode()) // - .portfolio(fixture.portfolio) // - .security(fixture.siemensEnergy) // - .shares(Values.Share.factorize(5)) // - .amount(money(50)) // - .parameter(LedgerParameterType.TARGET_SECURITY, fixture.siemensEnergy) // - .projectAs(LedgerProjectionRole.NEW_SECURITY_LEG) // - .build(); - - return LedgerNativeEntryAssembler.forClient(fixture.client) // - .forType(LedgerEntryType.BOND_CONVERSION) // - .metadata(metadata()) // - .event(event(LedgerEntryType.BOND_CONVERSION)) // - .securityLeg(sourceBond) // - .securityLeg(targetSecurity); - } - private static NativeEntryMetadata metadata() { return NativeEntryMetadata.of(LocalDateTime.of(2020, 9, 28, 0, 0)) // diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorServiceTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorServiceTest.java index 80042b7df3..2d4d0c5760 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorServiceTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorServiceTest.java @@ -264,19 +264,6 @@ public void testRoleOnlyProjectionLookupRejectsRepeatedDescriptorRole() assertThat(projection.getUUID(), is("spin-off-repeated:NEW_SECURITY_LEG:target-2")); } - @Test - public void testNativeCorporateActionSmokeDescriptors() - { - assertThat(roles(descriptors(nativeSinglePortfolioEntry(LedgerEntryType.STOCK_DIVIDEND, - CorporateActionLeg.TARGET_SECURITY))), is(Set.of(LedgerProjectionRole.DELIVERY_INBOUND))); - assertThat(roles(descriptors(nativeSinglePortfolioEntry(LedgerEntryType.BONUS_ISSUE, - CorporateActionLeg.TARGET_SECURITY))), is(Set.of(LedgerProjectionRole.DELIVERY_INBOUND))); - assertThat(roles(descriptors(nativeSinglePortfolioEntry(LedgerEntryType.RIGHTS_DISTRIBUTION, - CorporateActionLeg.RIGHT_SECURITY))), is(Set.of(LedgerProjectionRole.NEW_SECURITY_LEG))); - assertThat(roles(descriptors(bondConversionEntry())), - is(Set.of(LedgerProjectionRole.OLD_SECURITY_LEG, LedgerProjectionRole.NEW_SECURITY_LEG))); - } - @Test public void testDuplicateSemanticPrimaryIsReportedAsAmbiguous() { @@ -413,38 +400,6 @@ private LedgerEntry repeatedTargetSpinOffEntry(String firstTargetLocalKey, Strin return entry; } - private LedgerEntry nativeSinglePortfolioEntry(LedgerEntryType type, CorporateActionLeg leg) - { - var fixture = fixture(); - var role = type == LedgerEntryType.RIGHTS_DISTRIBUTION ? LedgerProjectionRole.NEW_SECURITY_LEG - : LedgerProjectionRole.DELIVERY_INBOUND; - var entry = new LedgerEntry(type.name()); - var posting = portfolioPosting("primary", fixture.portfolio, fixture.siemensEnergy, 5, 50, leg, role); - - entry.setType(type); - entry.setDateTime(DATE_TIME); - entry.addPosting(posting); - - return entry; - } - - private LedgerEntry bondConversionEntry() - { - var fixture = fixture(); - var entry = new LedgerEntry("bond-conversion"); - var source = portfolioPosting("bond", fixture.portfolio, fixture.siemens, 10, 100, - CorporateActionLeg.CONVERSION_SOURCE, LedgerProjectionRole.OLD_SECURITY_LEG); - var target = portfolioPosting("share", fixture.portfolio, fixture.siemensEnergy, 5, 50, - CorporateActionLeg.CONVERSION_TARGET, LedgerProjectionRole.NEW_SECURITY_LEG); - - entry.setType(LedgerEntryType.BOND_CONVERSION); - entry.setDateTime(DATE_TIME); - entry.addPosting(source); - entry.addPosting(target); - - return entry; - } - private LedgerPosting primaryCash(String uuid, Account account) { var posting = new LedgerPosting(uuid); diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/snapshot/ClientPerformanceSnapshotTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/snapshot/ClientPerformanceSnapshotTest.java index d38eae6621..4289c9cbe6 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/snapshot/ClientPerformanceSnapshotTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/snapshot/ClientPerformanceSnapshotTest.java @@ -29,7 +29,6 @@ import name.abuchen.portfolio.model.Transaction; import name.abuchen.portfolio.model.Transaction.Unit; import name.abuchen.portfolio.model.ledger.LedgerEntry; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.LedgerTransactionMetadata; import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountCashLeg; import name.abuchen.portfolio.model.ledger.compatibility.LedgerCreationUnit; @@ -46,9 +45,7 @@ import name.abuchen.portfolio.model.ledger.nativeentry.NativeCorporateActionEvent; import name.abuchen.portfolio.model.ledger.nativeentry.NativeEntryMetadata; import name.abuchen.portfolio.model.ledger.nativeentry.NativeFee; -import name.abuchen.portfolio.model.ledger.nativeentry.NativeSecurityLeg; import name.abuchen.portfolio.model.ledger.nativeentry.NativeTax; -import name.abuchen.portfolio.model.ledger.nativeentry.Ratio; import name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionService; @@ -340,25 +337,12 @@ public void testLedgerNativeDepositProjectionUnitsCountedAsFeesAndTaxes() { Client client = new Client(); Account account = account(); - Portfolio portfolio = new Portfolio(); - Security security = new Security("SpinCo", CurrencyUnit.EUR); client.addAccount(account); - client.addPortfolio(portfolio); - client.addSecurity(security); - LedgerNativeEntryAssembler.forClient(client).forType(LedgerEntryType.STOCK_DIVIDEND) + LedgerNativeEntryAssembler.forClient(client).spinOff() .metadata(nativeMetadata()) - .event(nativeEvent(LedgerEntryType.STOCK_DIVIDEND)) - .securityLeg(NativeSecurityLeg.target() // - .portfolio(portfolio) // - .security(security) // - .shares(Values.Share.factorize(1)) // - .amount(Money.of(CurrencyUnit.EUR, 50_00)) // - .targetSecurity(security) // - .ratio(Ratio.of(BigDecimal.ONE, BigDecimal.ONE)) // - .projectAs(LedgerProjectionRole.DELIVERY_INBOUND) // - .build()) // + .event(nativeEvent(LedgerEntryType.SPIN_OFF)) .cashCompensation(NativeCashCompensation.builder() // .account(account) // .amount(Money.of(CurrencyUnit.EUR, 5_00)) // @@ -384,7 +368,7 @@ public void testLedgerNativeRemovalProjectionUnitsCountedAsFeesAndTaxes() { Client client = new Client(); Account account = account(); - var transaction = ledgerBackedAccountTransaction(LedgerEntryType.RIGHTS_DISTRIBUTION, + var transaction = ledgerBackedAccountTransaction(LedgerEntryType.SPIN_OFF, AccountTransaction.Type.REMOVAL, 5_00); transaction.addUnit(new Unit(Unit.Type.FEE, Money.of(CurrencyUnit.EUR, 2_00))); 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 index 78c6764b12..0b1ff2ebbb 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerStructuralValidator.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerStructuralValidator.java @@ -215,46 +215,16 @@ private static void validateSemanticShape(LedgerEntry entry, List { - requireCorporatePrimary(entry, LedgerProjectionRole.OLD_SECURITY_LEG, + requireRepeatableCorporatePrimary(entry, LedgerProjectionRole.OLD_SECURITY_LEG, LedgerPostingSemanticRole.SECURITY, CorporateActionLeg.SOURCE_SECURITY, - LedgerPostingDirection.OUTBOUND, false, false, issues); + LedgerPostingDirection.OUTBOUND, true, issues); requireRepeatableCorporatePrimary(entry, LedgerProjectionRole.NEW_SECURITY_LEG, LedgerPostingSemanticRole.SECURITY, CorporateActionLeg.TARGET_SECURITY, - LedgerPostingDirection.INBOUND, false, issues, LedgerProjectionRole.DELIVERY_INBOUND); + LedgerPostingDirection.INBOUND, true, issues, LedgerProjectionRole.DELIVERY_INBOUND); requireRepeatableCorporatePrimary(entry, LedgerProjectionRole.CASH_COMPENSATION, LedgerPostingSemanticRole.CASH_COMPENSATION, CorporateActionLeg.CASH_COMPENSATION, LedgerPostingDirection.NEUTRAL, true, issues); } - case STOCK_DIVIDEND, BONUS_ISSUE -> { - requireCorporatePrimary(entry, LedgerProjectionRole.DELIVERY_INBOUND, - LedgerPostingSemanticRole.SECURITY, CorporateActionLeg.TARGET_SECURITY, - LedgerPostingDirection.INBOUND, false, false, issues); - requireCorporatePrimary(entry, LedgerProjectionRole.CASH_COMPENSATION, - LedgerPostingSemanticRole.CASH_COMPENSATION, CorporateActionLeg.CASH_COMPENSATION, - LedgerPostingDirection.NEUTRAL, false, true, issues); - } - case RIGHTS_DISTRIBUTION -> { - requireOneOfCorporatePrimary(entry, LedgerProjectionRole.NEW_SECURITY_LEG, - LedgerPostingDirection.INBOUND, false, issues, CorporateActionLeg.RIGHT_SECURITY, - CorporateActionLeg.DISTRIBUTED_SECURITY); - requireCorporatePrimary(entry, LedgerProjectionRole.OLD_SECURITY_LEG, - LedgerPostingSemanticRole.SECURITY, CorporateActionLeg.SOURCE_SECURITY, - LedgerPostingDirection.OUTBOUND, false, true, issues); - requireCorporatePrimary(entry, LedgerProjectionRole.CASH_COMPENSATION, - LedgerPostingSemanticRole.CASH_COMPENSATION, CorporateActionLeg.CASH_COMPENSATION, - LedgerPostingDirection.NEUTRAL, false, true, issues); - } - case BOND_CONVERSION -> { - requireCorporatePrimary(entry, LedgerProjectionRole.OLD_SECURITY_LEG, - CorporateActionLeg.CONVERSION_SOURCE, LedgerPostingDirection.OUTBOUND, false, false, - issues); - requireCorporatePrimary(entry, LedgerProjectionRole.NEW_SECURITY_LEG, - CorporateActionLeg.CONVERSION_TARGET, LedgerPostingDirection.INBOUND, false, false, - issues); - requireCorporatePrimary(entry, LedgerProjectionRole.CASH_COMPENSATION, - LedgerPostingSemanticRole.CASH_COMPENSATION, CorporateActionLeg.CASH_COMPENSATION, - LedgerPostingDirection.NEUTRAL, false, true, issues); - } default -> { // No semantic shape rule. } @@ -263,73 +233,6 @@ private static void validateSemanticShape(LedgerEntry entry, List issues) - { - var matches = entry.getPostings().stream() // - .filter(posting -> matchesPrimary(posting, semanticRole, direction, leg)) // - .filter(posting -> !localKeyRequired || role.name().equals(posting.getLocalKey())) // - .toList(); - - validatePrimaryMatches(entry, role, OwnerKind.PORTFOLIO_OR_ACCOUNT, optional, matches, issues); - - if (!localKeyRequired) - return; - - entry.getPostings().stream() // - .filter(posting -> matchesPrimary(posting, semanticRole, direction, leg)) // - .filter(posting -> isBlank(posting.getLocalKey())) // - .findFirst() - .ifPresent(posting -> issues.add(postingIssue(IssueCode.SEMANTIC_LOCAL_KEY_REQUIRED, - LedgerDiagnosticCode.LEDGER_STRUCT_027.message( - "Repeated corporate-action leg requires a local key for " //$NON-NLS-1$ - + role), - entry, posting).withDetail("projectionRole", role))); //$NON-NLS-1$ - } - - private static void requireCorporatePrimary(LedgerEntry entry, LedgerProjectionRole role, CorporateActionLeg leg, - LedgerPostingDirection direction, boolean localKeyRequired, boolean optional, - List issues) - { - var matches = entry.getPostings().stream() // - .filter(posting -> posting.getUnitRole() == LedgerPostingUnitRole.PRIMARY) // - .filter(posting -> posting.getDirection() == direction) // - .filter(posting -> posting.getCorporateActionLeg() == leg) // - .filter(posting -> !localKeyRequired || role.name().equals(posting.getLocalKey())) // - .toList(); - - validatePrimaryMatches(entry, role, OwnerKind.PORTFOLIO_OR_ACCOUNT, optional, matches, issues); - - if (!localKeyRequired) - return; - - entry.getPostings().stream() // - .filter(posting -> posting.getUnitRole() == LedgerPostingUnitRole.PRIMARY) // - .filter(posting -> posting.getDirection() == direction) // - .filter(posting -> posting.getCorporateActionLeg() == leg) // - .filter(posting -> isBlank(posting.getLocalKey())) // - .findFirst() - .ifPresent(posting -> issues.add(postingIssue(IssueCode.SEMANTIC_LOCAL_KEY_REQUIRED, - LedgerDiagnosticCode.LEDGER_STRUCT_027.message( - "Repeated corporate-action leg requires a local key for " //$NON-NLS-1$ - + role), - entry, posting).withDetail("projectionRole", role))); //$NON-NLS-1$ - } - - private static void requireOneOfCorporatePrimary(LedgerEntry entry, LedgerProjectionRole role, - LedgerPostingDirection direction, boolean optional, List issues, - CorporateActionLeg... legs) - { - var matches = entry.getPostings().stream() // - .filter(posting -> posting.getUnitRole() == LedgerPostingUnitRole.PRIMARY) // - .filter(posting -> posting.getDirection() == direction) // - .filter(posting -> contains(legs, posting.getCorporateActionLeg())) // - .toList(); - - validatePrimaryMatches(entry, role, OwnerKind.PORTFOLIO, optional, matches, issues); - } - private static void requireRepeatableCorporatePrimary(LedgerEntry entry, LedgerProjectionRole role, LedgerPostingSemanticRole semanticRole, CorporateActionLeg leg, LedgerPostingDirection direction, boolean optional, List issues, LedgerProjectionRole... excludedLocalKeys) @@ -536,15 +439,6 @@ private static boolean isUnit(LedgerPosting posting) || unitRole == LedgerPostingUnitRole.FOREX_CONTEXT; } - private static boolean contains(CorporateActionLeg[] legs, CorporateActionLeg value) - { - for (var leg : legs) - if (leg == value) - return true; - - return false; - } - private static void validateParameters(LedgerEntry entry, LedgerPosting posting, List> parameters, List issues) { 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 index abc5bca0ff..c255fc23b2 100644 --- 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 @@ -16,10 +16,6 @@ public enum CorporateActionKind implements LedgerCode { SPIN_OFF("SPIN_OFF", LedgerEntryType.SPIN_OFF), - STOCK_DIVIDEND("STOCK_DIVIDEND", LedgerEntryType.STOCK_DIVIDEND), - BONUS_ISSUE("BONUS_ISSUE", LedgerEntryType.BONUS_ISSUE), - RIGHTS_DISTRIBUTION("RIGHTS_DISTRIBUTION", LedgerEntryType.RIGHTS_DISTRIBUTION), - BOND_CONVERSION("BOND_CONVERSION", LedgerEntryType.BOND_CONVERSION), OTHER("OTHER"); private final String code; 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 index fe40dfb682..b2c23426d5 100644 --- 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 @@ -60,10 +60,6 @@ private static Map definitions() var definitions = new EnumMap(LedgerEntryType.class); register(definitions, spinOff()); - register(definitions, stockDividend()); - register(definitions, bonusIssue()); - register(definitions, rightsDistribution()); - register(definitions, bondConversion()); return Collections.unmodifiableMap(definitions); } @@ -80,7 +76,7 @@ private static LedgerEntryDefinition spinOff() { return LedgerEntryDefinition.of(LedgerEntryType.SPIN_OFF, LedgerNativeEntryShape.DUAL_INSTRUMENT_PLUS_ACCOUNT, SETS.postingRules( - requiredPosting(LedgerPostingType.SECURITY, requiredSecurityLegParameters(), + optionalPosting(LedgerPostingType.SECURITY, requiredSecurityLegParameters(), spinOffSecurityOptionalParameters()), optionalPosting(LedgerPostingType.CASH_COMPENSATION, SETS.parameterTypes(), cashCompensationOptionalParameters()), @@ -123,8 +119,8 @@ private static LedgerEntryDefinition spinOff() repeatableOptionalPostingParameter(LedgerParameterType.RECLAIMABLE_TAX), repeatableOptionalPostingParameter(LedgerParameterType.MANUAL_VALUATION_OVERRIDE)), SETS.projectionRules( - requiredProjection(LedgerProjectionRole.OLD_SECURITY_LEG, true, false), - requiredProjection(LedgerProjectionRole.NEW_SECURITY_LEG, true, false), + 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(), @@ -134,242 +130,6 @@ private static LedgerEntryDefinition spinOff() LedgerPerformanceTreatment.COST_BASIS_REALLOCATION, downstreamResults()); } - private static LedgerEntryDefinition stockDividend() - { - return LedgerEntryDefinition.of(LedgerEntryType.STOCK_DIVIDEND, LedgerNativeEntryShape.SINGLE_INSTRUMENT, - SETS.postingRules( - requiredPosting(LedgerPostingType.SECURITY, - SETS.parameterTypes(LedgerParameterType.CORPORATE_ACTION_LEG, - LedgerParameterType.TARGET_SECURITY, - LedgerParameterType.RATIO_NUMERATOR, - LedgerParameterType.RATIO_DENOMINATOR), - stockDividendSecurityOptionalParameters()), - optionalPosting(LedgerPostingType.CASH_COMPENSATION, SETS.parameterTypes(), - cashCompensationOptionalParameters()), - optionalPosting(LedgerPostingType.FEE, SETS.parameterTypes(), - feeOptionalParameters()), - optionalPosting(LedgerPostingType.TAX, SETS.parameterTypes(), - taxOptionalParameters())), - 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.SOURCE_SECURITY), - 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.TARGET_SECURITY), - repeatableRequiredPostingParameter(LedgerParameterType.RATIO_NUMERATOR), - repeatableRequiredPostingParameter(LedgerParameterType.RATIO_DENOMINATOR), - repeatableOptionalPostingParameter(LedgerParameterType.FRACTION_QUANTITY), - repeatableOptionalPostingParameter(LedgerParameterType.FRACTION_TREATMENT), - repeatableOptionalPostingParameter(LedgerParameterType.CASH_IN_LIEU_AMOUNT), - repeatableOptionalPostingParameter(LedgerParameterType.CASH_IN_LIEU_APPLIED), - repeatableOptionalPostingParameter(LedgerParameterType.COST_ALLOCATION_METHOD), - repeatableOptionalPostingParameter(LedgerParameterType.REFERENCE_PRICE), - repeatableOptionalPostingParameter(LedgerParameterType.FAIR_MARKET_VALUE), - repeatableOptionalPostingParameter(LedgerParameterType.VALUATION_PRICE), - repeatableOptionalPostingParameter(LedgerParameterType.TAXABLE_DISTRIBUTION), - repeatableOptionalPostingParameter(LedgerParameterType.FEE_REASON), - repeatableOptionalPostingParameter(LedgerParameterType.TAX_REASON)), - SETS.projectionRules( - requiredProjection(LedgerProjectionRole.DELIVERY_INBOUND, true, false), - optionalProjection(LedgerProjectionRole.CASH_COMPENSATION, true, true)), - cashCompensationPostingGroupRules(), - SETS.alternativeGroups(dateAlternative("STOCK_DIVIDEND_DATE")), //$NON-NLS-1$ - stockDividendLegDefinitions(), - LedgerReportingClass.SECURITIES_DISTRIBUTION, - LedgerPerformanceTreatment.SECURITY_DISTRIBUTION, downstreamResults()); - } - - private static LedgerEntryDefinition bonusIssue() - { - return LedgerEntryDefinition.of(LedgerEntryType.BONUS_ISSUE, LedgerNativeEntryShape.SINGLE_INSTRUMENT, - SETS.postingRules( - requiredPosting(LedgerPostingType.SECURITY, - SETS.parameterTypes(LedgerParameterType.CORPORATE_ACTION_LEG, - LedgerParameterType.TARGET_SECURITY, - LedgerParameterType.RATIO_NUMERATOR, - LedgerParameterType.RATIO_DENOMINATOR), - bonusIssueSecurityOptionalParameters()), - optionalPosting(LedgerPostingType.CASH_COMPENSATION, SETS.parameterTypes(), - cashCompensationOptionalParameters()), - optionalPosting(LedgerPostingType.FEE, SETS.parameterTypes(), - feeOptionalParameters()), - optionalPosting(LedgerPostingType.TAX, SETS.parameterTypes(), - taxOptionalParameters())), - 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.SOURCE_SECURITY), - 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.TARGET_SECURITY), - repeatableRequiredPostingParameter(LedgerParameterType.RATIO_NUMERATOR), - repeatableRequiredPostingParameter(LedgerParameterType.RATIO_DENOMINATOR), - repeatableOptionalPostingParameter(LedgerParameterType.SAME_SECURITY_AS_SOURCE), - repeatableOptionalPostingParameter(LedgerParameterType.FRACTION_TREATMENT), - repeatableOptionalPostingParameter(LedgerParameterType.ROUNDING_MODE), - repeatableOptionalPostingParameter(LedgerParameterType.COST_ALLOCATION_METHOD), - repeatableOptionalPostingParameter(LedgerParameterType.REFERENCE_PRICE), - repeatableOptionalPostingParameter(LedgerParameterType.FAIR_MARKET_VALUE), - repeatableOptionalPostingParameter(LedgerParameterType.VALUATION_PRICE), - repeatableOptionalPostingParameter(LedgerParameterType.FEE_REASON), - repeatableOptionalPostingParameter(LedgerParameterType.TAX_REASON)), - SETS.projectionRules( - requiredProjection(LedgerProjectionRole.DELIVERY_INBOUND, true, false), - optionalProjection(LedgerProjectionRole.CASH_COMPENSATION, true, true)), - cashCompensationPostingGroupRules(), - SETS.alternativeGroups(dateAlternative("BONUS_ISSUE_DATE")), //$NON-NLS-1$ - bonusIssueLegDefinitions(), - LedgerReportingClass.SECURITIES_DISTRIBUTION, - LedgerPerformanceTreatment.PERFORMANCE_NEUTRAL, downstreamResults()); - } - - private static LedgerEntryDefinition rightsDistribution() - { - return LedgerEntryDefinition.of(LedgerEntryType.RIGHTS_DISTRIBUTION, - LedgerNativeEntryShape.DUAL_INSTRUMENT_PLUS_ACCOUNT, - SETS.postingRules( - optionalPosting(LedgerPostingType.RIGHT, - SETS.parameterTypes(LedgerParameterType.CORPORATE_ACTION_LEG, - LedgerParameterType.SOURCE_SECURITY, - LedgerParameterType.RIGHT_SECURITY, - LedgerParameterType.RATIO_NUMERATOR, - LedgerParameterType.RATIO_DENOMINATOR), - rightsOptionalParameters()), - optionalPosting(LedgerPostingType.SECURITY, - SETS.parameterTypes(LedgerParameterType.CORPORATE_ACTION_LEG), - rightsOptionalParameters()), - optionalPosting(LedgerPostingType.CASH_COMPENSATION, SETS.parameterTypes(), - cashCompensationOptionalParameters()), - optionalPosting(LedgerPostingType.FEE, SETS.parameterTypes(), - feeOptionalParameters()), - optionalPosting(LedgerPostingType.TAX, SETS.parameterTypes(), - taxOptionalParameters())), - 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), - optionalEntryParameter(LedgerParameterType.ELECTION_DEADLINE)), - SETS.parameterRules(repeatableRequiredPostingParameter(LedgerParameterType.CORPORATE_ACTION_LEG), - repeatableRequiredPostingParameter(LedgerParameterType.SOURCE_SECURITY), - repeatableRequiredPostingParameter(LedgerParameterType.RIGHT_SECURITY), - repeatableRequiredPostingParameter(LedgerParameterType.RATIO_NUMERATOR), - repeatableRequiredPostingParameter(LedgerParameterType.RATIO_DENOMINATOR), - repeatableOptionalPostingParameter(LedgerParameterType.SUBSCRIPTION_PRICE), - repeatableOptionalPostingParameter(LedgerParameterType.REFERENCE_PRICE), - repeatableOptionalPostingParameter(LedgerParameterType.FRACTION_QUANTITY), - repeatableOptionalPostingParameter(LedgerParameterType.FRACTION_TREATMENT), - repeatableOptionalPostingParameter(LedgerParameterType.ROUNDING_MODE), - repeatableOptionalPostingParameter(LedgerParameterType.FEE_REASON), - repeatableOptionalPostingParameter(LedgerParameterType.TAX_REASON)), - SETS.projectionRules( - requiredProjection(LedgerProjectionRole.NEW_SECURITY_LEG, true, false), - optionalProjection(LedgerProjectionRole.OLD_SECURITY_LEG, true, false), - optionalProjection(LedgerProjectionRole.CASH_COMPENSATION, true, true)), - cashCompensationPostingGroupRules(), - SETS.alternativeGroups(dateAlternative("RIGHTS_DISTRIBUTION_DATE"), //$NON-NLS-1$ - LedgerRequirementGroup.postingTypes("RIGHTS_DISTRIBUTED_INSTRUMENT", //$NON-NLS-1$ - LedgerRequirement.REQUIRED, - SETS.postingTypes(LedgerPostingType.RIGHT, - LedgerPostingType.SECURITY))), - rightsDistributionLegDefinitions(), - LedgerReportingClass.RIGHTS_EVENT, LedgerPerformanceTreatment.PERFORMANCE_NEUTRAL, - downstreamResults()); - } - - private static LedgerEntryDefinition bondConversion() - { - return LedgerEntryDefinition.of(LedgerEntryType.BOND_CONVERSION, - LedgerNativeEntryShape.DUAL_INSTRUMENT_PLUS_ACCOUNT, - SETS.postingRules( - requiredPosting(LedgerPostingType.BOND, - SETS.parameterTypes(LedgerParameterType.CORPORATE_ACTION_LEG, - LedgerParameterType.SOURCE_SECURITY, - LedgerParameterType.NOMINAL_VALUE, - LedgerParameterType.QUOTATION_STYLE), - bondOptionalParameters()), - requiredPosting(LedgerPostingType.SECURITY, - SETS.parameterTypes(LedgerParameterType.CORPORATE_ACTION_LEG, - LedgerParameterType.TARGET_SECURITY), - bondOptionalParameters()), - optionalPosting(LedgerPostingType.CASH, SETS.parameterTypes(), - cashOptionalParameters()), - optionalPosting(LedgerPostingType.CASH_COMPENSATION, SETS.parameterTypes(), - cashCompensationOptionalParameters()), - optionalPosting(LedgerPostingType.ACCRUED_INTEREST, SETS.parameterTypes(), - accruedInterestOptionalParameters()), - optionalPosting(LedgerPostingType.FEE, SETS.parameterTypes(), - feeOptionalParameters()), - optionalPosting(LedgerPostingType.TAX, SETS.parameterTypes(), - taxOptionalParameters())), - SETS.parameterRules(requiredEntryParameter(LedgerParameterType.CORPORATE_ACTION_KIND), - optionalEntryParameter(LedgerParameterType.EFFECTIVE_DATE), - optionalEntryParameter(LedgerParameterType.CORPORATE_ACTION_SUBTYPE), - optionalEntryParameter(LedgerParameterType.EVENT_REFERENCE), - optionalEntryParameter(LedgerParameterType.EVENT_STAGE), - optionalEntryParameter(LedgerParameterType.SETTLEMENT_DATE)), - SETS.parameterRules(repeatableRequiredPostingParameter(LedgerParameterType.CORPORATE_ACTION_LEG), - repeatableRequiredPostingParameter(LedgerParameterType.SOURCE_SECURITY), - repeatableRequiredPostingParameter(LedgerParameterType.TARGET_SECURITY), - repeatableRequiredPostingParameter(LedgerParameterType.NOMINAL_VALUE), - repeatableRequiredPostingParameter(LedgerParameterType.QUOTATION_STYLE), - repeatableOptionalPostingParameter(LedgerParameterType.CONVERSION_RATIO), - repeatableOptionalPostingParameter(LedgerParameterType.RATIO_NUMERATOR), - repeatableOptionalPostingParameter(LedgerParameterType.RATIO_DENOMINATOR), - repeatableOptionalPostingParameter(LedgerParameterType.REFERENCE_PRICE), - repeatableOptionalPostingParameter(LedgerParameterType.FAIR_MARKET_VALUE), - repeatableOptionalPostingParameter(LedgerParameterType.VALUATION_PRICE), - repeatableOptionalPostingParameter(LedgerParameterType.ACCRUED_INTEREST_AMOUNT), - repeatableOptionalPostingParameter(LedgerParameterType.INTEREST_PERIOD_START), - repeatableOptionalPostingParameter(LedgerParameterType.INTEREST_PERIOD_END), - repeatableOptionalPostingParameter(LedgerParameterType.CASH_IN_LIEU_AMOUNT), - repeatableOptionalPostingParameter(LedgerParameterType.COST_ALLOCATION_METHOD), - repeatableOptionalPostingParameter(LedgerParameterType.SOURCE_COST_PERCENT), - repeatableOptionalPostingParameter(LedgerParameterType.TARGET_COST_PERCENT), - repeatableOptionalPostingParameter(LedgerParameterType.FEE_REASON), - repeatableOptionalPostingParameter(LedgerParameterType.TAX_REASON)), - SETS.projectionRules( - requiredProjection(LedgerProjectionRole.OLD_SECURITY_LEG, true, false), - requiredProjection(LedgerProjectionRole.NEW_SECURITY_LEG, true, false), - optionalProjection(LedgerProjectionRole.CASH_COMPENSATION, true, true)), - cashCompensationPostingGroupRules(), - SETS.alternativeGroups( - LedgerRequirementGroup.parameterTypes("BOND_CONVERSION_RATIO", //$NON-NLS-1$ - LedgerRequirement.REQUIRED, - SETS.parameterTypes(LedgerParameterType.CONVERSION_RATIO, - LedgerParameterType.RATIO_NUMERATOR, - LedgerParameterType.RATIO_DENOMINATOR)), - LedgerRequirementGroup.parameterTypes("BOND_CONVERSION_DATE", //$NON-NLS-1$ - LedgerRequirement.REQUIRED, - SETS.parameterTypes(LedgerParameterType.EFFECTIVE_DATE, - LedgerParameterType.SETTLEMENT_DATE))), - bondConversionLegDefinitions(), - LedgerReportingClass.SECURITY_REORGANIZATION, - LedgerPerformanceTreatment.INTERNAL_RECLASSIFICATION, downstreamResults()); - } - - private static LedgerPostingRule requiredPosting(LedgerPostingType postingType, - EnumSet requiredParameterTypes, - EnumSet optionalParameterTypes) - { - return LedgerPostingRule.required(postingType, requiredParameterTypes, optionalParameterTypes); - } - private static LedgerPostingRule optionalPosting(LedgerPostingType postingType, EnumSet requiredParameterTypes, EnumSet optionalParameterTypes) @@ -397,12 +157,6 @@ private static LedgerParameterRule repeatableOptionalPostingParameter(LedgerPara return LedgerParameterRule.repeatable(parameterType, LedgerRequirement.OPTIONAL); } - private static LedgerProjectionRule requiredProjection(LedgerProjectionRole role, boolean primaryPostingExpected, - boolean postingGroupExpected) - { - return LedgerProjectionRule.required(role, primaryPostingExpected, postingGroupExpected); - } - private static LedgerProjectionRule optionalProjection(LedgerProjectionRole role, boolean primaryPostingExpected, boolean postingGroupExpected) { @@ -426,7 +180,7 @@ private static Set spinOffLegDefinitions() { return SETS.legDefinitions( LedgerLegDefinition.of(LedgerLegRole.SOURCE_SECURITY_LEG, LedgerPostingType.SECURITY, - LedgerLegCardinality.EXACTLY_ONE) + LedgerLegCardinality.REPEATABLE) .requiredParameters(SETS.parameterTypes( LedgerParameterType.CORPORATE_ACTION_LEG, LedgerParameterType.SOURCE_SECURITY, @@ -435,7 +189,7 @@ private static Set spinOffLegDefinitions() .optionalParameters(spinOffSourceSecurityLegOptionalParameters()) .projection(LedgerProjectionRole.OLD_SECURITY_LEG, true, false).build(), LedgerLegDefinition.of(LedgerLegRole.TARGET_SECURITY_LEG, LedgerPostingType.SECURITY, - LedgerLegCardinality.AT_LEAST_ONE) + LedgerLegCardinality.REPEATABLE) .requiredParameters(SETS.parameterTypes( LedgerParameterType.CORPORATE_ACTION_LEG, LedgerParameterType.TARGET_SECURITY, @@ -475,121 +229,6 @@ private static EnumSet spinOffTargetSecurityLegOptionalPara return parameters; } - private static Set stockDividendLegDefinitions() - { - return SETS.legDefinitions( - LedgerLegDefinition.of(LedgerLegRole.RECEIVED_SECURITY_LEG, LedgerPostingType.SECURITY, - LedgerLegCardinality.EXACTLY_ONE) - .requiredParameters(SETS.parameterTypes( - LedgerParameterType.CORPORATE_ACTION_LEG, - LedgerParameterType.TARGET_SECURITY, - LedgerParameterType.RATIO_NUMERATOR, - LedgerParameterType.RATIO_DENOMINATOR)) - .optionalParameters(stockDividendSecurityOptionalParameters()) - .projection(LedgerProjectionRole.DELIVERY_INBOUND, true, false).build(), - cashCompensationLeg(), - feeLeg(), - taxLeg()); - } - - private static Set bonusIssueLegDefinitions() - { - return SETS.legDefinitions( - LedgerLegDefinition.of(LedgerLegRole.RECEIVED_SECURITY_LEG, LedgerPostingType.SECURITY, - LedgerLegCardinality.EXACTLY_ONE) - .requiredParameters(SETS.parameterTypes( - LedgerParameterType.CORPORATE_ACTION_LEG, - LedgerParameterType.TARGET_SECURITY, - LedgerParameterType.RATIO_NUMERATOR, - LedgerParameterType.RATIO_DENOMINATOR)) - .optionalParameters(bonusIssueSecurityOptionalParameters()) - .projection(LedgerProjectionRole.DELIVERY_INBOUND, true, false).build(), - cashCompensationLeg(), - feeLeg(), - taxLeg()); - } - - private static Set rightsDistributionLegDefinitions() - { - return SETS.legDefinitions( - LedgerLegDefinition.of(LedgerLegRole.DISTRIBUTED_RIGHT_LEG, LedgerPostingType.RIGHT, - LedgerLegCardinality.OPTIONAL) - .requiredParameters(SETS.parameterTypes( - LedgerParameterType.CORPORATE_ACTION_LEG, - LedgerParameterType.SOURCE_SECURITY, - LedgerParameterType.RIGHT_SECURITY, - LedgerParameterType.RATIO_NUMERATOR, - LedgerParameterType.RATIO_DENOMINATOR)) - .optionalParameters(rightsOptionalParameters()) - .projection(LedgerProjectionRole.NEW_SECURITY_LEG, true, false).build(), - LedgerLegDefinition.of(LedgerLegRole.DISTRIBUTED_SECURITY_LEG, LedgerPostingType.SECURITY, - LedgerLegCardinality.OPTIONAL) - .requiredParameters(SETS.parameterTypes( - LedgerParameterType.CORPORATE_ACTION_LEG)) - .optionalParameters(rightsOptionalParameters()) - .projection(LedgerProjectionRole.NEW_SECURITY_LEG, true, false).build(), - LedgerLegDefinition.of(LedgerLegRole.SOURCE_SECURITY_LEG, LedgerPostingType.SECURITY, - LedgerLegCardinality.OPTIONAL) - .optionalParameters(rightsOptionalParameters()) - .projection(LedgerProjectionRole.OLD_SECURITY_LEG, true, false).build(), - cashCompensationLeg(), - feeLeg(), - taxLeg()); - } - - private static Set bondConversionLegDefinitions() - { - return SETS.legDefinitions( - LedgerLegDefinition.of(LedgerLegRole.SOURCE_BOND_LEG, LedgerPostingType.BOND, - LedgerLegCardinality.EXACTLY_ONE) - .requiredParameters(SETS.parameterTypes( - LedgerParameterType.CORPORATE_ACTION_LEG, - LedgerParameterType.SOURCE_SECURITY, - LedgerParameterType.NOMINAL_VALUE, - LedgerParameterType.QUOTATION_STYLE)) - .optionalParameters(bondOptionalParameters()) - .projection(LedgerProjectionRole.OLD_SECURITY_LEG, true, false).build(), - LedgerLegDefinition.of(LedgerLegRole.TARGET_SECURITY_LEG, LedgerPostingType.SECURITY, - LedgerLegCardinality.EXACTLY_ONE) - .requiredParameters(SETS.parameterTypes( - LedgerParameterType.CORPORATE_ACTION_LEG, - LedgerParameterType.TARGET_SECURITY)) - .optionalParameters(bondOptionalParameters()) - .projection(LedgerProjectionRole.NEW_SECURITY_LEG, true, false).build(), - LedgerLegDefinition.of(LedgerLegRole.CASH_LEG, LedgerPostingType.CASH, - LedgerLegCardinality.OPTIONAL) - .optionalParameters(cashOptionalParameters()).build(), - cashCompensationLeg(), - LedgerLegDefinition.of(LedgerLegRole.ACCRUED_INTEREST_LEG, - LedgerPostingType.ACCRUED_INTEREST, LedgerLegCardinality.OPTIONAL) - .optionalParameters(accruedInterestOptionalParameters()).build(), - feeLeg(), - taxLeg()); - } - - private static LedgerLegDefinition cashCompensationLeg() - { - return LedgerLegDefinition.of(LedgerLegRole.CASH_COMPENSATION_LEG, - LedgerPostingType.CASH_COMPENSATION, LedgerLegCardinality.OPTIONAL) - .optionalParameters(cashCompensationOptionalParameters()) - .projection(LedgerProjectionRole.CASH_COMPENSATION, true, true) - .group(CASH_COMPENSATION_GROUP).build(); - } - - private static LedgerLegDefinition feeLeg() - { - return LedgerLegDefinition.of(LedgerLegRole.FEE_LEG, LedgerPostingType.FEE, LedgerLegCardinality.REPEATABLE) - .optionalParameters(feeOptionalParameters()) - .group(CASH_COMPENSATION_GROUP).build(); - } - - private static LedgerLegDefinition taxLeg() - { - return LedgerLegDefinition.of(LedgerLegRole.TAX_LEG, LedgerPostingType.TAX, LedgerLegCardinality.REPEATABLE) - .optionalParameters(taxOptionalParameters()) - .group(CASH_COMPENSATION_GROUP).build(); - } - private static EnumSet spinOffSecurityOptionalParameters() { return SETS.parameterTypes(LedgerParameterType.FRACTION_QUANTITY, @@ -600,51 +239,6 @@ private static EnumSet spinOffSecurityOptionalParameters() LedgerParameterType.MANUAL_VALUATION_OVERRIDE); } - private static EnumSet stockDividendSecurityOptionalParameters() - { - return SETS.parameterTypes(LedgerParameterType.SOURCE_SECURITY, LedgerParameterType.FRACTION_QUANTITY, - LedgerParameterType.FRACTION_TREATMENT, LedgerParameterType.CASH_IN_LIEU_AMOUNT, - LedgerParameterType.CASH_IN_LIEU_APPLIED, LedgerParameterType.COST_ALLOCATION_METHOD, - LedgerParameterType.REFERENCE_PRICE, LedgerParameterType.FAIR_MARKET_VALUE, - LedgerParameterType.VALUATION_PRICE, LedgerParameterType.TAXABLE_DISTRIBUTION); - } - - private static EnumSet bonusIssueSecurityOptionalParameters() - { - return SETS.parameterTypes(LedgerParameterType.SOURCE_SECURITY, - LedgerParameterType.SAME_SECURITY_AS_SOURCE, LedgerParameterType.FRACTION_TREATMENT, - LedgerParameterType.ROUNDING_MODE, LedgerParameterType.COST_ALLOCATION_METHOD, - LedgerParameterType.REFERENCE_PRICE, LedgerParameterType.FAIR_MARKET_VALUE, - LedgerParameterType.VALUATION_PRICE); - } - - private static EnumSet rightsOptionalParameters() - { - return SETS.parameterTypes(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); - } - - private static EnumSet bondOptionalParameters() - { - return SETS.parameterTypes(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); - } - - private static EnumSet cashOptionalParameters() - { - return SETS.parameterTypes(LedgerParameterType.SOURCE_ACCOUNT, LedgerParameterType.TARGET_ACCOUNT, - LedgerParameterType.CASH_ACCOUNT, LedgerParameterType.EVENT_REFERENCE, - LedgerParameterType.PAYMENT_DATE, LedgerParameterType.SETTLEMENT_DATE); - } - private static EnumSet cashCompensationOptionalParameters() { return SETS.parameterTypes(LedgerParameterType.CASH_ACCOUNT, @@ -679,16 +273,6 @@ private static EnumSet forexOptionalParameters() LedgerParameterType.VALUATION_PRICE, LedgerParameterType.EVENT_REFERENCE); } - private static EnumSet accruedInterestOptionalParameters() - { - return SETS.parameterTypes(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 Set cashCompensationPostingGroupRules() { return SETS.postingGroupRules(LedgerPostingGroupRule.of(CASH_COMPENSATION_GROUP, 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 index 6c02a693f1..f69b4a7826 100644 --- 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 @@ -27,18 +27,12 @@ public enum LedgerEntryType SECURITY_TRANSFER("SECURITY_TRANSFER", Shape.LEGACY_FIXED), DELIVERY_INBOUND("DELIVERY_INBOUND", Shape.LEGACY_FIXED), DELIVERY_OUTBOUND("DELIVERY_OUTBOUND", Shape.LEGACY_FIXED), - SPIN_OFF("SPIN_OFF", Shape.LEDGER_NATIVE_TARGETED), - STOCK_DIVIDEND("STOCK_DIVIDEND", Shape.LEDGER_NATIVE_TARGETED), - BONUS_ISSUE("BONUS_ISSUE", Shape.LEDGER_NATIVE_TARGETED), - RIGHTS_DISTRIBUTION("RIGHTS_DISTRIBUTION", Shape.LEDGER_NATIVE_TARGETED), - BOND_CONVERSION("BOND_CONVERSION", Shape.LEDGER_NATIVE_TARGETED), - CORPORATE_ACTION_MOVEMENT_CONFIRMATION("CORPORATE_ACTION_MOVEMENT_CONFIRMATION", Shape.LEDGER_NATIVE_MODEL_ONLY); + SPIN_OFF("SPIN_OFF", Shape.LEDGER_NATIVE_TARGETED); private enum Shape { LEGACY_FIXED, - LEDGER_NATIVE_TARGETED, - LEDGER_NATIVE_MODEL_ONLY + LEDGER_NATIVE_TARGETED } private final String code; diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java index 0be325fa08..3d930a7809 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java @@ -531,30 +531,6 @@ private static Optional expectedCorporateActionLegCode(LedgerEntryType e return Optional.of(CorporateActionLeg.TARGET_SECURITY.getCode()); } - if (entryType == LedgerEntryType.STOCK_DIVIDEND || entryType == LedgerEntryType.BONUS_ISSUE) - { - if (role == LedgerLegRole.RECEIVED_SECURITY_LEG) - return Optional.of(CorporateActionLeg.TARGET_SECURITY.getCode()); - } - - if (entryType == LedgerEntryType.RIGHTS_DISTRIBUTION) - { - if (role == LedgerLegRole.DISTRIBUTED_RIGHT_LEG) - return Optional.of(CorporateActionLeg.RIGHT_SECURITY.getCode()); - if (role == LedgerLegRole.DISTRIBUTED_SECURITY_LEG) - return Optional.of(CorporateActionLeg.DISTRIBUTED_SECURITY.getCode()); - if (role == LedgerLegRole.SOURCE_SECURITY_LEG) - return Optional.of(CorporateActionLeg.SOURCE_SECURITY.getCode()); - } - - if (entryType == LedgerEntryType.BOND_CONVERSION) - { - if (role == LedgerLegRole.SOURCE_BOND_LEG) - return Optional.of(CorporateActionLeg.CONVERSION_SOURCE.getCode()); - if (role == LedgerLegRole.TARGET_SECURITY_LEG) - return Optional.of(CorporateActionLeg.CONVERSION_TARGET.getCode()); - } - if (role == LedgerLegRole.CASH_COMPENSATION_LEG) return Optional.of(CorporateActionLeg.CASH_COMPENSATION.getCode()); if (role == LedgerLegRole.FEE_LEG) diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java index d9b100520c..9dc6f155bb 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java @@ -84,12 +84,13 @@ public Result derive(LedgerEntry entry) .ifPresent(descriptors::add); } case SPIN_OFF -> { - portfolio(entry, LedgerProjectionRole.OLD_SECURITY_LEG, + repeatedPortfolio(entry, LedgerProjectionRole.OLD_SECURITY_LEG, primary().and(corporateLeg(CorporateActionLeg.SOURCE_SECURITY)) + .and(localKey(LedgerProjectionRole.OLD_SECURITY_LEG)) .or(legacyCorporateLeg(LedgerPostingType.SECURITY, CorporateActionLeg.SOURCE_SECURITY)), - diagnostics) - .ifPresent(descriptors::add); + primary().and(corporateLeg(CorporateActionLeg.SOURCE_SECURITY)), true, diagnostics) + .forEach(descriptors::add); optionalPortfolio(entry, LedgerProjectionRole.DELIVERY_INBOUND, primary().and(corporateLeg(CorporateActionLeg.TARGET_SECURITY)) .and(localKey(LedgerProjectionRole.DELIVERY_INBOUND)) @@ -101,7 +102,7 @@ public Result derive(LedgerEntry entry) .or(legacyNewSpinOffTarget()), primary().and(corporateLeg(CorporateActionLeg.TARGET_SECURITY)) .and(localKey(LedgerProjectionRole.DELIVERY_INBOUND).negate()), - false, diagnostics).forEach(descriptors::add); + true, diagnostics).forEach(descriptors::add); repeatedAccount(entry, LedgerProjectionRole.CASH_COMPENSATION, primary().and(corporateLeg(CorporateActionLeg.CASH_COMPENSATION)) .and(localKey(LedgerProjectionRole.CASH_COMPENSATION)) @@ -110,58 +111,6 @@ public Result derive(LedgerEntry entry) primary().and(corporateLeg(CorporateActionLeg.CASH_COMPENSATION)), true, diagnostics) .forEach(descriptors::add); } - case STOCK_DIVIDEND, BONUS_ISSUE -> { - portfolio(entry, LedgerProjectionRole.DELIVERY_INBOUND, - primary().and(corporateLeg(CorporateActionLeg.TARGET_SECURITY)) - .or(legacyCorporateLeg(LedgerPostingType.SECURITY, - CorporateActionLeg.TARGET_SECURITY)), - diagnostics) - .ifPresent(descriptors::add); - optionalAccount(entry, LedgerProjectionRole.CASH_COMPENSATION, - primary().and(corporateLeg(CorporateActionLeg.CASH_COMPENSATION)) - .or(legacyCorporateLeg(LedgerPostingType.CASH_COMPENSATION, - CorporateActionLeg.CASH_COMPENSATION)), - diagnostics) - .ifPresent(descriptors::add); - } - case RIGHTS_DISTRIBUTION -> { - portfolio(entry, LedgerProjectionRole.NEW_SECURITY_LEG, - primary().and(posting -> posting.getCorporateActionLeg() == CorporateActionLeg.RIGHT_SECURITY - || posting.getCorporateActionLeg() == CorporateActionLeg.DISTRIBUTED_SECURITY), - diagnostics).ifPresent(descriptors::add); - optionalPortfolio(entry, LedgerProjectionRole.OLD_SECURITY_LEG, - primary().and(corporateLeg(CorporateActionLeg.SOURCE_SECURITY)) - .or(legacyCorporateLeg(LedgerPostingType.SECURITY, - CorporateActionLeg.SOURCE_SECURITY)), - diagnostics) - .ifPresent(descriptors::add); - optionalAccount(entry, LedgerProjectionRole.CASH_COMPENSATION, - primary().and(corporateLeg(CorporateActionLeg.CASH_COMPENSATION)) - .or(legacyCorporateLeg(LedgerPostingType.CASH_COMPENSATION, - CorporateActionLeg.CASH_COMPENSATION)), - diagnostics) - .ifPresent(descriptors::add); - } - case BOND_CONVERSION -> { - portfolio(entry, LedgerProjectionRole.OLD_SECURITY_LEG, - primary().and(corporateLeg(CorporateActionLeg.CONVERSION_SOURCE)) - .or(legacyCorporateLeg(LedgerPostingType.BOND, - CorporateActionLeg.CONVERSION_SOURCE)), - diagnostics) - .ifPresent(descriptors::add); - portfolio(entry, LedgerProjectionRole.NEW_SECURITY_LEG, - primary().and(corporateLeg(CorporateActionLeg.CONVERSION_TARGET)) - .or(legacyCorporateLeg(LedgerPostingType.BOND, - CorporateActionLeg.CONVERSION_TARGET)), - diagnostics) - .ifPresent(descriptors::add); - optionalAccount(entry, LedgerProjectionRole.CASH_COMPENSATION, - primary().and(corporateLeg(CorporateActionLeg.CASH_COMPENSATION)) - .or(legacyCorporateLeg(LedgerPostingType.CASH_COMPENSATION, - CorporateActionLeg.CASH_COMPENSATION)), - diagnostics) - .ifPresent(descriptors::add); - } default -> diagnostics.add(Diagnostic.missing(entry, null, "No derived projection rule for entry type " + entry.getType())); //$NON-NLS-1$ } @@ -175,12 +124,6 @@ private java.util.Optional account(LedgerEntry entr return descriptor(entry, role, DerivedProjectionViewKind.ACCOUNT, selector, false, diagnostics); } - private java.util.Optional optionalAccount(LedgerEntry entry, LedgerProjectionRole role, - Predicate selector, List diagnostics) - { - return descriptor(entry, role, DerivedProjectionViewKind.ACCOUNT, selector, true, diagnostics); - } - private java.util.Optional portfolio(LedgerEntry entry, LedgerProjectionRole role, Predicate selector, List diagnostics) { From f4b5819c4116dbe75082a88c575488b83e6414dd Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:36:58 +0200 Subject: [PATCH 47/68] Generalize Ledger corporate action entry type Replace the concrete SPIN_OFF ledger entry type with a generic CORPORATE_ACTION entry type and keep the concrete business shape in CorporateActionKind.SPIN_OFF. This lets native Corporate Action services share a stable entry family while preserving the existing spin-off definitions, validators, descriptors, assembler path, materialization, and persistence behavior. XML/protobuf schemas, legacy PTransaction mappings, UUID-free Ledger truth, projection memberships, UI, reports, importers, and tax/performance behavior are intentionally unchanged. --- .../model/LedgerProtobufPersistenceTest.java | 35 +++++++++- .../model/ledger/LedgerCodeTest.java | 35 ++-------- ...ateActionMultiMovementPersistenceTest.java | 6 +- .../ledger/LedgerEntryDefinitionTest.java | 6 +- .../ledger/LedgerInlineEditingPolicyTest.java | 4 +- .../model/ledger/LedgerModelTest.java | 9 ++- ...gerNativeEntryDefinitionValidatorTest.java | 8 +-- .../ledger/LedgerParameterCodeDomainTest.java | 3 +- .../LedgerPostingTypeDefinitionTest.java | 2 +- .../ledger/LedgerSpinOffScenarioTest.java | 16 +++-- ...LedgerSpinOffXmlDefinitionCheckerTest.java | 2 +- .../ledger/LedgerStructuralValidatorTest.java | 24 ++++--- .../ledger/LedgerXmlPersistenceTest.java | 44 ++++++++++++ ...dgerNativeComponentInspectorModelTest.java | 5 +- ...iveEntryDefinitionValidatorPolicyTest.java | 2 +- .../LedgerNativeEntryAssemblerTest.java | 25 +++---- ...erivedProjectionDescriptorServiceTest.java | 9 ++- .../LedgerRuntimeProjectionRestorerTest.java | 7 +- .../ClientPerformanceSnapshotTest.java | 6 +- .../model/LedgerModelLoadSupport.java | 27 +++++++ .../LedgerProtobufPersistenceSupport.java | 6 +- .../model/LedgerXmlPersistenceSupport.java | 18 ++++- .../ledger/LedgerStructuralValidator.java | 31 +++++--- .../configuration/CorporateActionKind.java | 40 ++++++++--- .../LedgerEntryDefinitionRegistry.java | 26 ++++++- .../ledger/configuration/LedgerEntryType.java | 2 +- .../LedgerNativeEntryDefinitionValidator.java | 4 +- .../LedgerNativeEntryAssembler.java | 2 +- .../DerivedProjectionDescriptorService.java | 70 +++++++++++-------- 29 files changed, 321 insertions(+), 153 deletions(-) diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceTest.java index 843a0e9659..bddfe26cdc 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceTest.java @@ -47,6 +47,7 @@ import name.abuchen.portfolio.model.ledger.compatibility.LedgerSecurityQuantity; import name.abuchen.portfolio.model.ledger.compatibility.LedgerTransactionCreator; 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; @@ -603,6 +604,32 @@ public void testUnknownLedgerEntryTypeCodeFailsClearly() throws IOException assertUnknownTypeCodeFailure(proto.build(), "LedgerEntryType", "UNKNOWN_ENTRY_TYPE"); } + /** + * Verifies that legacy native spin-off protobuf type codes load into the generic + * Corporate Action entry family with the concrete kind parameter. + */ + @Test + public void testLegacySpinOffProtobufTypeCodeLoadsAsCorporateActionKind() throws IOException + { + var fixture = fixture(); + addNativeEntry(fixture, LedgerEntryType.CORPORATE_ACTION); + var proto = saveProto(fixture.client()).toBuilder(); + + proto.getLedgerBuilder().getEntriesBuilder(0).setTypeCode("SPIN_OFF").clearParameters(); + + var loaded = load(wrap(proto.build())); + var entry = loaded.getLedger().getEntries().get(0); + + assertThat(entry.getType(), is(LedgerEntryType.CORPORATE_ACTION)); + assertThat(entry.getParameters().stream().filter( + parameter -> parameter.getType() == LedgerParameterType.CORPORATE_ACTION_KIND) + .map(LedgerParameter::getValue).findFirst().orElseThrow(), + is(CorporateActionKind.SPIN_OFF.getCode())); + assertThat(saveProto(loaded).getLedger().getEntries(0).getTypeCode(), + is(LedgerEntryType.CORPORATE_ACTION.getCode())); + assertValid(loaded); + } + /** * Verifies that an unknown ledger posting type code fails with a clear protobuf load error. * Unsupported posting vocabulary must not be guessed during load. @@ -699,7 +726,7 @@ public void testLegacyTransactionTypeEnumDoesNotContainCorporateActions() @Test public void testNativeCorporateActionsRoundtripAsSemanticLedgerTruth() throws IOException { - for (var entryType : List.of(LedgerEntryType.SPIN_OFF)) + for (var entryType : List.of(LedgerEntryType.CORPORATE_ACTION)) { var fixture = fixture(); addNativeEntry(fixture, entryType); @@ -1082,10 +1109,12 @@ private void addNativeEntry(ClientFixture fixture, LedgerEntryType entryType) entry.setDateTime(DATE_TIME); entry.setNote("Native corporate action"); entry.setSource("protobuf-test"); + entry.addParameter(LedgerParameter.ofCode(LedgerParameterType.CORPORATE_ACTION_KIND, + CorporateActionKind.SPIN_OFF)); switch (entryType) { - case SPIN_OFF: + case CORPORATE_ACTION: entry.addPosting(nativeSecurityPosting(fixture, LedgerPostingType.SECURITY, CorporateActionLeg.SOURCE_SECURITY, fixture.security(), LedgerPostingDirection.OUTBOUND, LedgerProjectionRole.OLD_SECURITY_LEG)); @@ -1104,7 +1133,7 @@ private List nativeCorporateActionShadowTypes(LedgerEntryType { return switch (entryType) { - case SPIN_OFF -> List.of(PTransaction.Type.OUTBOUND_DELIVERY, PTransaction.Type.INBOUND_DELIVERY); + case CORPORATE_ACTION -> List.of(PTransaction.Type.OUTBOUND_DELIVERY, PTransaction.Type.INBOUND_DELIVERY); default -> throw new IllegalArgumentException(entryType.name()); }; } 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 index 980135a4b8..c7a66f03c9 100644 --- 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 @@ -88,37 +88,16 @@ public void testDomainAllowedCodesMatchDomainEnums() } /** - * Checks the ledger rule scenario: corporate action kind codes map to ledger native entry types. - * Invalid entry shapes must be rejected before they can be stored. - * This keeps higher-level Ledger-V6 transaction flows predictable. + * Checks the ledger rule scenario: corporate action kinds classify the generic + * native corporate action entry family. */ @Test - public void testCorporateActionKindCodesMapToLedgerNativeEntryTypes() + public void testCorporateActionKindsClassifyGenericNativeEntryType() { - var relatedTypes = EnumSet.noneOf(LedgerEntryType.class); - - for (var kind : CorporateActionKind.values()) - { - if (kind == CorporateActionKind.OTHER) - { - assertTrue(kind.getRelatedEntryType().isEmpty()); - continue; - } - - var entryType = kind.getRelatedEntryType().orElseThrow(); - - assertTrue(entryType.isLedgerNativeTargeted()); - assertThat(kind.getCode(), is(entryType.name())); - assertTrue(entryType.name(), relatedTypes.add(entryType)); - } - - var nativeTypes = EnumSet.noneOf(LedgerEntryType.class); - - for (var entryType : LedgerEntryType.values()) - if (entryType.isLedgerNativeTargeted()) - nativeTypes.add(entryType); - - assertThat(relatedTypes, is(nativeTypes)); + assertTrue(LedgerEntryType.CORPORATE_ACTION.isLedgerNativeTargeted()); + assertThat(List.of(CorporateActionKind.values()), is(List.of(CorporateActionKind.SPIN_OFF))); + assertThat(CorporateActionKind.fromCode("SPIN_OFF").orElseThrow(), is(CorporateActionKind.SPIN_OFF)); + assertTrue(CorporateActionKind.fromCode("OTHER").isEmpty()); } private List allCodes() diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java index be8abee39f..7d54901318 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java @@ -83,7 +83,7 @@ public void testProtobufRoundtripPreservesRepeatedSpinOffMovementLegs() throws E var protoEntry = proto.getLedger().getEntries(0); assertThat(proto.getLedger().getEntriesCount(), is(1)); - assertThat(protoEntry.getTypeCode(), is(LedgerEntryType.SPIN_OFF.getCode())); + assertThat(protoEntry.getTypeCode(), is(LedgerEntryType.CORPORATE_ACTION.getCode())); assertThat(protoEntry.getPostingsCount(), is(8)); assertNoCorporateActionSpecificLegacyTransactionType(proto); @@ -124,7 +124,7 @@ private Fixture spinOffFixture() client.addSecurity(targetSecurityB); var entry = new LedgerEntry("spin-off-repeated"); - entry.setType(LedgerEntryType.SPIN_OFF); + entry.setType(LedgerEntryType.CORPORATE_ACTION); entry.setDateTime(DATE_TIME); entry.setSource("SPIN_OFF repeated movement proof"); entry.setNote("core service proof only"); @@ -244,7 +244,7 @@ private LedgerPosting unitPosting(LedgerPostingType type, LedgerPostingSemanticR private void assertRepeatedSpinOff(LedgerEntry entry) { - assertThat(entry.getType(), is(LedgerEntryType.SPIN_OFF)); + assertThat(entry.getType(), is(LedgerEntryType.CORPORATE_ACTION)); assertThat(entry.getPostings().size(), is(8)); var sourceSecurities = postings(entry, LedgerPostingType.SECURITY, LedgerPostingDirection.OUTBOUND, 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 index 2986761765..2d08e0f64b 100644 --- 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 @@ -214,7 +214,7 @@ public void testAlternativeGroupsDoNotDuplicateHardRequiredMembers() @Test public void testSpinOffDefinitionDescribesNativeDataModel() { - var definition = LedgerEntryDefinitionRegistry.lookup(LedgerEntryType.SPIN_OFF).orElseThrow(); + var definition = LedgerEntryDefinitionRegistry.lookup(LedgerEntryType.CORPORATE_ACTION).orElseThrow(); assertThat(definition.getNativeShape(), is(LedgerNativeEntryShape.DUAL_INSTRUMENT_PLUS_ACCOUNT)); assertTrue(definition.getPostingTypes().contains(LedgerPostingType.SECURITY)); @@ -260,7 +260,7 @@ public void testSpinOffDefinitionDescribesNativeDataModel() @Test public void testSpinOffDefinitionDescribesFunctionalLegs() { - var definition = LedgerEntryDefinitionRegistry.lookup(LedgerEntryType.SPIN_OFF).orElseThrow(); + var definition = LedgerEntryDefinitionRegistry.lookup(LedgerEntryType.CORPORATE_ACTION).orElseThrow(); var sourceLeg = assertLeg(definition, LedgerLegRole.SOURCE_SECURITY_LEG, LedgerPostingType.SECURITY, LedgerLegCardinality.REPEATABLE); @@ -422,7 +422,7 @@ public void testDefinitionLayerAllowsPartialNativeCompleteness() var entry = new LedgerEntry("entry-1"); var posting = new LedgerPosting("posting-1"); - entry.setType(LedgerEntryType.SPIN_OFF); + entry.setType(LedgerEntryType.CORPORATE_ACTION); entry.setDateTime(LocalDateTime.of(2026, 1, 2, 0, 0)); posting.setType(LedgerPostingType.CASH); posting.setCurrency(CurrencyUnit.EUR); diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerInlineEditingPolicyTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerInlineEditingPolicyTest.java index 382b238636..605e01a1da 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerInlineEditingPolicyTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerInlineEditingPolicyTest.java @@ -31,9 +31,9 @@ public void testSuppliedInlineEditingMatrixRows() LedgerInlineEditingField.SHARES), is(false)); assertThat(LedgerInlineEditingPolicy.isEditable(LedgerEntryType.DELIVERY_INBOUND, LedgerProjectionRole.DELIVERY_INBOUND, LedgerInlineEditingField.TYPE), is(true)); - assertThat(LedgerInlineEditingPolicy.isEditable(LedgerEntryType.SPIN_OFF, + assertThat(LedgerInlineEditingPolicy.isEditable(LedgerEntryType.CORPORATE_ACTION, LedgerProjectionRole.DELIVERY_INBOUND, LedgerInlineEditingField.DATE), is(false)); - assertThat(LedgerInlineEditingPolicy.isEditable(LedgerEntryType.SPIN_OFF, + assertThat(LedgerInlineEditingPolicy.isEditable(LedgerEntryType.CORPORATE_ACTION, LedgerProjectionRole.CASH_COMPENSATION, LedgerInlineEditingField.SOURCE), is(false)); } } 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 index 2c31c3a9bb..d6c48a2b98 100644 --- 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 @@ -410,8 +410,7 @@ public void testLedgerParameterCodeDomainsAreExplicit() CorporateActionLeg.REDEMPTION, CorporateActionLeg.CONVERSION_SOURCE, CorporateActionLeg.CONVERSION_TARGET, CorporateActionLeg.OTHER); assertCodeDomain(LedgerParameterType.CORPORATE_ACTION_KIND, - LedgerParameterCodeDomain.CORPORATE_ACTION_KIND, CorporateActionKind.SPIN_OFF, - CorporateActionKind.OTHER); + LedgerParameterCodeDomain.CORPORATE_ACTION_KIND, CorporateActionKind.SPIN_OFF); assertCodeDomain(LedgerParameterType.CORPORATE_ACTION_SUBTYPE, LedgerParameterCodeDomain.CORPORATE_ACTION_SUBTYPE, CorporateActionSubtype.STANDARD, CorporateActionSubtype.OPTIONAL, CorporateActionSubtype.MANDATORY, @@ -523,14 +522,14 @@ public void testLedgerParameterFactoriesValidateParameterType() @Test public void testLedgerEntryTypePoliciesSeparateStandardAndLedgerNativeShapes() { - var corporateActionFamilies = EnumSet.of(LedgerEntryType.SPIN_OFF); + var corporateActionFamilies = EnumSet.of(LedgerEntryType.CORPORATE_ACTION); var standardFamilies = EnumSet.complementOf(corporateActionFamilies); standardFamilies.forEach(this::assertStandardLegacyShape); corporateActionFamilies.forEach(this::assertLedgerNativeTargetedShape); - assertTrue(LedgerEntryType.SPIN_OFF.requiresTargetedDerivedDescriptors()); - assertTrue(LedgerEntryType.SPIN_OFF.usesSignedTargetedProjectionFacts()); + assertTrue(LedgerEntryType.CORPORATE_ACTION.requiresTargetedDerivedDescriptors()); + assertTrue(LedgerEntryType.CORPORATE_ACTION.usesSignedTargetedProjectionFacts()); } /** diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java index 5556ce970d..4ca9c11ee3 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java @@ -66,16 +66,16 @@ public void testValidSpinOffIsNativeDefinitionValid() } /** - * Checks that a native entry without its required corporate-action kind is - * rejected before it can be used by supported create paths. + * Checks that a native corporate-action entry without a kind is rejected + * before it can select a kind-specific native definition. */ @Test - public void testMissingRequiredEntryParameterIsRejected() + public void testCorporateActionWithoutKindIsRejected() { var entry = copyValidSpinOff(); removeEntryParameters(entry, LedgerParameterType.CORPORATE_ACTION_KIND); - assertIssue(entry, IssueCode.REQUIRED_ENTRY_PARAMETER_MISSING); + assertIssue(entry, IssueCode.ENTRY_DEFINITION_MISSING); } /** 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 index 05b8aaf4d5..2b79f197a4 100644 --- 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 @@ -27,8 +27,7 @@ public class LedgerParameterCodeDomainTest "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("SPIN_OFF", "OTHER")), + Map.entry(LedgerParameterCodeDomain.CORPORATE_ACTION_KIND, List.of("SPIN_OFF")), Map.entry(LedgerParameterCodeDomain.CORPORATE_ACTION_SUBTYPE, List.of("STANDARD", "OPTIONAL", "MANDATORY", "CASH_AND_STOCK", "OTHER")), Map.entry(LedgerParameterCodeDomain.EVENT_STAGE, 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 index d0e9feec9c..857393f070 100644 --- 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 @@ -155,7 +155,7 @@ public void testDefinitionLayerDoesNotEnforcePostingFactCompleteness() var entry = new LedgerEntry("entry-1"); var posting = new LedgerPosting("posting-1"); - entry.setType(LedgerEntryType.SPIN_OFF); + entry.setType(LedgerEntryType.CORPORATE_ACTION); entry.setDateTime(LocalDateTime.of(2026, 1, 2, 0, 0)); posting.setType(LedgerPostingType.CASH); posting.setCurrency(CurrencyUnit.EUR); diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffScenarioTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffScenarioTest.java index 3111f987da..3555473b9e 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffScenarioTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffScenarioTest.java @@ -84,10 +84,10 @@ public class LedgerSpinOffScenarioTest @Test public void testSpinOffUsesLedgerNativeTargetedPolicy() { - assertFalse(LedgerEntryType.SPIN_OFF.isLegacyFixedShape()); - assertTrue(LedgerEntryType.SPIN_OFF.isLedgerNativeTargeted()); - assertTrue(LedgerEntryType.SPIN_OFF.requiresTargetedDerivedDescriptors()); - assertTrue(LedgerEntryType.SPIN_OFF.usesSignedTargetedProjectionFacts()); + assertFalse(LedgerEntryType.CORPORATE_ACTION.isLegacyFixedShape()); + assertTrue(LedgerEntryType.CORPORATE_ACTION.isLedgerNativeTargeted()); + assertTrue(LedgerEntryType.CORPORATE_ACTION.requiresTargetedDerivedDescriptors()); + assertTrue(LedgerEntryType.CORPORATE_ACTION.usesSignedTargetedProjectionFacts()); } /** @@ -239,8 +239,10 @@ public void testShareAdjustmentHelperRejectsTargetedProjectionWithoutPrimaryPost client.addPortfolio(portfolio); client.addSecurity(security); - entry.setType(LedgerEntryType.SPIN_OFF); + entry.setType(LedgerEntryType.CORPORATE_ACTION); entry.setDateTime(SPIN_OFF_DATE); + entry.addParameter(LedgerParameter.ofCode(LedgerParameterType.CORPORATE_ACTION_KIND, + CorporateActionKind.SPIN_OFF)); var posting = invalidTargetSecurityPosting(portfolio, security, Values.Share.factorize(10), Values.Amount.factorize(100), CorporateActionLeg.TARGET_SECURITY.getCode(), security, @@ -416,7 +418,7 @@ private SpinOffFixture fixture() private LedgerEntry spinOffEntry(Client client) { - return client.getLedger().getEntries().stream().filter(entry -> entry.getType() == LedgerEntryType.SPIN_OFF) + return client.getLedger().getEntries().stream().filter(entry -> entry.getType() == LedgerEntryType.CORPORATE_ACTION) .filter(entry -> SPIN_OFF_DATE.equals(entry.getDateTime())).findFirst().orElseThrow(); } @@ -548,7 +550,7 @@ private void assertSpinOffScenarioClient(Client client) assertThat(client.getSecurities().stream().filter(security -> "Siemens Energy AG".equals(security.getName())) .count(), is(1L)); assertThat(client.getPortfolios().get(0).getReferenceAccount(), is(client.getAccounts().get(0))); - assertThat(entry.getType(), is(LedgerEntryType.SPIN_OFF)); + assertThat(entry.getType(), is(LedgerEntryType.CORPORATE_ACTION)); assertThat(entry.getUpdatedAt(), is(UPDATED_AT)); assertThat(entry.getPostings().size(), is(6)); var oldSecurityLeg = securityPosting(entry, siemens(client), CorporateActionLeg.SOURCE_SECURITY.getCode(), diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffXmlDefinitionCheckerTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffXmlDefinitionCheckerTest.java index 7fa6bf7429..ec131592d0 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffXmlDefinitionCheckerTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffXmlDefinitionCheckerTest.java @@ -85,7 +85,7 @@ public static Report check(Path xmlFile) throws IOException for (var entry : client.getLedger().getEntries()) { - if (entry.getType() == LedgerEntryType.SPIN_OFF) + if (entry.getType() == LedgerEntryType.CORPORATE_ACTION) spinOffEntryCount++; var definition = entry.getType() == null ? null 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 index 9b9bdf4a77..128eca7962 100644 --- 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 @@ -13,7 +13,9 @@ 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; @@ -146,15 +148,17 @@ public void testOptionalLocalKeyIsNotRequiredForUniqueUnit() @Test public void testCorporateActionLegCompletenessIsValidated() { - assertOK(LedgerStructuralValidator.validate(ledger(nativeEntry(LedgerEntryType.SPIN_OFF)))); + assertOK(LedgerStructuralValidator.validate(ledger(nativeEntry(LedgerEntryType.CORPORATE_ACTION)))); } @Test public void testSpinOffAllowsNoCorporateActionPrimaryLegs() { var entry = new LedgerEntry(); - entry.setType(LedgerEntryType.SPIN_OFF); + 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))); } @@ -162,7 +166,7 @@ public void testSpinOffAllowsNoCorporateActionPrimaryLegs() @Test public void testDuplicateCorporateActionLegWithoutLocalKeyIsRejected() { - var entry = nativeEntry(LedgerEntryType.SPIN_OFF); + var entry = nativeEntry(LedgerEntryType.CORPORATE_ACTION); var duplicate = securityPosting("duplicate-target", LedgerPostingDirection.INBOUND, //$NON-NLS-1$ CorporateActionLeg.TARGET_SECURITY); duplicate.setLocalKey(null); @@ -174,7 +178,7 @@ public void testDuplicateCorporateActionLegWithoutLocalKeyIsRejected() @Test public void testSpinOffAcceptsRepeatedTargetLegsWithDistinctLocalKeys() { - var entry = nativeEntry(LedgerEntryType.SPIN_OFF); + 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); @@ -190,7 +194,7 @@ public void testSpinOffAcceptsRepeatedTargetLegsWithDistinctLocalKeys() @Test public void testSpinOffRejectsRepeatedTargetLegsWithDuplicateLocalKey() { - var entry = nativeEntry(LedgerEntryType.SPIN_OFF); + 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); @@ -204,7 +208,7 @@ public void testSpinOffRejectsRepeatedTargetLegsWithDuplicateLocalKey() @Test public void testSpinOffWithoutTargetLegIsAccepted() { - var entry = nativeEntry(LedgerEntryType.SPIN_OFF); + var entry = nativeEntry(LedgerEntryType.CORPORATE_ACTION); entry.removePosting(posting(entry, CorporateActionLeg.TARGET_SECURITY)); assertOK(LedgerStructuralValidator.validate(ledger(entry))); @@ -213,7 +217,7 @@ public void testSpinOffWithoutTargetLegIsAccepted() @Test public void testSpinOffAcceptsRepeatedCashCompensationWithDistinctLocalKeys() { - var entry = nativeEntry(LedgerEntryType.SPIN_OFF); + var entry = nativeEntry(LedgerEntryType.CORPORATE_ACTION); entry.addPosting(cashCompensationPosting("cash-1")); //$NON-NLS-1$ entry.addPosting(cashCompensationPosting("cash-2")); //$NON-NLS-1$ @@ -224,7 +228,7 @@ public void testSpinOffAcceptsRepeatedCashCompensationWithDistinctLocalKeys() @Test public void testSpinOffRejectsRepeatedCashCompensationWithDuplicateLocalKey() { - var entry = nativeEntry(LedgerEntryType.SPIN_OFF); + var entry = nativeEntry(LedgerEntryType.CORPORATE_ACTION); entry.addPosting(cashCompensationPosting("cash-1")); //$NON-NLS-1$ entry.addPosting(cashCompensationPosting("cash-1")); //$NON-NLS-1$ @@ -296,7 +300,9 @@ private LedgerEntry nativeEntry(LedgerEntryType type) switch (type) { - case SPIN_OFF -> { + 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(), diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerXmlPersistenceTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerXmlPersistenceTest.java index f7340df64c..60e81d6c62 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerXmlPersistenceTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerXmlPersistenceTest.java @@ -359,11 +359,36 @@ public void testLegacyLedgerParameterAliasesLoadAndSaveCurrentForm() throws Exce assertFalse(currentXml.contains("")); assertFalse(currentXml.contains("ledger-posting-parameter-type")); assertFalse(currentXml.contains("LedgerBacked")); + assertCurrentCorporateActionType(currentXml); assertNoLedgerUuidTruth(currentXml); assertTrue(currentXml.contains("")); } + /** + * Verifies that legacy native spin-off XML type codes load into the generic + * Corporate Action entry family with the concrete kind parameter. + */ + @Test + public void testLegacySpinOffXmlTypeCodeLoadsAsCorporateActionKind() throws Exception + { + var xml = save(legacyLedgerParameterCompatibilityClient()); + var legacyXml = replaceCorporateActionTypeWithLegacySpinOff(xml); + + var loaded = load(legacyXml); + var entry = loaded.getLedger().getEntries().get(0); + + assertThat(entry.getType(), is(LedgerEntryType.CORPORATE_ACTION)); + assertThat(parameter(entry.getParameters(), LedgerParameterType.CORPORATE_ACTION_KIND).getValue(), + is(CorporateActionKind.SPIN_OFF.getCode())); + assertTrue(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).size() > 0); + assertValid(loaded); + + var currentXml = save(loaded); + + assertCurrentCorporateActionType(currentXml); + } + /** * Verifies that XML LedgerParameter diagnostics have stable persistence codes. * Malformed persisted parameters must fail clearly without changing load semantics. @@ -631,6 +656,25 @@ private String replaceRequired(String xml, String target, String replacement) return replaced; } + private String replaceCorporateActionTypeWithLegacySpinOff(String xml) + { + var replaced = xml.replace("type=\"CORPORATE_ACTION\"", "type=\"SPIN_OFF\""); + + if (replaced.equals(xml)) + replaced = xml.replace("CORPORATE_ACTION", "SPIN_OFF"); + + assertFalse(replaced.equals(xml)); + + return replaced; + } + + private void assertCurrentCorporateActionType(String xml) + { + assertTrue(xml.contains("type=\"CORPORATE_ACTION\"") || xml.contains("CORPORATE_ACTION")); + assertFalse(xml.contains("type=\"SPIN_OFF\"")); + assertFalse(xml.contains("SPIN_OFF")); + } + private String replaceRequiredPattern(String xml, String pattern, String replacement) { var replaced = xml.replaceFirst(pattern, replacement); diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerNativeComponentInspectorModelTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerNativeComponentInspectorModelTest.java index 14698333c1..201bc5c98a 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerNativeComponentInspectorModelTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerNativeComponentInspectorModelTest.java @@ -64,7 +64,8 @@ public void testSpinOffInspectionIncludesEntryLegPostingParameterAndProjectionRo .from(entry, selectedDescriptor, LedgerEntryDefinitionRegistry::lookup).orElseThrow(); assertThat(model.getHeaderRows().stream() - .anyMatch(row -> row.field() == HeaderField.ENTRY_TYPE && "SPIN_OFF".equals(row.value())), + .anyMatch(row -> row.field() == HeaderField.ENTRY_TYPE + && "CORPORATE_ACTION".equals(row.value())), is(true)); assertThat(model.getHeaderRows().stream() .anyMatch(row -> row.field() == HeaderField.NATIVE_TARGETED && "true".equals(row.value())), @@ -196,7 +197,7 @@ public void testNonLedgerBackedTransactionIsUnsupported() private static LedgerEntry spinOffEntry() { var entry = new LedgerEntry("entry-spin-off"); - entry.setType(LedgerEntryType.SPIN_OFF); + entry.setType(LedgerEntryType.CORPORATE_ACTION); entry.setDateTime(LocalDateTime.of(2026, 6, 23, 9, 0)); entry.setNote("Spin-off note"); entry.setSource("manual"); diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidatorPolicyTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidatorPolicyTest.java index f8acafa3aa..d020c29f2e 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidatorPolicyTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidatorPolicyTest.java @@ -79,7 +79,7 @@ private LedgerEntry entry() { var entry = new LedgerEntry("policy-entry"); - entry.setType(LedgerEntryType.SPIN_OFF); + entry.setType(LedgerEntryType.CORPORATE_ACTION); return entry; } diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblerTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblerTest.java index cc4b30c856..878581c34b 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblerTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblerTest.java @@ -124,7 +124,7 @@ public void testSpinOffIsConvenienceForForType() var fixture = fixture(); assertThat(LedgerNativeEntryAssembler.forClient(fixture.client).spinOff(), is(notNullValue())); - assertThat(LedgerNativeEntryAssembler.forClient(fixture.client).forType(LedgerEntryType.SPIN_OFF), + assertThat(LedgerNativeEntryAssembler.forClient(fixture.client).forType(LedgerEntryType.CORPORATE_ACTION), is(notNullValue())); } @@ -161,7 +161,7 @@ public void testRejectsMissingEntryDefinition() { var exception = assertThrows(LedgerNativeEntryAssemblyException.class, () -> new LedgerNativeEntryAssembler(new Client(), type -> Optional.empty()) - .forType(LedgerEntryType.SPIN_OFF)); + .forType(LedgerEntryType.CORPORATE_ACTION)); assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.ENTRY_DEFINITION_MISSING)); } @@ -214,7 +214,7 @@ public void testRejectsPostingTypeOutsideEachEntryDefinition() () -> LedgerNativeEntryAssembler.forClient(fixture.client) .forType(definition.getEntryType()) // .metadata(metadata()) // - .event(event(definition.getEntryType())) // + .event(event()) // .securityLeg(invalidLeg) // .buildDetached()); @@ -325,7 +325,7 @@ public void testBuildsDetachedMinimalSpinOff() var result = validSpinOff(fixture).buildDetached(); var entry = result.getEntry(); - assertThat(entry.getType(), is(LedgerEntryType.SPIN_OFF)); + assertThat(entry.getType(), is(LedgerEntryType.CORPORATE_ACTION)); assertThat(entry.getPostings().size(), is(5)); assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).size(), is(3)); assertThat(fixture.client.getLedger().getEntries().size(), is(0)); @@ -687,7 +687,7 @@ private static LedgerNativeEntryAssembler.EntryBuilder baseSpinOff(Fixture fixtu { return LedgerNativeEntryAssembler.forClient(fixture.client).spinOff() // .metadata(metadata()) // - .event(event(LedgerEntryType.SPIN_OFF)); + .event(event()); } private static LedgerNativeEntryAssembler.EntryBuilder spinOffWithRetainedAndNewLegs(Fixture fixture) @@ -761,26 +761,17 @@ private static NativeEntryMetadata metadata() .source("native-entry-assembler-test"); } - private static NativeCorporateActionEvent event(LedgerEntryType entryType) + private static NativeCorporateActionEvent event() { return NativeCorporateActionEvent.builder() // - .kind(corporateActionKind(entryType)) // + .kind(CorporateActionKind.SPIN_OFF) // .subtype(CorporateActionSubtype.STANDARD) // - .reference(entryType.name() + "-2020") // + .reference(CorporateActionKind.SPIN_OFF.getCode() + "-2020") // .stage(EventStage.SETTLED) // .effectiveDate(LocalDate.of(2020, 9, 28)) // .build(); } - private static CorporateActionKind corporateActionKind(LedgerEntryType entryType) - { - for (var kind : CorporateActionKind.values()) - if (kind.getRelatedEntryType().filter(entryType::equals).isPresent()) - return kind; - - throw new IllegalArgumentException("No corporate action kind for " + entryType); - } - private static NativeSecurityLeg.Builder sourceLeg(Fixture fixture) { return NativeSecurityLeg.source() // diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorServiceTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorServiceTest.java index 2d4d0c5760..af61192b92 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorServiceTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorServiceTest.java @@ -36,6 +36,7 @@ import name.abuchen.portfolio.model.ledger.compatibility.LedgerSecurityQuantity; import name.abuchen.portfolio.model.ledger.compatibility.LedgerTransactionCreator; 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; @@ -365,8 +366,10 @@ private LedgerEntry spinOffEntry(Fixture fixture) var fee = unitPosting("fee", LedgerPostingType.FEE, 2, LedgerPostingUnitRole.FEE); var tax = unitPosting("tax", LedgerPostingType.TAX, 1, LedgerPostingUnitRole.TAX); - entry.setType(LedgerEntryType.SPIN_OFF); + entry.setType(LedgerEntryType.CORPORATE_ACTION); entry.setDateTime(DATE_TIME); + entry.addParameter(LedgerParameter.ofCode(LedgerParameterType.CORPORATE_ACTION_KIND, + CorporateActionKind.SPIN_OFF)); entry.addPosting(oldLeg); entry.addPosting(retainedLeg); entry.addPosting(newLeg); @@ -391,8 +394,10 @@ private LedgerEntry repeatedTargetSpinOffEntry(String firstTargetLocalKey, Strin 30, CorporateActionLeg.TARGET_SECURITY, LedgerProjectionRole.NEW_SECURITY_LEG, secondTargetLocalKey); - entry.setType(LedgerEntryType.SPIN_OFF); + entry.setType(LedgerEntryType.CORPORATE_ACTION); entry.setDateTime(DATE_TIME); + entry.addParameter(LedgerParameter.ofCode(LedgerParameterType.CORPORATE_ACTION_KIND, + CorporateActionKind.SPIN_OFF)); entry.addPosting(oldLeg); entry.addPosting(targetA); entry.addPosting(targetB); diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerRuntimeProjectionRestorerTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerRuntimeProjectionRestorerTest.java index 04591d1f58..be1b342ace 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerRuntimeProjectionRestorerTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerRuntimeProjectionRestorerTest.java @@ -26,6 +26,7 @@ import name.abuchen.portfolio.model.Security; import name.abuchen.portfolio.model.Transaction; import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerParameter; import name.abuchen.portfolio.model.ledger.LedgerPosting; import name.abuchen.portfolio.model.ledger.LedgerPostingDirection; import name.abuchen.portfolio.model.ledger.LedgerPostingSemanticRole; @@ -42,7 +43,9 @@ import name.abuchen.portfolio.model.ledger.compatibility.LedgerSecurityQuantity; import name.abuchen.portfolio.model.ledger.compatibility.LedgerTransactionCreator; 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.Money; @@ -129,8 +132,10 @@ public void testSemanticGroupKeyMaterializesNativeTargetedUnits() var fee = unitPosting("fee-posting", LedgerPostingType.FEE, account, 2); var tax = unitPosting("tax-posting", LedgerPostingType.TAX, account, 1); - entry.setType(LedgerEntryType.SPIN_OFF); + entry.setType(LedgerEntryType.CORPORATE_ACTION); entry.setDateTime(DATE_TIME); + entry.addParameter(LedgerParameter.ofCode(LedgerParameterType.CORPORATE_ACTION_KIND, + CorporateActionKind.SPIN_OFF)); entry.addPosting(sourceLeg); entry.addPosting(targetLeg); compensation.setType(LedgerPostingType.CASH_COMPENSATION); diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/snapshot/ClientPerformanceSnapshotTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/snapshot/ClientPerformanceSnapshotTest.java index 4289c9cbe6..4e3824f300 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/snapshot/ClientPerformanceSnapshotTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/snapshot/ClientPerformanceSnapshotTest.java @@ -342,7 +342,7 @@ public void testLedgerNativeDepositProjectionUnitsCountedAsFeesAndTaxes() LedgerNativeEntryAssembler.forClient(client).spinOff() .metadata(nativeMetadata()) - .event(nativeEvent(LedgerEntryType.SPIN_OFF)) + .event(nativeEvent(LedgerEntryType.CORPORATE_ACTION)) .cashCompensation(NativeCashCompensation.builder() // .account(account) // .amount(Money.of(CurrencyUnit.EUR, 5_00)) // @@ -368,7 +368,7 @@ public void testLedgerNativeRemovalProjectionUnitsCountedAsFeesAndTaxes() { Client client = new Client(); Account account = account(); - var transaction = ledgerBackedAccountTransaction(LedgerEntryType.SPIN_OFF, + var transaction = ledgerBackedAccountTransaction(LedgerEntryType.CORPORATE_ACTION, AccountTransaction.Type.REMOVAL, 5_00); transaction.addUnit(new Unit(Unit.Type.FEE, Money.of(CurrencyUnit.EUR, 2_00))); @@ -849,7 +849,7 @@ private NativeEntryMetadata nativeMetadata() private NativeCorporateActionEvent nativeEvent(LedgerEntryType entryType) { return NativeCorporateActionEvent.builder() // - .kind(CorporateActionKind.valueOf(entryType.name())) // + .kind(CorporateActionKind.SPIN_OFF) // .subtype(CorporateActionSubtype.STANDARD) // .stage(EventStage.SETTLED) // .effectiveDate(LocalDate.of(2011, Month.MARCH, 1)) // diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerModelLoadSupport.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerModelLoadSupport.java index ba27b62914..071e2cac84 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerModelLoadSupport.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerModelLoadSupport.java @@ -10,7 +10,9 @@ import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerParameter; import name.abuchen.portfolio.model.ledger.LedgerPosting; +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; /** @@ -20,6 +22,8 @@ */ final class LedgerModelLoadSupport { + private static final String LEGACY_SPIN_OFF_TYPE_CODE = "SPIN_OFF"; //$NON-NLS-1$ + private LedgerModelLoadSupport() { } @@ -34,6 +38,29 @@ static LedgerEntry newEntry(String uuid, LedgerEntryType type, LocalDateTime dat return entry; } + static LedgerEntryType entryTypeFromPersistedCode(String code) + { + if (LEGACY_SPIN_OFF_TYPE_CODE.equals(code)) + return LedgerEntryType.CORPORATE_ACTION; + + return LedgerEntryType.fromCode(code); + } + + static boolean isLegacySpinOffTypeCode(String code) + { + return LEGACY_SPIN_OFF_TYPE_CODE.equals(code); + } + + static void addLegacySpinOffKindIfMissing(LedgerEntry entry) + { + if (entry.getParameters().stream() + .anyMatch(parameter -> parameter.getType() == LedgerParameterType.CORPORATE_ACTION_KIND)) + return; + + entry.addParameter(LedgerParameter.ofCode(LedgerParameterType.CORPORATE_ACTION_KIND, + CorporateActionKind.SPIN_OFF)); + } + static void setEntryNote(LedgerEntry entry, String note) { Objects.requireNonNull(entry).setNote(note); diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceSupport.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceSupport.java index 014eb8f43c..80ed44788f 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceSupport.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceSupport.java @@ -199,8 +199,9 @@ private static void loadLedger(PLedger newLedger, Client client, ProtobufWriter. { for (PLedgerEntry newEntry : newLedger.getEntriesList()) { + var typeCode = newEntry.getTypeCode(); LedgerEntry entry = LedgerModelLoadSupport.newEntry(UUID.randomUUID().toString(), - LedgerEntryType.fromCode(newEntry.getTypeCode()), + LedgerModelLoadSupport.entryTypeFromPersistedCode(typeCode), fromTimestamp(newEntry.getDateTime())); if (newEntry.hasNote()) @@ -222,6 +223,9 @@ private static void loadLedger(PLedger newLedger, Client client, ProtobufWriter. for (PLedgerParameter newParameter : newEntry.getParametersList()) LedgerModelLoadSupport.addEntryParameter(entry, loadLedgerParameter(newParameter, lookup)); + if (LedgerModelLoadSupport.isLegacySpinOffTypeCode(typeCode)) + LedgerModelLoadSupport.addLegacySpinOffKindIfMissing(entry); + for (PLedgerPosting newPosting : newEntry.getPostingsList()) LedgerModelLoadSupport.addPosting(entry, loadLedgerPosting(newPosting, lookup)); diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerXmlPersistenceSupport.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerXmlPersistenceSupport.java index 8fbe740f78..4a8592cbc5 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerXmlPersistenceSupport.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerXmlPersistenceSupport.java @@ -241,9 +241,15 @@ public Object unmarshal(HierarchicalStreamReader reader, UnmarshallingContext co { var entry = new LedgerEntry(); var updatedAt = reader.getAttribute("updatedAt"); //$NON-NLS-1$ + var legacySpinOffTypeCode = false; + var typeAttribute = reader.getAttribute("type"); //$NON-NLS-1$ readAttribute(reader, "uuid").ifPresent(entry::setUUID); //$NON-NLS-1$ - readAttribute(reader, "type").map(LedgerEntryType::valueOf).ifPresent(entry::setType); //$NON-NLS-1$ + if (typeAttribute != null) + { + entry.setType(LedgerModelLoadSupport.entryTypeFromPersistedCode(typeAttribute)); + legacySpinOffTypeCode = LedgerModelLoadSupport.isLegacySpinOffTypeCode(typeAttribute); + } readAttribute(reader, "dateTime").map(LocalDateTime::parse).ifPresent(entry::setDateTime); //$NON-NLS-1$ while (reader.hasMoreChildren()) @@ -253,8 +259,11 @@ public Object unmarshal(HierarchicalStreamReader reader, UnmarshallingContext co switch (reader.getNodeName()) { case "uuid" -> entry.setUUID(reader.getValue()); //$NON-NLS-1$ - case "type" -> entry.setType((LedgerEntryType) context.convertAnother(entry, //$NON-NLS-1$ - LedgerEntryType.class)); + case "type" -> { //$NON-NLS-1$ + var typeCode = reader.getValue(); + entry.setType(LedgerModelLoadSupport.entryTypeFromPersistedCode(typeCode)); + legacySpinOffTypeCode |= LedgerModelLoadSupport.isLegacySpinOffTypeCode(typeCode); + } case "dateTime" -> entry.setDateTime((LocalDateTime) context.convertAnother(entry, //$NON-NLS-1$ LocalDateTime.class)); case "updatedAt" -> updatedAt = reader.getValue(); //$NON-NLS-1$ @@ -279,6 +288,9 @@ public Object unmarshal(HierarchicalStreamReader reader, UnmarshallingContext co if (updatedAt != null) entry.setUpdatedAt(Instant.parse(updatedAt)); + if (legacySpinOffTypeCode) + LedgerModelLoadSupport.addLegacySpinOffKindIfMissing(entry); + return entry; } 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 index 0b1ff2ebbb..0d0310069f 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerStructuralValidator.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerStructuralValidator.java @@ -14,6 +14,7 @@ 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; @@ -214,17 +215,7 @@ private static void validateSemanticShape(LedgerEntry entry, List { - 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); - } + case CORPORATE_ACTION -> validateCorporateActionSemanticShape(entry, issues); default -> { // No semantic shape rule. } @@ -233,6 +224,24 @@ private static void validateSemanticShape(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) 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 index c255fc23b2..7b4b76c6e2 100644 --- 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 @@ -2,6 +2,9 @@ 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 @@ -15,21 +18,13 @@ @SuppressWarnings("nls") public enum CorporateActionKind implements LedgerCode { - SPIN_OFF("SPIN_OFF", LedgerEntryType.SPIN_OFF), - OTHER("OTHER"); + SPIN_OFF("SPIN_OFF"); private final String code; - private final LedgerEntryType relatedEntryType; private CorporateActionKind(String code) - { - this(code, null); - } - - private CorporateActionKind(String code, LedgerEntryType relatedEntryType) { this.code = code; - this.relatedEntryType = relatedEntryType; } @Override @@ -44,8 +39,31 @@ public String getCode() return code; } - public Optional getRelatedEntryType() + 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) { - return Optional.ofNullable(relatedEntryType); + 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/LedgerEntryDefinitionRegistry.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerEntryDefinitionRegistry.java index b2c23426d5..5e39014565 100644 --- 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 @@ -10,6 +10,7 @@ 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; @@ -45,6 +46,28 @@ public static Optional lookup(LedgerEntryType entryType) 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 != CorporateActionKind.SPIN_OFF) + return Optional.empty(); + + return lookup(entryType); + } + public static Collection getDefinitions() { return DEFINITIONS.values(); @@ -74,7 +97,8 @@ private static void register(Map definit private static LedgerEntryDefinition spinOff() { - return LedgerEntryDefinition.of(LedgerEntryType.SPIN_OFF, LedgerNativeEntryShape.DUAL_INSTRUMENT_PLUS_ACCOUNT, + return LedgerEntryDefinition.of(LedgerEntryType.CORPORATE_ACTION, + LedgerNativeEntryShape.DUAL_INSTRUMENT_PLUS_ACCOUNT, SETS.postingRules( optionalPosting(LedgerPostingType.SECURITY, requiredSecurityLegParameters(), spinOffSecurityOptionalParameters()), 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 index f69b4a7826..9cc0bbe623 100644 --- 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 @@ -27,7 +27,7 @@ public enum LedgerEntryType SECURITY_TRANSFER("SECURITY_TRANSFER", Shape.LEGACY_FIXED), DELIVERY_INBOUND("DELIVERY_INBOUND", Shape.LEGACY_FIXED), DELIVERY_OUTBOUND("DELIVERY_OUTBOUND", Shape.LEGACY_FIXED), - SPIN_OFF("SPIN_OFF", Shape.LEDGER_NATIVE_TARGETED); + CORPORATE_ACTION("CORPORATE_ACTION", Shape.LEDGER_NATIVE_TARGETED); private enum Shape { diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java index 3d930a7809..b7c2e718c5 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java @@ -78,7 +78,7 @@ public static ValidationResult validate(LedgerEntry entry) if (!entryType.isLedgerNativeTargeted()) return new ValidationResult(issues); - var definition = LedgerEntryDefinitionRegistry.lookup(entryType); + var definition = LedgerEntryDefinitionRegistry.lookup(entry); if (definition.isEmpty()) { issues.add(issue(IssueCode.ENTRY_DEFINITION_MISSING, @@ -523,7 +523,7 @@ private static boolean postingMatchesLeg(LedgerEntryType entryType, LedgerPostin private static Optional expectedCorporateActionLegCode(LedgerEntryType entryType, LedgerLegRole role) { - if (entryType == LedgerEntryType.SPIN_OFF) + if (entryType == LedgerEntryType.CORPORATE_ACTION) { if (role == LedgerLegRole.SOURCE_SECURITY_LEG) return Optional.of(CorporateActionLeg.SOURCE_SECURITY.getCode()); diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssembler.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssembler.java index 2b4b1da899..e3734d4112 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssembler.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssembler.java @@ -90,7 +90,7 @@ public EntryBuilder forType(LedgerEntryType entryType) public EntryBuilder spinOff() { - return forType(LedgerEntryType.SPIN_OFF); + return forType(LedgerEntryType.CORPORATE_ACTION); } static LedgerNativeEntryAssemblyException issue(LedgerNativeEntryAssemblyIssue issue, String message) diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java index 9dc6f155bb..cccb2d96d1 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java @@ -17,6 +17,7 @@ import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; 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; @@ -83,34 +84,7 @@ public Result derive(LedgerEntry entry) primary().and(direction(LedgerPostingDirection.INBOUND)), diagnostics) .ifPresent(descriptors::add); } - case SPIN_OFF -> { - repeatedPortfolio(entry, LedgerProjectionRole.OLD_SECURITY_LEG, - primary().and(corporateLeg(CorporateActionLeg.SOURCE_SECURITY)) - .and(localKey(LedgerProjectionRole.OLD_SECURITY_LEG)) - .or(legacyCorporateLeg(LedgerPostingType.SECURITY, - CorporateActionLeg.SOURCE_SECURITY)), - primary().and(corporateLeg(CorporateActionLeg.SOURCE_SECURITY)), true, diagnostics) - .forEach(descriptors::add); - optionalPortfolio(entry, LedgerProjectionRole.DELIVERY_INBOUND, - primary().and(corporateLeg(CorporateActionLeg.TARGET_SECURITY)) - .and(localKey(LedgerProjectionRole.DELIVERY_INBOUND)) - .or(legacyRetainedSpinOffTarget()), - diagnostics).ifPresent(descriptors::add); - repeatedPortfolio(entry, LedgerProjectionRole.NEW_SECURITY_LEG, - primary().and(corporateLeg(CorporateActionLeg.TARGET_SECURITY)) - .and(localKey(LedgerProjectionRole.NEW_SECURITY_LEG)) - .or(legacyNewSpinOffTarget()), - primary().and(corporateLeg(CorporateActionLeg.TARGET_SECURITY)) - .and(localKey(LedgerProjectionRole.DELIVERY_INBOUND).negate()), - true, diagnostics).forEach(descriptors::add); - repeatedAccount(entry, LedgerProjectionRole.CASH_COMPENSATION, - primary().and(corporateLeg(CorporateActionLeg.CASH_COMPENSATION)) - .and(localKey(LedgerProjectionRole.CASH_COMPENSATION)) - .or(legacyCorporateLeg(LedgerPostingType.CASH_COMPENSATION, - CorporateActionLeg.CASH_COMPENSATION)), - primary().and(corporateLeg(CorporateActionLeg.CASH_COMPENSATION)), true, diagnostics) - .forEach(descriptors::add); - } + case CORPORATE_ACTION -> corporateAction(entry, descriptors, diagnostics); default -> diagnostics.add(Diagnostic.missing(entry, null, "No derived projection rule for entry type " + entry.getType())); //$NON-NLS-1$ } @@ -118,6 +92,46 @@ public Result derive(LedgerEntry entry) return new Result(descriptors, diagnostics); } + private void corporateAction(LedgerEntry entry, List descriptors, + List diagnostics) + { + var kind = CorporateActionKind.fromEntry(entry); + + if (kind.filter(CorporateActionKind.SPIN_OFF::equals).isEmpty()) + { + diagnostics.add(Diagnostic.missing(entry, null, + "No derived projection rule for corporate action kind")); //$NON-NLS-1$ + return; + } + + repeatedPortfolio(entry, LedgerProjectionRole.OLD_SECURITY_LEG, + primary().and(corporateLeg(CorporateActionLeg.SOURCE_SECURITY)) + .and(localKey(LedgerProjectionRole.OLD_SECURITY_LEG)) + .or(legacyCorporateLeg(LedgerPostingType.SECURITY, + CorporateActionLeg.SOURCE_SECURITY)), + primary().and(corporateLeg(CorporateActionLeg.SOURCE_SECURITY)), true, diagnostics) + .forEach(descriptors::add); + optionalPortfolio(entry, LedgerProjectionRole.DELIVERY_INBOUND, + primary().and(corporateLeg(CorporateActionLeg.TARGET_SECURITY)) + .and(localKey(LedgerProjectionRole.DELIVERY_INBOUND)) + .or(legacyRetainedSpinOffTarget()), + diagnostics).ifPresent(descriptors::add); + repeatedPortfolio(entry, LedgerProjectionRole.NEW_SECURITY_LEG, + primary().and(corporateLeg(CorporateActionLeg.TARGET_SECURITY)) + .and(localKey(LedgerProjectionRole.NEW_SECURITY_LEG)) + .or(legacyNewSpinOffTarget()), + primary().and(corporateLeg(CorporateActionLeg.TARGET_SECURITY)) + .and(localKey(LedgerProjectionRole.DELIVERY_INBOUND).negate()), + true, diagnostics).forEach(descriptors::add); + repeatedAccount(entry, LedgerProjectionRole.CASH_COMPENSATION, + primary().and(corporateLeg(CorporateActionLeg.CASH_COMPENSATION)) + .and(localKey(LedgerProjectionRole.CASH_COMPENSATION)) + .or(legacyCorporateLeg(LedgerPostingType.CASH_COMPENSATION, + CorporateActionLeg.CASH_COMPENSATION)), + primary().and(corporateLeg(CorporateActionLeg.CASH_COMPENSATION)), true, diagnostics) + .forEach(descriptors::add); + } + private java.util.Optional account(LedgerEntry entry, LedgerProjectionRole role, Predicate selector, List diagnostics) { From afd3c56a903cab469a98b4e6dd8c04f72358d1a7 Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Fri, 3 Jul 2026 18:03:15 +0200 Subject: [PATCH 48/68] Register supported Ledger corporate action kinds Add stable CorporateActionKind codes for the explicit registry profiles and register definition subsets for the kinds that fit current Ledger posting and parameter primitives. This lets corporate action kind identity persist through existing parameters while keeping non-SPIN_OFF profiles honest about partial service support. Unsupported SEC_CTX, semantic primary-movement one-of, correction, reversal, basis, and mandatory interest-detail semantics remain intentionally unimplemented. --- .../model/LedgerProtobufPersistenceTest.java | 34 ++ .../model/ledger/LedgerCodeTest.java | 18 +- .../ledger/LedgerEntryDefinitionTest.java | 71 +++- .../model/ledger/LedgerModelTest.java | 10 +- .../ledger/LedgerParameterCodeDomainTest.java | 7 +- .../ledger/LedgerXmlPersistenceTest.java | 31 ++ .../LedgerNativeEntryAssemblerTest.java | 6 +- .../configuration/CorporateActionKind.java | 16 +- .../LedgerEntryDefinitionRegistry.java | 335 +++++++++++++++++- .../ledger/configuration/LedgerLegRole.java | 1 + .../LedgerNativeEntryDefinitionValidator.java | 10 + .../DerivedProjectionDescriptorService.java | 4 - 12 files changed, 519 insertions(+), 24 deletions(-) diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceTest.java index bddfe26cdc..e0a6fe58dd 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceTest.java @@ -272,6 +272,40 @@ public void testProtobufLoadPreservesLedgerGraphOrderParametersAndSemantics() th assertValid(loaded); } + /** + * Verifies that newly registered corporate action kind identities roundtrip through + * existing protobuf parameter persistence without adding legacy transaction types. + */ + @Test + public void testProtobufRoundtripPreservesRegisteredCorporateActionKind() throws IOException + { + var client = new Client(); + var entry = new LedgerEntry("entry-maturity-kind"); + + entry.setType(LedgerEntryType.CORPORATE_ACTION); + entry.setDateTime(DATE_TIME); + entry.addParameter(LedgerParameter.ofCode(LedgerParameterType.CORPORATE_ACTION_KIND, + CorporateActionKind.MATURITY)); + client.getLedger().addEntry(entry); + + var proto = saveProto(client); + var protoEntry = proto.getLedger().getEntries(0); + + assertThat(protoEntry.getTypeCode(), is(LedgerEntryType.CORPORATE_ACTION.getCode())); + assertThat(protoEntry.getParameters(0).getTypeCode(), is(LedgerParameterType.CORPORATE_ACTION_KIND.getCode())); + assertThat(protoEntry.getParameters(0).getStringValue(), is(CorporateActionKind.MATURITY.getCode())); + assertTrue(proto.getTransactionsList().stream() + .noneMatch(transaction -> "MATURITY".equals(transaction.getType().name()))); + + var loaded = load(wrap(proto)); + var reloadedEntry = loaded.getLedger().getEntries().get(0); + + assertThat(reloadedEntry.getType(), is(LedgerEntryType.CORPORATE_ACTION)); + assertThat(reloadedEntry.getParameters().size(), is(1)); + assertThat(reloadedEntry.getParameters().get(0).getType(), is(LedgerParameterType.CORPORATE_ACTION_KIND)); + assertThat(reloadedEntry.getParameters().get(0).getValue(), is(CorporateActionKind.MATURITY.getCode())); + } + /** * Verifies that protobuf save writes derived shadows for cross-entry families. * Those shadows must mirror the ledger without becoming independent transaction truth. 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 index c7a66f03c9..242a9b0ef8 100644 --- 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 @@ -95,8 +95,24 @@ public void testDomainAllowedCodesMatchDomainEnums() public void testCorporateActionKindsClassifyGenericNativeEntryType() { assertTrue(LedgerEntryType.CORPORATE_ACTION.isLedgerNativeTargeted()); - assertThat(List.of(CorporateActionKind.values()), is(List.of(CorporateActionKind.SPIN_OFF))); + 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()); } 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 index 2d08e0f64b..cbd35c02eb 100644 --- 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 @@ -11,6 +11,7 @@ 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; @@ -79,11 +80,7 @@ public void testDefinitionsAreConsistentStaticConfiguration() assertFalse(definition.getRequiredEntryParameterRules().isEmpty()); assertFalse(definition.getEntryParameterRules().isEmpty()); assertFalse(definition.getPostingParameterTypes().isEmpty()); - assertFalse(definition.getRequiredPostingParameterRules().isEmpty()); assertFalse(definition.getPostingParameterRules().isEmpty()); - assertFalse(definition.getProjectionRoles().isEmpty()); - assertFalse(definition.getProjectionRules().isEmpty()); - assertFalse(definition.getAlternativeRequirementGroups().isEmpty()); assertFalse(definition.getLegDefinitions().isEmpty()); assertTrue(definition.getReportingClass() != LedgerReportingClass.UNDEFINED); assertTrue(definition.getPerformanceTreatment() != LedgerPerformanceTreatment.UNDEFINED); @@ -110,9 +107,6 @@ public void testDefinitionRegistriesExposeReadOnlySchemas() assertFalse(definition.getPostingRules().isEmpty()); assertFalse(definition.getEntryParameterRules().isEmpty()); assertFalse(definition.getPostingParameterRules().isEmpty()); - assertFalse(definition.getProjectionRules().isEmpty()); - assertFalse(definition.getPostingGroupRules().isEmpty()); - assertFalse(definition.getAlternativeRequirementGroups().isEmpty()); assertTrue(definition.getReportingClass() != LedgerReportingClass.UNDEFINED); assertTrue(definition.getPerformanceTreatment() != LedgerPerformanceTreatment.UNDEFINED); assertThat(definition.getDownstreamResultsNotPersisted(), is(EnumSet.allOf(LedgerDownstreamResult.class))); @@ -214,7 +208,8 @@ public void testAlternativeGroupsDoNotDuplicateHardRequiredMembers() @Test public void testSpinOffDefinitionDescribesNativeDataModel() { - var definition = LedgerEntryDefinitionRegistry.lookup(LedgerEntryType.CORPORATE_ACTION).orElseThrow(); + 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)); @@ -260,7 +255,8 @@ public void testSpinOffDefinitionDescribesNativeDataModel() @Test public void testSpinOffDefinitionDescribesFunctionalLegs() { - var definition = LedgerEntryDefinitionRegistry.lookup(LedgerEntryType.CORPORATE_ACTION).orElseThrow(); + var definition = LedgerEntryDefinitionRegistry + .lookup(LedgerEntryType.CORPORATE_ACTION, CorporateActionKind.SPIN_OFF).orElseThrow(); var sourceLeg = assertLeg(definition, LedgerLegRole.SOURCE_SECURITY_LEG, LedgerPostingType.SECURITY, LedgerLegCardinality.REPEATABLE); @@ -307,6 +303,63 @@ public void testSpinOffDefinitionDescribesFunctionalLegs() 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. 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 index d6c48a2b98..9b74e369a3 100644 --- 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 @@ -410,7 +410,15 @@ public void testLedgerParameterCodeDomainsAreExplicit() CorporateActionLeg.REDEMPTION, CorporateActionLeg.CONVERSION_SOURCE, CorporateActionLeg.CONVERSION_TARGET, CorporateActionLeg.OTHER); assertCodeDomain(LedgerParameterType.CORPORATE_ACTION_KIND, - LedgerParameterCodeDomain.CORPORATE_ACTION_KIND, CorporateActionKind.SPIN_OFF); + 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, 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 index 2b79f197a4..c7383f1334 100644 --- 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 @@ -27,7 +27,12 @@ public class LedgerParameterCodeDomainTest "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("SPIN_OFF")), + 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, diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerXmlPersistenceTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerXmlPersistenceTest.java index 60e81d6c62..6a07452924 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerXmlPersistenceTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerXmlPersistenceTest.java @@ -261,6 +261,37 @@ public void testLedgerXmlRoundtripPreservesTruthAndDoesNotPersistRuntimeProjecti assertValid(loaded); } + /** + * Verifies that newly registered corporate action kind identities roundtrip through + * existing Ledger parameter persistence without implying projection service support. + */ + @Test + public void testXmlRoundtripPreservesRegisteredCorporateActionKind() throws Exception + { + var client = new Client(); + var entry = new LedgerEntry("entry-maturity-kind"); + + entry.setType(LedgerEntryType.CORPORATE_ACTION); + entry.setDateTime(DATE_TIME); + entry.addParameter(LedgerParameter.ofCode(LedgerParameterType.CORPORATE_ACTION_KIND, + CorporateActionKind.MATURITY)); + client.getLedger().addEntry(entry); + + var xml = save(client); + + assertTrue(xml.contains("CORPORATE_ACTION")); + assertTrue(xml.contains("MATURITY")); + assertFalse(xml.contains("SPIN_OFF")); + + var loaded = load(xml); + var reloadedEntry = loaded.getLedger().getEntries().get(0); + + assertThat(reloadedEntry.getType(), is(LedgerEntryType.CORPORATE_ACTION)); + assertThat(reloadedEntry.getParameters().size(), is(1)); + assertThat(reloadedEntry.getParameters().get(0).getType(), is(LedgerParameterType.CORPORATE_ACTION_KIND)); + assertThat(reloadedEntry.getParameters().get(0).getValue(), is(CorporateActionKind.MATURITY.getCode())); + } + /** * Verifies that loading XML with ledger truth does not migrate compatibility rows again. * Shadow rows must stay derived data and not create duplicate ledger entries. diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblerTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblerTest.java index 878581c34b..3a8c621e11 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblerTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblerTest.java @@ -142,9 +142,6 @@ public void testDefinitionRegistrySchemasAreReadableByAssemblerConsumers() assertThat(definition.getPostingRules().isEmpty(), is(false)); assertThat(definition.getEntryParameterRules().isEmpty(), is(false)); assertThat(definition.getPostingParameterRules().isEmpty(), is(false)); - assertThat(definition.getProjectionRules().isEmpty(), is(false)); - assertThat(definition.getPostingGroupRules().isEmpty(), is(false)); - assertThat(definition.getAlternativeRequirementGroups().isEmpty(), is(false)); assertThat(definition.getReportingClass() != LedgerReportingClass.UNDEFINED, is(true)); assertThat(definition.getPerformanceTreatment() != LedgerPerformanceTreatment.UNDEFINED, is(true)); assertThat(definition.getDownstreamResultsNotPersisted().isEmpty(), is(false)); @@ -200,6 +197,9 @@ public void testRejectsPostingTypeOutsideEachEntryDefinition() for (var definition : LedgerEntryDefinitionRegistry.getDefinitions()) { + if (definition.getProjectionRules().isEmpty()) + continue; + var invalidPostingType = java.util.Arrays.stream(LedgerPostingType.values()) // .filter(postingType -> !definition.getPostingTypes().contains(postingType)) // .findFirst().orElseThrow(); 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 index 7b4b76c6e2..08b96083fe 100644 --- 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 @@ -18,7 +18,21 @@ @SuppressWarnings("nls") public enum CorporateActionKind implements LedgerCode { - SPIN_OFF("SPIN_OFF"); + 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; 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 index 5e39014565..d0ed82e8ec 100644 --- 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 @@ -4,6 +4,7 @@ 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; @@ -36,6 +37,7 @@ public final class LedgerEntryDefinitionRegistry private static final SetBuilder SETS = new SetBuilder(); private static final Map DEFINITIONS = definitions(); + private static final Map CORPORATE_ACTION_DEFINITIONS = corporateActionDefinitions(); private LedgerEntryDefinitionRegistry() { @@ -43,6 +45,9 @@ 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)); } @@ -62,19 +67,25 @@ public static Optional lookup(LedgerEntryType entryType, if (entryType != LedgerEntryType.CORPORATE_ACTION) return lookup(entryType); - if (kind != CorporateActionKind.SPIN_OFF) + if (kind == null) return Optional.empty(); - return lookup(entryType); + return Optional.ofNullable(CORPORATE_ACTION_DEFINITIONS.get(kind)); } public static Collection getDefinitions() { - return DEFINITIONS.values(); + 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); } @@ -82,7 +93,25 @@ private static Map definitions() { var definitions = new EnumMap(LedgerEntryType.class); - register(definitions, spinOff()); + 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()); + 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); } @@ -95,6 +124,14 @@ private static void register(Map definit .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, @@ -154,6 +191,296 @@ private static LedgerEntryDefinition spinOff() 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) 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 index fee0af67e5..d103a82e5e 100644 --- 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 @@ -17,6 +17,7 @@ public enum LedgerLegRole 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/LedgerNativeEntryDefinitionValidator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java index b7c2e718c5..488910adbd 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java @@ -529,6 +529,16 @@ private static Optional expectedCorporateActionLegCode(LedgerEntryType e return Optional.of(CorporateActionLeg.SOURCE_SECURITY.getCode()); if (role == LedgerLegRole.TARGET_SECURITY_LEG) return Optional.of(CorporateActionLeg.TARGET_SECURITY.getCode()); + if (role == LedgerLegRole.RECEIVED_SECURITY_LEG) + return Optional.of(CorporateActionLeg.TARGET_SECURITY.getCode()); + if (role == LedgerLegRole.DISTRIBUTED_SECURITY_LEG) + return Optional.of(CorporateActionLeg.DISTRIBUTED_SECURITY.getCode()); + if (role == LedgerLegRole.DISTRIBUTED_RIGHT_LEG) + return Optional.of(CorporateActionLeg.RIGHT_SECURITY.getCode()); + if (role == LedgerLegRole.SOURCE_BOND_LEG) + return Optional.of(CorporateActionLeg.SOURCE_SECURITY.getCode()); + if (role == LedgerLegRole.PRINCIPAL_REDEMPTION_LEG) + return Optional.of(CorporateActionLeg.PRINCIPAL.getCode()); } if (role == LedgerLegRole.CASH_COMPENSATION_LEG) diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java index cccb2d96d1..284fe89adc 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java @@ -98,11 +98,7 @@ private void corporateAction(LedgerEntry entry, List Date: Sun, 5 Jul 2026 08:08:18 +0200 Subject: [PATCH 49/68] Require portfolio for Ledger native security postings --- .../ledger/LedgerDiagnosticCodeTest.java | 2 +- .../ledger/LedgerStructuralValidatorTest.java | 73 +++++++++++++++++++ .../src/name/abuchen/portfolio/Messages.java | 1 + .../abuchen/portfolio/messages.properties | 2 + .../portfolio/model/LedgerDiagnosticCode.java | 1 + .../ledger/LedgerStructuralValidator.java | 10 +++ 6 files changed, 88 insertions(+), 1 deletion(-) 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 index 6c1a07ac57..05027937e9 100644 --- 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 @@ -26,7 +26,7 @@ public void testCodeTextAndPrefix() 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_STRUCT_056.getCode(), is("LEDGER-STRUCT-056")); 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")); 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 index 128eca7962..707bbc4d23 100644 --- 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 @@ -151,6 +151,69 @@ public void testCorporateActionLegCompletenessIsValidated() assertOK(LedgerStructuralValidator.validate(ledger(nativeEntry(LedgerEntryType.CORPORATE_ACTION)))); } + @Test + public void testNativeCorporateActionRejectsSecurityPostingWithoutPortfolio() + { + var entry = nativeEntry(LedgerEntryType.CORPORATE_ACTION); + posting(entry, CorporateActionLeg.TARGET_SECURITY).setPortfolio(null); + + assertIssue(entry, IssueCode.POSTING_PORTFOLIO_REQUIRED_FOR_SECURITY); + } + + @Test + public void testNativeCorporateActionAcceptsSecurityPostingWithPortfolio() + { + assertOK(LedgerStructuralValidator.validate(ledger(nativeEntry(LedgerEntryType.CORPORATE_ACTION)))); + } + + @Test + public void testNativeCorporateActionAcceptsPureCashPostingWithoutPortfolio() + { + 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)); + entry.addPosting(cashCompensationPosting("cash-1")); //$NON-NLS-1$ + + assertOK(LedgerStructuralValidator.validate(ledger(entry))); + } + + @Test + public void testNativeCorporateActionAllowsPortfolioWithoutSecurityForThisRule() + { + var entry = nativeEntry(LedgerEntryType.CORPORATE_ACTION); + var posting = posting(entry, CorporateActionLeg.TARGET_SECURITY); + posting.setType(LedgerPostingType.CASH); + posting.setSecurity(null); + + assertOK(LedgerStructuralValidator.validate(ledger(entry))); + } + + @Test + public void testRegisteredCorporateActionKindRejectsSecurityPostingWithoutPortfolio() + { + var entry = corporateActionEntry(CorporateActionKind.STOCK_DIVIDEND); + entry.addPosting(securityPosting("target", LedgerPostingDirection.INBOUND, //$NON-NLS-1$ + CorporateActionLeg.TARGET_SECURITY)); + posting(entry, CorporateActionLeg.TARGET_SECURITY).setPortfolio(null); + + assertIssue(entry, IssueCode.POSTING_PORTFOLIO_REQUIRED_FOR_SECURITY); + } + + @Test + public void testRegisteredCorporateActionKindAcceptsPureCashPostingWithoutPortfolio() + { + 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.COUPON_PAYMENT)); + entry.addPosting(cashCompensationPosting("coupon-cash")); //$NON-NLS-1$ + + assertOK(LedgerStructuralValidator.validate(ledger(entry))); + } + @Test public void testSpinOffAllowsNoCorporateActionPrimaryLegs() { @@ -315,6 +378,16 @@ private LedgerEntry nativeEntry(LedgerEntryType type) return entry; } + private LedgerEntry corporateActionEntry(CorporateActionKind kind) + { + 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, kind)); + + return entry; + } + private LedgerPosting cashPosting(String localKey, LedgerPostingDirection direction) { return accountPosting(localKey, LedgerPostingType.CASH, LedgerPostingSemanticRole.CASH, direction); diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/Messages.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/Messages.java index fd328bf79a..c0e0a2a9aa 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/Messages.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/Messages.java @@ -296,6 +296,7 @@ public class Messages extends NLS public static String LedgerStructuralValidatorPostingCurrencyRequired; public static String LedgerStructuralValidatorPostingExchangeRatePositive; public static String LedgerStructuralValidatorPostingGroupRefNotFound; + public static String LedgerStructuralValidatorPostingPortfolioRequiredForSecurity; public static String LedgerStructuralValidatorPostingSecurityRequired; public static String LedgerStructuralValidatorPostingTypeRequired; public static String LedgerStructuralValidatorPrimaryPostingRefNotFound; diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages.properties index 6cef515d64..c23051f5c9 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages.properties @@ -580,6 +580,8 @@ LedgerStructuralValidatorPostingExchangeRatePositive = Ledger posting {0} must h LedgerStructuralValidatorPostingGroupRefNotFound = Posting group reference {0} must point to a posting in the same entry. +LedgerStructuralValidatorPostingPortfolioRequiredForSecurity = Ledger-native security posting {0} must reference a portfolio. + LedgerStructuralValidatorPostingSecurityRequired = Security posting {0} must reference a security. LedgerStructuralValidatorPostingTypeRequired = Ledger posting {0} must have a posting type. diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerDiagnosticCode.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerDiagnosticCode.java index 9415f3defa..588fafc080 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerDiagnosticCode.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerDiagnosticCode.java @@ -110,6 +110,7 @@ public enum LedgerDiagnosticCode 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_STRUCT_056("STRUCT", 56), //$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$ 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 index 0d0310069f..824eb8ff2a 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerStructuralValidator.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerStructuralValidator.java @@ -33,6 +33,7 @@ public enum IssueCode POSTING_TYPE_REQUIRED, POSTING_CURRENCY_REQUIRED, POSTING_SECURITY_REQUIRED, + POSTING_PORTFOLIO_REQUIRED_FOR_SECURITY, POSTING_EXCHANGE_RATE_POSITIVE, DIVIDEND_SECURITY_REQUIRED, PARAMETER_TYPE_REQUIRED, @@ -163,6 +164,15 @@ private static void validatePostingShape(LedgerEntry entry, LedgerPosting postin posting.getUUID())), entry, posting)); + if (entry.getType() != null && entry.getType().isLedgerNativeTargeted() && posting.getSecurity() != null + && posting.getPortfolio() == null) + issues.add(postingIssue(IssueCode.POSTING_PORTFOLIO_REQUIRED_FOR_SECURITY, + LedgerDiagnosticCode.LEDGER_STRUCT_056 + .message(MessageFormat.format( + Messages.LedgerStructuralValidatorPostingPortfolioRequiredForSecurity, + 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( From 5cac0e2dd2c275a91042d7c3c48b3e23f0eec9d8 Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Sun, 5 Jul 2026 08:26:24 +0200 Subject: [PATCH 50/68] Prepare Ledger corporate action security context Add a SECURITY_CONTEXT corporate action leg and a non-projecting native leg role so corporate action entries can carry depot-specific security context without reusing source or target movement semantics. This keeps the existing Security => Portfolio invariant meaningful for context rows and adds validation, descriptor, and XML/protobuf roundtrip coverage for non-economic context postings. This intentionally leaves full SEC_CTX profile rules, primary-movement one-of validation, BASIS, interest-detail semantics, UI, importers, schema changes, and legacy transaction mappings unchanged. --- ...ateActionMultiMovementPersistenceTest.java | 29 +++++++++++- .../model/ledger/LedgerModelTest.java | 6 +-- ...gerNativeEntryDefinitionValidatorTest.java | 46 +++++++++++++++++++ .../ledger/LedgerParameterCodeDomainTest.java | 9 ++-- .../ledger/LedgerStructuralValidatorTest.java | 46 +++++++++++++++++++ ...erivedProjectionDescriptorServiceTest.java | 39 ++++++++++++++++ .../configuration/CorporateActionLeg.java | 1 + .../LedgerEntryDefinitionRegistry.java | 5 ++ .../ledger/configuration/LedgerLegRole.java | 1 + .../LedgerNativeEntryDefinitionValidator.java | 2 + 10 files changed, 175 insertions(+), 9 deletions(-) diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java index 7d54901318..0677e4c2f4 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java @@ -84,7 +84,7 @@ public void testProtobufRoundtripPreservesRepeatedSpinOffMovementLegs() throws E assertThat(proto.getLedger().getEntriesCount(), is(1)); assertThat(protoEntry.getTypeCode(), is(LedgerEntryType.CORPORATE_ACTION.getCode())); - assertThat(protoEntry.getPostingsCount(), is(8)); + assertThat(protoEntry.getPostingsCount(), is(9)); assertNoCorporateActionSpecificLegacyTransactionType(proto); var loaded = ProtobufTestUtilities.load(bytes); @@ -102,6 +102,7 @@ private Fixture spinOffFixture() var portfolio = new Portfolio(); var sourceSecurity = new Security("Source AG", CurrencyUnit.EUR); var sourceSecurityB = new Security("Source B AG", CurrencyUnit.EUR); + var contextSecurity = new Security("Context AG", CurrencyUnit.EUR); var targetSecurityA = new Security("Target A AG", CurrencyUnit.EUR); var targetSecurityB = new Security("Target B AG", CurrencyUnit.EUR); @@ -113,6 +114,7 @@ private Fixture spinOffFixture() portfolio.setUpdatedAt(UPDATED_AT); sourceSecurity.setUpdatedAt(UPDATED_AT); sourceSecurityB.setUpdatedAt(UPDATED_AT); + contextSecurity.setUpdatedAt(UPDATED_AT); targetSecurityA.setUpdatedAt(UPDATED_AT); targetSecurityB.setUpdatedAt(UPDATED_AT); @@ -120,6 +122,7 @@ private Fixture spinOffFixture() client.addPortfolio(portfolio); client.addSecurity(sourceSecurity); client.addSecurity(sourceSecurityB); + client.addSecurity(contextSecurity); client.addSecurity(targetSecurityA); client.addSecurity(targetSecurityB); @@ -138,6 +141,7 @@ private Fixture spinOffFixture() entry.addPosting(spinOffSecurityPosting(portfolio, sourceSecurityB, sourceSecurityB, targetSecurityB, LedgerPostingDirection.OUTBOUND, CorporateActionLeg.SOURCE_SECURITY, "main", "source-2", 4, 40)); + entry.addPosting(securityContextPosting(portfolio, contextSecurity, "main", "context-1")); entry.addPosting(spinOffSecurityPosting(portfolio, targetSecurityA, sourceSecurity, targetSecurityA, LedgerPostingDirection.INBOUND, CorporateActionLeg.TARGET_SECURITY, "main", "target-1", 3, 60)); @@ -177,6 +181,18 @@ private LedgerPosting securityPosting(Portfolio portfolio, Security security, Le return posting; } + private LedgerPosting securityContextPosting(Portfolio portfolio, Security security, String groupKey, + String localKey) + { + var posting = securityPosting(portfolio, security, LedgerPostingDirection.NEUTRAL, + CorporateActionLeg.SECURITY_CONTEXT, groupKey, localKey, 0, 0); + + posting.addParameter(LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_LEG, + CorporateActionLeg.SECURITY_CONTEXT.getCode())); + + return posting; + } + private LedgerPosting spinOffSecurityPosting(Portfolio portfolio, Security security, Security sourceSecurity, Security targetSecurity, LedgerPostingDirection direction, CorporateActionLeg leg, String groupKey, String localKey, long shares, long amount) @@ -245,10 +261,12 @@ private LedgerPosting unitPosting(LedgerPostingType type, LedgerPostingSemanticR private void assertRepeatedSpinOff(LedgerEntry entry) { assertThat(entry.getType(), is(LedgerEntryType.CORPORATE_ACTION)); - assertThat(entry.getPostings().size(), is(8)); + assertThat(entry.getPostings().size(), is(9)); var sourceSecurities = postings(entry, LedgerPostingType.SECURITY, LedgerPostingDirection.OUTBOUND, CorporateActionLeg.SOURCE_SECURITY); + var securityContexts = postings(entry, LedgerPostingType.SECURITY, LedgerPostingDirection.NEUTRAL, + CorporateActionLeg.SECURITY_CONTEXT); var targetSecurities = postings(entry, LedgerPostingType.SECURITY, LedgerPostingDirection.INBOUND, CorporateActionLeg.TARGET_SECURITY); var cashMovements = postings(entry, LedgerPostingType.CASH_COMPENSATION, LedgerPostingDirection.NEUTRAL, @@ -263,6 +281,11 @@ private void assertRepeatedSpinOff(LedgerEntry entry) assertThat(sourceSecurities.size(), is(2)); assertThat(localKeys(sourceSecurities), is(Set.of("source-1", "source-2"))); + assertThat(securityContexts.size(), is(1)); + assertThat(localKeys(securityContexts), is(Set.of("context-1"))); + assertTrue(securityContexts.stream().allMatch(posting -> posting.getPortfolio() != null)); + assertTrue(securityContexts.stream().allMatch(posting -> posting.getSecurity() != null)); + assertFalse(securityContexts.stream().anyMatch(posting -> posting.getAccount() != null)); assertThat(targetSecurities.size(), is(2)); assertThat(localKeys(targetSecurities), is(Set.of("target-1", "target-2"))); assertThat(cashMovements.size(), is(2)); @@ -274,6 +297,8 @@ private void assertRepeatedSpinOff(LedgerEntry entry) assertThat(targetDescriptors.size(), is(2)); assertThat(targetDescriptors.stream().map(descriptor -> descriptor.getSemanticInstanceKey().orElseThrow()) .collect(Collectors.toSet()), is(Set.of("target-1", "target-2"))); + assertFalse(descriptors.stream().map(DerivedProjectionDescriptor::getPrimaryPosting) + .anyMatch(posting -> posting.getCorporateActionLeg() == CorporateActionLeg.SECURITY_CONTEXT)); var runtimeProjectionIds = targetDescriptors.stream().map(DerivedProjectionDescriptor::getRuntimeProjectionId) .collect(Collectors.toSet()); 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 index 9b74e369a3..4bcdb7d897 100644 --- 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 @@ -403,9 +403,9 @@ 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.SECURITY_CONTEXT, 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); diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java index 4ca9c11ee3..0bdfb9994a 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java @@ -16,6 +16,7 @@ import name.abuchen.portfolio.model.Portfolio; import name.abuchen.portfolio.model.Security; import name.abuchen.portfolio.model.ledger.configuration.CashCompensationKind; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; import name.abuchen.portfolio.model.ledger.configuration.CorporateActionKind; import name.abuchen.portfolio.model.ledger.configuration.CorporateActionSubtype; import name.abuchen.portfolio.model.ledger.configuration.EventStage; @@ -139,6 +140,29 @@ public void testMissingTargetSecurityLegIsAccepted() assertOK(entry); } + @Test + public void testSpinOffDefinitionAcceptsSecurityContextLeg() + { + var fixture = fixture(); + var entry = copyValidSpinOff(); + + entry.addPosting(securityContextPosting(fixture, "context-1")); //$NON-NLS-1$ + + assertOK(entry); + } + + @Test + public void testSpinOffDefinitionRejectsDuplicateSecurityContextLocalKey() + { + var fixture = fixture(); + var entry = copyValidSpinOff(); + + entry.addPosting(securityContextPosting(fixture, "context-1")); //$NON-NLS-1$ + entry.addPosting(securityContextPosting(fixture, "context-1")); //$NON-NLS-1$ + + assertIssue(entry, IssueCode.LEG_LOCAL_KEY_DUPLICATE); + } + /** * Checks that repeated source legs follow the same semantic-key policy as * repeated target and cash movement legs. @@ -507,6 +531,28 @@ private static NativeSecurityLeg.Builder targetLeg(Fixture fixture) .ratio(Ratio.of(BigDecimal.ONE, BigDecimal.valueOf(2))); } + private static LedgerPosting securityContextPosting(Fixture fixture, String localKey) + { + var posting = new LedgerPosting("security-context-" + localKey); //$NON-NLS-1$ + + posting.setType(LedgerPostingType.SECURITY); + posting.setPortfolio(fixture.portfolio); + posting.setSecurity(fixture.siemens); + posting.setAmount(0L); + posting.setShares(0L); + posting.setCurrency(CurrencyUnit.EUR); + posting.setSemanticRole(LedgerPostingSemanticRole.SECURITY); + posting.setDirection(LedgerPostingDirection.NEUTRAL); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + posting.setCorporateActionLeg(CorporateActionLeg.SECURITY_CONTEXT); + posting.setGroupKey("main"); //$NON-NLS-1$ + posting.setLocalKey(localKey); + posting.addParameter(LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_LEG, + CorporateActionLeg.SECURITY_CONTEXT.getCode())); + + return posting; + } + private static Money money(long amount) { return Money.of(CurrencyUnit.EUR, Values.Amount.factorize(amount)); 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 index c7383f1334..b45811fc4e 100644 --- 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 @@ -23,10 +23,11 @@ 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")), + List.of("SOURCE_SECURITY", "TARGET_SECURITY", "SECURITY_CONTEXT", + "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", 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 index 707bbc4d23..83236a17c0 100644 --- 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 @@ -2,6 +2,7 @@ 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; @@ -179,6 +180,41 @@ public void testNativeCorporateActionAcceptsPureCashPostingWithoutPortfolio() assertOK(LedgerStructuralValidator.validate(ledger(entry))); } + @Test + public void testNativeCorporateActionAcceptsSecurityContextWithPortfolio() + { + var entry = corporateActionEntry(CorporateActionKind.SPIN_OFF); + entry.addPosting(securityContextPosting("context-1")); //$NON-NLS-1$ + + assertOK(LedgerStructuralValidator.validate(ledger(entry))); + } + + @Test + public void testNativeCorporateActionSecurityContextRequiresPortfolio() + { + var entry = corporateActionEntry(CorporateActionKind.SPIN_OFF); + var context = securityContextPosting("context-1"); //$NON-NLS-1$ + + context.setPortfolio(null); + entry.addPosting(context); + + assertIssue(entry, IssueCode.POSTING_PORTFOLIO_REQUIRED_FOR_SECURITY); + } + + @Test + public void testNativeCorporateActionSecurityContextIsNotMovement() + { + var entry = corporateActionEntry(CorporateActionKind.SPIN_OFF); + + entry.addPosting(securityContextPosting("context-1")); //$NON-NLS-1$ + + assertFalse(entry.getPostings().stream() + .anyMatch(posting -> posting.getCorporateActionLeg() == CorporateActionLeg.SOURCE_SECURITY)); + assertFalse(entry.getPostings().stream() + .anyMatch(posting -> posting.getCorporateActionLeg() == CorporateActionLeg.TARGET_SECURITY)); + assertOK(LedgerStructuralValidator.validate(ledger(entry))); + } + @Test public void testNativeCorporateActionAllowsPortfolioWithoutSecurityForThisRule() { @@ -428,6 +464,16 @@ private LedgerPosting securityPosting(String localKey, LedgerPostingDirection di return posting; } + private LedgerPosting securityContextPosting(String localKey) + { + var posting = securityPosting(localKey, LedgerPostingDirection.NEUTRAL, CorporateActionLeg.SECURITY_CONTEXT); + + posting.setAmount(0L); + posting.setShares(0L); + + return posting; + } + private LedgerPosting cashCompensationPosting(String localKey) { var posting = accountPosting(localKey, LedgerPostingType.CASH_COMPENSATION, diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorServiceTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorServiceTest.java index af61192b92..527b5d8802 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorServiceTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorServiceTest.java @@ -193,6 +193,23 @@ public void testSiemensSpinOffDescriptorsDeriveTargetedRuntimeViews() LedgerProjectionRole.CASH_COMPENSATION); } + @Test + public void testSecurityContextDoesNotDeriveDescriptor() + { + var fixture = fixture(); + var entry = spinOffEntry(fixture); + + entry.addPosting(securityContextPosting("context-1", fixture.portfolio, fixture.siemens)); //$NON-NLS-1$ + + var descriptors = descriptors(entry); + + assertThat(roles(descriptors), is(Set.of(LedgerProjectionRole.OLD_SECURITY_LEG, + LedgerProjectionRole.DELIVERY_INBOUND, LedgerProjectionRole.NEW_SECURITY_LEG, + LedgerProjectionRole.CASH_COMPENSATION))); + assertFalse(descriptors.stream().map(DerivedProjectionDescriptor::getPrimaryPosting) + .anyMatch(posting -> posting.getCorporateActionLeg() == CorporateActionLeg.SECURITY_CONTEXT)); + } + @Test public void testExistingSpinOffDescriptorRuntimeIdRemainsRoleOnly() { @@ -453,6 +470,28 @@ private LedgerPosting repeatedPortfolioPosting(String uuid, Portfolio portfolio, return posting; } + private LedgerPosting securityContextPosting(String uuid, Portfolio portfolio, Security security) + { + var posting = new LedgerPosting(uuid); + + posting.setType(LedgerPostingType.SECURITY); + posting.setPortfolio(portfolio); + posting.setSecurity(security); + posting.setShares(0L); + posting.setAmount(0L); + posting.setCurrency(CurrencyUnit.EUR); + posting.setSemanticRole(LedgerPostingSemanticRole.SECURITY); + posting.setDirection(LedgerPostingDirection.NEUTRAL); + posting.setCorporateActionLeg(CorporateActionLeg.SECURITY_CONTEXT); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + posting.setGroupKey("main"); //$NON-NLS-1$ + posting.setLocalKey(uuid); + posting.addParameter(LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_LEG, + CorporateActionLeg.SECURITY_CONTEXT.getCode())); + + return posting; + } + private LedgerPosting accountPosting(String uuid, Account account, int amount, CorporateActionLeg leg, LedgerProjectionRole role) { 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 index cc00f249c2..db7562b923 100644 --- 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 @@ -15,6 +15,7 @@ public enum CorporateActionLeg implements LedgerCode { SOURCE_SECURITY("SOURCE_SECURITY"), TARGET_SECURITY("TARGET_SECURITY"), + SECURITY_CONTEXT("SECURITY_CONTEXT"), DISTRIBUTED_SECURITY("DISTRIBUTED_SECURITY"), RIGHT_SECURITY("RIGHT_SECURITY"), CASH_COMPENSATION("CASH_COMPENSATION"), 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 index d0ed82e8ec..9b1d2b89d5 100644 --- 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 @@ -548,6 +548,11 @@ private static Set spinOffLegDefinitions() LedgerParameterType.RATIO_DENOMINATOR)) .optionalParameters(spinOffTargetSecurityLegOptionalParameters()) .projection(LedgerProjectionRole.NEW_SECURITY_LEG, true, false).build(), + LedgerLegDefinition.of(LedgerLegRole.SECURITY_CONTEXT_LEG, LedgerPostingType.SECURITY, + LedgerLegCardinality.REPEATABLE) + .requiredParameters(SETS.parameterTypes( + LedgerParameterType.CORPORATE_ACTION_LEG)) + .optionalParameters(spinOffSecurityOptionalParameters()).build(), LedgerLegDefinition.of(LedgerLegRole.CASH_COMPENSATION_LEG, LedgerPostingType.CASH_COMPENSATION, LedgerLegCardinality.REPEATABLE) .optionalParameters(cashCompensationOptionalParameters()) 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 index d103a82e5e..4abc10098e 100644 --- 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 @@ -10,6 +10,7 @@ public enum LedgerLegRole { SOURCE_SECURITY_LEG, TARGET_SECURITY_LEG, + SECURITY_CONTEXT_LEG, RECEIVED_SECURITY_LEG, DISTRIBUTED_RIGHT_LEG, DISTRIBUTED_SECURITY_LEG, diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java index 488910adbd..62f65e64c0 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java @@ -529,6 +529,8 @@ private static Optional expectedCorporateActionLegCode(LedgerEntryType e return Optional.of(CorporateActionLeg.SOURCE_SECURITY.getCode()); if (role == LedgerLegRole.TARGET_SECURITY_LEG) return Optional.of(CorporateActionLeg.TARGET_SECURITY.getCode()); + if (role == LedgerLegRole.SECURITY_CONTEXT_LEG) + return Optional.of(CorporateActionLeg.SECURITY_CONTEXT.getCode()); if (role == LedgerLegRole.RECEIVED_SECURITY_LEG) return Optional.of(CorporateActionLeg.TARGET_SECURITY.getCode()); if (role == LedgerLegRole.DISTRIBUTED_SECURITY_LEG) From 2414ee832961642c4f7dd644b5cd5e4be264383e Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Sun, 5 Jul 2026 08:52:08 +0200 Subject: [PATCH 51/68] Register Ledger corporate action security context rules Register SECURITY_CONTEXT legs on the supported corporate action definitions using the existing native Ledger leg-cardinality model. This applies required context to must-profile kinds and optional context to should or optional profiles while keeping context non-projecting and depot-specific through Portfolio plus Security. This intentionally leaves PRIMARY_MOVEMENT one-of rules, BASIS, correction and reversal semantics, mandatory interest detail rules, Cash Dividend registration, schemas, and legacy PTransaction mappings unchanged. --- .../ledger/LedgerEntryDefinitionTest.java | 46 ++++ ...gerNativeEntryDefinitionValidatorTest.java | 204 +++++++++++++++++- .../LedgerProjectionMaterializerTest.java | 16 ++ .../ledger/LedgerSpinOffScenarioTest.java | 14 +- .../ledger/LedgerXmlPersistenceTest.java | 8 + ...dgerNativeComponentInspectorModelTest.java | 8 + .../LedgerNativeEntryAssemblerTest.java | 16 +- .../ClientPerformanceSnapshotTest.java | 12 +- .../LedgerEntryDefinitionRegistry.java | 40 ++-- .../ledger/nativeentry/NativeSecurityLeg.java | 5 + 10 files changed, 349 insertions(+), 20 deletions(-) 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 index cbd35c02eb..685ae406cd 100644 --- 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 @@ -280,6 +280,12 @@ public void testSpinOffDefinitionDescribesFunctionalLegs() assertTrue(targetLeg.isPrimaryPostingExpected()); assertFalse(targetLeg.isPostingGroupExpected()); + var contextLeg = assertLeg(definition, LedgerLegRole.SECURITY_CONTEXT_LEG, LedgerPostingType.SECURITY, + LedgerLegCardinality.AT_LEAST_ONE); + assertTrue(contextLeg.getRequiredParameterTypes().contains(LedgerParameterType.CORPORATE_ACTION_LEG)); + assertTrue(contextLeg.getProjectionRole().isEmpty()); + assertTrue(contextLeg.getGroupNames().isEmpty()); + var cashLeg = assertLeg(definition, LedgerLegRole.CASH_COMPENSATION_LEG, LedgerPostingType.CASH_COMPENSATION, LedgerLegCardinality.REPEATABLE); assertThat(cashLeg.getProjectionRole().orElseThrow(), is(LedgerProjectionRole.CASH_COMPENSATION)); @@ -331,20 +337,49 @@ public void testCorporateActionKindDefinitionsRegisterRepresentableProfileSubset .orElseThrow(); assertLeg(stockDividend, LedgerLegRole.TARGET_SECURITY_LEG, LedgerPostingType.SECURITY, LedgerLegCardinality.AT_LEAST_ONE); + assertLeg(stockDividend, LedgerLegRole.SECURITY_CONTEXT_LEG, LedgerPostingType.SECURITY, + LedgerLegCardinality.OPTIONAL); assertLeg(stockDividend, LedgerLegRole.CASH_COMPENSATION_LEG, LedgerPostingType.CASH_COMPENSATION, LedgerLegCardinality.REPEATABLE); assertTrue(stockDividend.getProjectionRoles().isEmpty()); + var spinOff = LedgerEntryDefinitionRegistry + .lookup(LedgerEntryType.CORPORATE_ACTION, CorporateActionKind.SPIN_OFF).orElseThrow(); + assertLeg(spinOff, LedgerLegRole.SECURITY_CONTEXT_LEG, LedgerPostingType.SECURITY, + LedgerLegCardinality.AT_LEAST_ONE); + + var bonusIssue = LedgerEntryDefinitionRegistry + .lookup(LedgerEntryType.CORPORATE_ACTION, CorporateActionKind.BONUS_ISSUE) + .orElseThrow(); + assertLeg(bonusIssue, LedgerLegRole.SECURITY_CONTEXT_LEG, LedgerPostingType.SECURITY, + LedgerLegCardinality.OPTIONAL); + + var rightsDistribution = LedgerEntryDefinitionRegistry + .lookup(LedgerEntryType.CORPORATE_ACTION, CorporateActionKind.RIGHTS_DISTRIBUTION) + .orElseThrow(); + assertLeg(rightsDistribution, LedgerLegRole.SECURITY_CONTEXT_LEG, LedgerPostingType.SECURITY, + LedgerLegCardinality.OPTIONAL); + var coupon = LedgerEntryDefinitionRegistry .lookup(LedgerEntryType.CORPORATE_ACTION, CorporateActionKind.COUPON_PAYMENT) .orElseThrow(); + assertLeg(coupon, LedgerLegRole.SECURITY_CONTEXT_LEG, LedgerPostingType.SECURITY, + LedgerLegCardinality.AT_LEAST_ONE); assertLeg(coupon, LedgerLegRole.CASH_LEG, LedgerPostingType.CASH, LedgerLegCardinality.AT_LEAST_ONE); assertLeg(coupon, LedgerLegRole.ACCRUED_INTEREST_LEG, LedgerPostingType.ACCRUED_INTEREST, LedgerLegCardinality.OPTIONAL); + var pikInterest = LedgerEntryDefinitionRegistry + .lookup(LedgerEntryType.CORPORATE_ACTION, CorporateActionKind.PIK_INTEREST) + .orElseThrow(); + assertLeg(pikInterest, LedgerLegRole.SECURITY_CONTEXT_LEG, LedgerPostingType.SECURITY, + LedgerLegCardinality.AT_LEAST_ONE); + var maturity = LedgerEntryDefinitionRegistry .lookup(LedgerEntryType.CORPORATE_ACTION, CorporateActionKind.MATURITY) .orElseThrow(); + assertLeg(maturity, LedgerLegRole.SECURITY_CONTEXT_LEG, LedgerPostingType.SECURITY, + LedgerLegCardinality.AT_LEAST_ONE); assertLeg(maturity, LedgerLegRole.SOURCE_SECURITY_LEG, LedgerPostingType.SECURITY, LedgerLegCardinality.AT_LEAST_ONE); assertLeg(maturity, LedgerLegRole.CASH_LEG, LedgerPostingType.CASH, LedgerLegCardinality.AT_LEAST_ONE); @@ -354,10 +389,21 @@ public void testCorporateActionKindDefinitionsRegisterRepresentableProfileSubset var conversion = LedgerEntryDefinitionRegistry .lookup(LedgerEntryType.CORPORATE_ACTION, CorporateActionKind.CONVERSION) .orElseThrow(); + assertLeg(conversion, LedgerLegRole.SECURITY_CONTEXT_LEG, LedgerPostingType.SECURITY, + LedgerLegCardinality.AT_LEAST_ONE); assertLeg(conversion, LedgerLegRole.SOURCE_SECURITY_LEG, LedgerPostingType.SECURITY, LedgerLegCardinality.AT_LEAST_ONE); assertLeg(conversion, LedgerLegRole.TARGET_SECURITY_LEG, LedgerPostingType.SECURITY, LedgerLegCardinality.AT_LEAST_ONE); + + for (var kind : new CorporateActionKind[] { CorporateActionKind.PARTIAL_REDEMPTION, + CorporateActionKind.CALL, CorporateActionKind.PUT, CorporateActionKind.EXCHANGE }) + { + var definition = LedgerEntryDefinitionRegistry.lookup(LedgerEntryType.CORPORATE_ACTION, kind) + .orElseThrow(); + assertLeg(definition, LedgerLegRole.SECURITY_CONTEXT_LEG, LedgerPostingType.SECURITY, + LedgerLegCardinality.AT_LEAST_ONE); + } } /** diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java index 0bdfb9994a..3cd3ac8dab 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java @@ -140,13 +140,23 @@ public void testMissingTargetSecurityLegIsAccepted() assertOK(entry); } + @Test + public void testSpinOffDefinitionRejectsMissingSecurityContextLeg() + { + var entry = copyValidSpinOff(); + + removePosting(entry, CorporateActionLeg.SECURITY_CONTEXT); + + assertIssue(entry, IssueCode.LEG_CARDINALITY_VIOLATED); + } + @Test public void testSpinOffDefinitionAcceptsSecurityContextLeg() { var fixture = fixture(); var entry = copyValidSpinOff(); - entry.addPosting(securityContextPosting(fixture, "context-1")); //$NON-NLS-1$ + entry.addPosting(securityContextPosting(fixture, "context-2")); //$NON-NLS-1$ assertOK(entry); } @@ -163,6 +173,79 @@ public void testSpinOffDefinitionRejectsDuplicateSecurityContextLocalKey() assertIssue(entry, IssueCode.LEG_LOCAL_KEY_DUPLICATE); } + @Test + public void testStockDividendDefinitionAcceptsMissingOptionalSecurityContextLeg() + { + var fixture = fixture(); + var entry = corporateActionEntry(CorporateActionKind.STOCK_DIVIDEND); + + entry.addPosting(targetSecurityPosting(fixture, "target-1")); //$NON-NLS-1$ + + assertOK(entry); + } + + @Test + public void testStockDividendDefinitionAcceptsSecurityContextLeg() + { + var fixture = fixture(); + var entry = corporateActionEntry(CorporateActionKind.STOCK_DIVIDEND); + + entry.addPosting(securityContextPosting(fixture, "context-1")); //$NON-NLS-1$ + entry.addPosting(targetSecurityPosting(fixture, "target-1")); //$NON-NLS-1$ + + assertOK(entry); + } + + @Test + public void testCouponPaymentDefinitionRequiresSecurityContextLeg() + { + var fixture = fixture(); + var entry = corporateActionEntry(CorporateActionKind.COUPON_PAYMENT); + + entry.addPosting(cashPosting(fixture, "cash-1")); //$NON-NLS-1$ + + assertIssue(entry, IssueCode.LEG_CARDINALITY_VIOLATED); + } + + @Test + public void testCouponPaymentDefinitionAcceptsSecurityContextAndCash() + { + var fixture = fixture(); + var entry = corporateActionEntry(CorporateActionKind.COUPON_PAYMENT); + + entry.addPosting(securityContextPosting(fixture, "context-1")); //$NON-NLS-1$ + entry.addPosting(cashPosting(fixture, "cash-1")); //$NON-NLS-1$ + + assertOK(entry); + } + + @Test + public void testMaturityDefinitionRequiresSecurityContextLeg() + { + var fixture = fixture(); + var entry = corporateActionEntry(CorporateActionKind.MATURITY); + + entry.addPosting(sourceSecurityPosting(fixture, "source-1")); //$NON-NLS-1$ + entry.addPosting(cashPosting(fixture, "cash-1")); //$NON-NLS-1$ + entry.addPosting(principalPosting(fixture, "principal-1")); //$NON-NLS-1$ + + assertIssue(entry, IssueCode.LEG_CARDINALITY_VIOLATED); + } + + @Test + public void testMaturityDefinitionAcceptsSecurityContextSourceCashAndPrincipal() + { + var fixture = fixture(); + var entry = corporateActionEntry(CorporateActionKind.MATURITY); + + entry.addPosting(securityContextPosting(fixture, "context-1")); //$NON-NLS-1$ + entry.addPosting(sourceSecurityPosting(fixture, "source-1")); //$NON-NLS-1$ + entry.addPosting(cashPosting(fixture, "cash-1")); //$NON-NLS-1$ + entry.addPosting(principalPosting(fixture, "principal-1")); //$NON-NLS-1$ + + assertOK(entry); + } + /** * Checks that repeated source legs follow the same semantic-key policy as * repeated target and cash movement legs. @@ -399,10 +482,12 @@ public void testMissingPostingGroupUUIDForCashCompensationProjectionIsRejected() public void testAssemblerBuildDetachedAcceptsPartialSpinOffEntry() { var fixture = fixture(); - var result = baseSpinOff(fixture).securityLeg(targetLeg(fixture).build()).buildDetached(); + var result = baseSpinOff(fixture) // + .securityLeg(contextLeg(fixture).build()) // + .securityLeg(targetLeg(fixture).build()).buildDetached(); assertTrue(result.getValidationResult().isOK()); - assertThat(result.getEntry().getPostings().size(), is(1)); + assertThat(result.getEntry().getPostings().size(), is(2)); } /** @@ -413,7 +498,9 @@ public void testAssemblerBuildDetachedAcceptsPartialSpinOffEntry() public void testAssemblerBuildAndAddAcceptsPartialSpinOffEntry() { var fixture = fixture(); - var result = baseSpinOff(fixture).securityLeg(targetLeg(fixture).build()).buildAndAdd(); + var result = baseSpinOff(fixture) // + .securityLeg(contextLeg(fixture).build()) // + .securityLeg(targetLeg(fixture).build()).buildAndAdd(); assertTrue(result.getValidationResult().isOK()); assertThat(fixture.client.getLedger().getEntries().size(), is(1)); @@ -460,6 +547,7 @@ private static LedgerNativeEntryAssembler.EntryBuilder validSpinOff(Fixture fixt { return baseSpinOff(fixture) // .securityLeg(sourceLeg(fixture).build()) // + .securityLeg(contextLeg(fixture).build()) // .securityLeg(targetLeg(fixture).build()) // .cashCompensation(NativeCashCompensation.builder() // .account(fixture.account) // @@ -531,6 +619,28 @@ private static NativeSecurityLeg.Builder targetLeg(Fixture fixture) .ratio(Ratio.of(BigDecimal.ONE, BigDecimal.valueOf(2))); } + private static NativeSecurityLeg.Builder contextLeg(Fixture fixture) + { + return NativeSecurityLeg.context() // + .portfolio(fixture.portfolio) // + .security(fixture.siemens) // + .amount(Money.of(CurrencyUnit.EUR, 0)) // + .shares(0L) // + .groupKey("main") // + .localKey("context-1"); + } + + private static LedgerEntry corporateActionEntry(CorporateActionKind kind) + { + var entry = new LedgerEntry("corporate-action-" + kind.getCode()); //$NON-NLS-1$ + + entry.setType(name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType.CORPORATE_ACTION); + entry.setDateTime(LocalDateTime.of(2020, 9, 28, 0, 0)); + entry.addParameter(LedgerParameter.ofCode(LedgerParameterType.CORPORATE_ACTION_KIND, kind)); + + return entry; + } + private static LedgerPosting securityContextPosting(Fixture fixture, String localKey) { var posting = new LedgerPosting("security-context-" + localKey); //$NON-NLS-1$ @@ -553,6 +663,92 @@ private static LedgerPosting securityContextPosting(Fixture fixture, String loca return posting; } + private static LedgerPosting targetSecurityPosting(Fixture fixture, String localKey) + { + var posting = securityPosting(fixture, localKey, LedgerPostingDirection.INBOUND, + CorporateActionLeg.TARGET_SECURITY, fixture.siemensEnergy); + + posting.addParameter(LedgerParameter.ofSecurity(LedgerParameterType.TARGET_SECURITY, + fixture.siemensEnergy)); + + return posting; + } + + private static LedgerPosting sourceSecurityPosting(Fixture fixture, String localKey) + { + var posting = securityPosting(fixture, localKey, LedgerPostingDirection.OUTBOUND, + CorporateActionLeg.SOURCE_SECURITY, fixture.siemens); + + posting.addParameter(LedgerParameter.ofSecurity(LedgerParameterType.SOURCE_SECURITY, fixture.siemens)); + + return posting; + } + + private static LedgerPosting securityPosting(Fixture fixture, String localKey, LedgerPostingDirection direction, + CorporateActionLeg leg, Security security) + { + var posting = new LedgerPosting("security-" + localKey); //$NON-NLS-1$ + + posting.setType(LedgerPostingType.SECURITY); + posting.setPortfolio(fixture.portfolio); + posting.setSecurity(security); + posting.setAmount(Values.Amount.factorize(1)); + posting.setShares(Values.Share.factorize(1)); + posting.setCurrency(CurrencyUnit.EUR); + posting.setSemanticRole(LedgerPostingSemanticRole.SECURITY); + posting.setDirection(direction); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + posting.setCorporateActionLeg(leg); + posting.setGroupKey("main"); //$NON-NLS-1$ + posting.setLocalKey(localKey); + posting.addParameter(LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_LEG, leg.getCode())); + + return posting; + } + + private static LedgerPosting cashPosting(Fixture fixture, String localKey) + { + var posting = new LedgerPosting("cash-" + localKey); //$NON-NLS-1$ + + posting.setType(LedgerPostingType.CASH); + posting.setAccount(fixture.account); + posting.setAmount(Values.Amount.factorize(1)); + posting.setCurrency(CurrencyUnit.EUR); + posting.setSemanticRole(LedgerPostingSemanticRole.CASH); + posting.setDirection(LedgerPostingDirection.NEUTRAL); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + posting.setGroupKey(localKey); + posting.setLocalKey(localKey); + + return posting; + } + + private static LedgerPosting principalPosting(Fixture fixture, String localKey) + { + var posting = new LedgerPosting("principal-" + localKey); //$NON-NLS-1$ + + posting.setType(LedgerPostingType.PRINCIPAL_REDEMPTION); + posting.setAccount(fixture.account); + posting.setAmount(Values.Amount.factorize(1)); + posting.setCurrency(CurrencyUnit.EUR); + posting.setSemanticRole(LedgerPostingSemanticRole.PRINCIPAL_REDEMPTION); + posting.setDirection(LedgerPostingDirection.NEUTRAL); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + posting.setCorporateActionLeg(CorporateActionLeg.PRINCIPAL); + posting.setGroupKey(localKey); + posting.setLocalKey(localKey); + posting.addParameter(LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_LEG, + CorporateActionLeg.PRINCIPAL.getCode())); + + return posting; + } + + private static void removePosting(LedgerEntry entry, CorporateActionLeg leg) + { + entry.getPostings().stream().filter(posting -> posting.getCorporateActionLeg() == leg).findFirst() + .ifPresent(entry::removePosting); + } + private static Money money(long amount) { return Money.of(CurrencyUnit.EUR, Values.Amount.factorize(amount)); diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerProjectionMaterializerTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerProjectionMaterializerTest.java index 46e33bebcd..c2c1b960c2 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerProjectionMaterializerTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerProjectionMaterializerTest.java @@ -152,6 +152,14 @@ public void testSiemensSpinOffMaterializesFromDerivedDescriptorsWithoutPersisted .targetSecurity(siemensEnergy) // .ratio(Ratio.of(BigDecimal.ONE, BigDecimal.valueOf(2))) // .build()) // + .securityLeg(NativeSecurityLeg.context() // + .portfolio(portfolio) // + .security(siemens) // + .shares(0L) // + .amount(money(0)) // + .groupKey("main") // + .localKey("context-1") // + .build()) // .securityLeg(NativeSecurityLeg.target() // .portfolio(portfolio) // .security(siemens) // @@ -802,6 +810,14 @@ private LedgerEntry repeatedSpinOffEntry(RepeatedSpinOffFixture fixture) .targetSecurity(fixture.firstTarget) // .ratio(Ratio.of(BigDecimal.ONE, BigDecimal.valueOf(2))) // .build()) // + .securityLeg(NativeSecurityLeg.context() // + .portfolio(fixture.portfolio) // + .security(fixture.source) // + .shares(0L) // + .amount(money(0)) // + .groupKey("main") // + .localKey("context-1") // + .build()) // .securityLeg(targetLeg(fixture, fixture.firstTarget, Values.Share.factorize(5), 50, "target-1")) // .securityLeg(targetLeg(fixture, fixture.secondTarget, Values.Share.factorize(7), 70, diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffScenarioTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffScenarioTest.java index 3555473b9e..7093467c84 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffScenarioTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffScenarioTest.java @@ -102,7 +102,7 @@ public void testCreatesTargetedSpinOffShape() var client = fixture.client(); var entry = spinOffEntry(client); - assertThat(entry.getPostings().size(), is(6)); + assertThat(entry.getPostings().size(), is(7)); assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).size(), is(4)); var oldSiemensOut = securityPosting(entry, fixture.siemens(), CorporateActionLeg.SOURCE_SECURITY.getCode(), fixture.siemensEnergy()); @@ -445,6 +445,14 @@ private LedgerEntry createSpinOffEntry(Client client, Account account, Portfolio .targetSecurity(siemensEnergy) .ratio(Ratio.of(BigDecimal.ONE, BigDecimal.valueOf(2))) .build()) + .securityLeg(NativeSecurityLeg.context() + .portfolio(portfolio) + .security(siemens) + .shares(0L) + .amount(Money.of(CurrencyUnit.EUR, 0L)) + .groupKey("main") + .localKey("context-1") + .build()) .securityLeg(NativeSecurityLeg.target() .portfolio(portfolio) .security(siemens) @@ -552,7 +560,9 @@ private void assertSpinOffScenarioClient(Client client) assertThat(client.getPortfolios().get(0).getReferenceAccount(), is(client.getAccounts().get(0))); assertThat(entry.getType(), is(LedgerEntryType.CORPORATE_ACTION)); assertThat(entry.getUpdatedAt(), is(UPDATED_AT)); - assertThat(entry.getPostings().size(), is(6)); + var hasSecurityContext = entry.getPostings().stream() + .anyMatch(posting -> posting.getCorporateActionLeg() == CorporateActionLeg.SECURITY_CONTEXT); + assertThat(entry.getPostings().size(), is(hasSecurityContext ? 7 : 6)); var oldSecurityLeg = securityPosting(entry, siemens(client), CorporateActionLeg.SOURCE_SECURITY.getCode(), siemensEnergy(client)); var retainedSecurityLeg = securityPosting(entry, siemens(client), CorporateActionLeg.TARGET_SECURITY.getCode(), diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerXmlPersistenceTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerXmlPersistenceTest.java index 6a07452924..0dd93c28b4 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerXmlPersistenceTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerXmlPersistenceTest.java @@ -870,6 +870,14 @@ private Client legacyLedgerParameterCompatibilityClient() .targetSecurity(targetSecurity) // .ratio(Ratio.of(BigDecimal.ONE, BigDecimal.valueOf(2))) // .build()) // + .securityLeg(NativeSecurityLeg.context() // + .portfolio(portfolio) // + .security(sourceSecurity) // + .shares(0L) // + .amount(money(0)) // + .groupKey("main") // + .localKey("context-1") // + .build()) // .securityLeg(NativeSecurityLeg.target() // .portfolio(portfolio) // .security(targetSecurity) // diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerNativeComponentInspectorModelTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerNativeComponentInspectorModelTest.java index 201bc5c98a..29f352ab8a 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerNativeComponentInspectorModelTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerNativeComponentInspectorModelTest.java @@ -129,6 +129,14 @@ public void testMaterializedNativeProjectionIsRecognizedAsNativeTargeted() .targetSecurity(fixture.targetSecurity()) .ratio(Ratio.of(BigDecimal.ONE, BigDecimal.TEN)) .build()) + .securityLeg(NativeSecurityLeg.context() + .portfolio(fixture.portfolio()) + .security(fixture.security()) + .shares(0L) + .amount(Money.of(CurrencyUnit.EUR, 0L)) + .groupKey("main") + .localKey("context-1") + .build()) .securityLeg(NativeSecurityLeg.target() .portfolio(fixture.portfolio()) .security(fixture.targetSecurity()) diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblerTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblerTest.java index 3a8c621e11..80f664c43c 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblerTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblerTest.java @@ -326,7 +326,7 @@ public void testBuildsDetachedMinimalSpinOff() var entry = result.getEntry(); assertThat(entry.getType(), is(LedgerEntryType.CORPORATE_ACTION)); - assertThat(entry.getPostings().size(), is(5)); + assertThat(entry.getPostings().size(), is(6)); assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).size(), is(3)); assertThat(fixture.client.getLedger().getEntries().size(), is(0)); assertTrue(result.getValidationResult().isOK()); @@ -660,6 +660,7 @@ private static LedgerNativeEntryAssembler.EntryBuilder validSpinOff(Fixture fixt { return baseSpinOff(fixture) // .securityLeg(sourceLeg(fixture).build()) // + .securityLeg(contextLeg(fixture).build()) // .securityLeg(targetLeg(fixture).build()) // .cashCompensation(NativeCashCompensation.builder() // .account(fixture.account) // @@ -694,6 +695,7 @@ private static LedgerNativeEntryAssembler.EntryBuilder spinOffWithRetainedAndNew { return baseSpinOff(fixture) // .securityLeg(sourceLeg(fixture).build()) // + .securityLeg(contextLeg(fixture).build()) // .securityLeg(targetLeg(fixture).projectAs(LedgerProjectionRole.DELIVERY_INBOUND).build()) // .securityLeg(targetLeg(fixture).projectAs(LedgerProjectionRole.NEW_SECURITY_LEG).build()) // .cashCompensation(NativeCashCompensation.builder() // @@ -713,6 +715,7 @@ private static LedgerNativeEntryAssembler.EntryBuilder repeatedSpinOff(Fixture f { return baseSpinOff(fixture) // .securityLeg(sourceLeg(fixture).build()) // + .securityLeg(contextLeg(fixture).build()) // .securityLeg(targetLeg(fixture).groupKey("main").localKey("target-1").build()) // .securityLeg(targetLeg(fixture) // .security(secondTarget) // @@ -796,6 +799,17 @@ private static NativeSecurityLeg.Builder targetLeg(Fixture fixture) .ratio(Ratio.of(BigDecimal.ONE, BigDecimal.valueOf(2))); } + private static NativeSecurityLeg.Builder contextLeg(Fixture fixture) + { + return NativeSecurityLeg.context() // + .portfolio(fixture.portfolio) // + .security(fixture.siemens) // + .shares(0L) // + .amount(money(0)) // + .groupKey("main") // + .localKey("context-1"); + } + private static LedgerParameter parameter(Collection> parameters, LedgerParameterType type) { return parameters.stream().filter(parameter -> parameter.getType() == type).findFirst().orElseThrow(); diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/snapshot/ClientPerformanceSnapshotTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/snapshot/ClientPerformanceSnapshotTest.java index 4e3824f300..02fd5e8c0b 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/snapshot/ClientPerformanceSnapshotTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/snapshot/ClientPerformanceSnapshotTest.java @@ -45,6 +45,7 @@ import name.abuchen.portfolio.model.ledger.nativeentry.NativeCorporateActionEvent; import name.abuchen.portfolio.model.ledger.nativeentry.NativeEntryMetadata; import name.abuchen.portfolio.model.ledger.nativeentry.NativeFee; +import name.abuchen.portfolio.model.ledger.nativeentry.NativeSecurityLeg; import name.abuchen.portfolio.model.ledger.nativeentry.NativeTax; import name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; @@ -337,12 +338,21 @@ public void testLedgerNativeDepositProjectionUnitsCountedAsFeesAndTaxes() { Client client = new Client(); Account account = account(); - client.addAccount(account); + Security security = new SecurityBuilder().addTo(client); + Portfolio portfolio = new PortfolioBuilder(account).addTo(client); LedgerNativeEntryAssembler.forClient(client).spinOff() .metadata(nativeMetadata()) .event(nativeEvent(LedgerEntryType.CORPORATE_ACTION)) + .securityLeg(NativeSecurityLeg.context() // + .portfolio(portfolio) // + .security(security) // + .amount(Money.of(CurrencyUnit.EUR, 0)) // + .shares(0L) // + .groupKey("main") // + .localKey("context-1") // + .build()) // .cashCompensation(NativeCashCompensation.builder() // .account(account) // .amount(Money.of(CurrencyUnit.EUR, 5_00)) // 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 index 9b1d2b89d5..3d1fd99e87 100644 --- 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 @@ -194,8 +194,9 @@ private static LedgerEntryDefinition spinOff() private static LedgerEntryDefinition stockDividend() { return corporateActionDefinition( - SETS.legDefinitions(targetSecurityLeg(LedgerLegCardinality.AT_LEAST_ONE), - cashCompensationLeg(), feeLeg(), taxLeg(), forexLeg()), + SETS.legDefinitions(securityContextLeg(LedgerLegCardinality.OPTIONAL), + targetSecurityLeg(LedgerLegCardinality.AT_LEAST_ONE), cashCompensationLeg(), + feeLeg(), taxLeg(), forexLeg()), LedgerReportingClass.SECURITIES_DISTRIBUTION, LedgerPerformanceTreatment.SECURITY_DISTRIBUTION); } @@ -203,8 +204,9 @@ private static LedgerEntryDefinition stockDividend() private static LedgerEntryDefinition bonusIssue() { return corporateActionDefinition( - SETS.legDefinitions(targetSecurityLeg(LedgerLegCardinality.AT_LEAST_ONE), - cashCompensationLeg(), feeLeg(), taxLeg(), forexLeg()), + SETS.legDefinitions(securityContextLeg(LedgerLegCardinality.OPTIONAL), + targetSecurityLeg(LedgerLegCardinality.AT_LEAST_ONE), cashCompensationLeg(), + feeLeg(), taxLeg(), forexLeg()), LedgerReportingClass.SECURITIES_DISTRIBUTION, LedgerPerformanceTreatment.SECURITY_DISTRIBUTION); } @@ -212,7 +214,8 @@ private static LedgerEntryDefinition bonusIssue() private static LedgerEntryDefinition rightsDistribution() { return corporateActionDefinition( - SETS.legDefinitions(distributedRightLeg(LedgerLegCardinality.AT_LEAST_ONE), + SETS.legDefinitions(securityContextLeg(LedgerLegCardinality.OPTIONAL), + distributedRightLeg(LedgerLegCardinality.AT_LEAST_ONE), cashCompensationLeg(), feeLeg(), taxLeg(), forexLeg()), LedgerReportingClass.RIGHTS_EVENT, LedgerPerformanceTreatment.SECURITY_DISTRIBUTION); @@ -221,7 +224,8 @@ private static LedgerEntryDefinition rightsDistribution() private static LedgerEntryDefinition couponPayment() { return corporateActionDefinition( - SETS.legDefinitions(cashLeg(LedgerLegCardinality.AT_LEAST_ONE), + SETS.legDefinitions(securityContextLeg(LedgerLegCardinality.AT_LEAST_ONE), + cashLeg(LedgerLegCardinality.AT_LEAST_ONE), accruedInterestLeg(LedgerLegCardinality.OPTIONAL), feeLeg(), taxLeg(), forexLeg()), LedgerReportingClass.FIXED_INCOME_COUPON, @@ -231,9 +235,10 @@ private static LedgerEntryDefinition couponPayment() private static LedgerEntryDefinition pikInterest() { return corporateActionDefinition( - SETS.legDefinitions(targetSecurityLeg(LedgerLegCardinality.AT_LEAST_ONE), - cashCompensationLeg(), accruedInterestLeg(LedgerLegCardinality.OPTIONAL), - feeLeg(), taxLeg(), forexLeg()), + SETS.legDefinitions(securityContextLeg(LedgerLegCardinality.AT_LEAST_ONE), + targetSecurityLeg(LedgerLegCardinality.AT_LEAST_ONE), cashCompensationLeg(), + accruedInterestLeg(LedgerLegCardinality.OPTIONAL), feeLeg(), taxLeg(), + forexLeg()), LedgerReportingClass.FIXED_INCOME_COUPON, LedgerPerformanceTreatment.INCOME_DISTRIBUTION); } @@ -261,7 +266,8 @@ private static LedgerEntryDefinition put() private static LedgerEntryDefinition fixedIncomeRedemption() { return corporateActionDefinition( - SETS.legDefinitions(sourceSecurityLeg(LedgerLegCardinality.AT_LEAST_ONE), + SETS.legDefinitions(securityContextLeg(LedgerLegCardinality.AT_LEAST_ONE), + sourceSecurityLeg(LedgerLegCardinality.AT_LEAST_ONE), cashLeg(LedgerLegCardinality.AT_LEAST_ONE), principalRedemptionLeg(LedgerLegCardinality.AT_LEAST_ONE), accruedInterestLeg(LedgerLegCardinality.OPTIONAL), feeLeg(), taxLeg(), @@ -283,7 +289,8 @@ private static LedgerEntryDefinition exchange() private static LedgerEntryDefinition securityReorganization() { return corporateActionDefinition( - SETS.legDefinitions(sourceSecurityLeg(LedgerLegCardinality.AT_LEAST_ONE), + SETS.legDefinitions(securityContextLeg(LedgerLegCardinality.AT_LEAST_ONE), + sourceSecurityLeg(LedgerLegCardinality.AT_LEAST_ONE), targetSecurityLeg(LedgerLegCardinality.AT_LEAST_ONE), cashCompensationLeg(), accruedInterestLeg(LedgerLegCardinality.OPTIONAL), feeLeg(), taxLeg(), @@ -417,6 +424,15 @@ private static LedgerLegDefinition targetSecurityLeg(LedgerLegCardinality cardin .build(); } + private static LedgerLegDefinition securityContextLeg(LedgerLegCardinality cardinality) + { + return LedgerLegDefinition.of(LedgerLegRole.SECURITY_CONTEXT_LEG, LedgerPostingType.SECURITY, + cardinality) + .requiredParameters(SETS.parameterTypes(LedgerParameterType.CORPORATE_ACTION_LEG)) + .optionalParameters(corporateActionPostingOptionalParametersFor(LedgerPostingType.SECURITY)) + .build(); + } + private static LedgerLegDefinition distributedRightLeg(LedgerLegCardinality cardinality) { return LedgerLegDefinition.of(LedgerLegRole.DISTRIBUTED_RIGHT_LEG, LedgerPostingType.RIGHT, @@ -549,7 +565,7 @@ private static Set spinOffLegDefinitions() .optionalParameters(spinOffTargetSecurityLegOptionalParameters()) .projection(LedgerProjectionRole.NEW_SECURITY_LEG, true, false).build(), LedgerLegDefinition.of(LedgerLegRole.SECURITY_CONTEXT_LEG, LedgerPostingType.SECURITY, - LedgerLegCardinality.REPEATABLE) + LedgerLegCardinality.AT_LEAST_ONE) .requiredParameters(SETS.parameterTypes( LedgerParameterType.CORPORATE_ACTION_LEG)) .optionalParameters(spinOffSecurityOptionalParameters()).build(), diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeSecurityLeg.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeSecurityLeg.java index 91bf00c59b..829b727940 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeSecurityLeg.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeSecurityLeg.java @@ -61,6 +61,11 @@ public static Builder target() LedgerProjectionRole.NEW_SECURITY_LEG); } + public static Builder context() + { + return new Builder(LedgerPostingType.SECURITY, CorporateActionLeg.SECURITY_CONTEXT.getCode(), null); + } + static Builder ofType(LedgerPostingType postingType) { return new Builder(postingType, CorporateActionLeg.SOURCE_SECURITY.getCode(), From 7af5c56c5c590ac07b956fc1171aa663566ff0ca Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Sun, 5 Jul 2026 09:05:53 +0200 Subject: [PATCH 52/68] Validate Ledger corporate action registry configuration Align the supported Ledger corporate action subset with Registry.md by requiring a Spin-off target/security-in leg and by documenting overview-only cash distribution names as intentionally absent from the current code domain. This keeps the Java registry honest about what is currently representable while preserving the kind-only registrations for profiles blocked by primary-movement, basis, correction, reversal, and interest-detail semantics. No XML or protobuf schema, legacy transaction type mapping, descriptor/materializer behavior, assembler API, UUID selector, projection membership, or Markdown/Registry.md inputs were changed. --- .../portfolio/model/ledger/LedgerCodeTest.java | 2 ++ .../model/ledger/LedgerEntryDefinitionTest.java | 13 ++++++++++++- .../LedgerNativeEntryDefinitionValidatorTest.java | 8 ++++---- .../snapshot/ClientPerformanceSnapshotTest.java | 12 ++++++++++++ .../LedgerEntryDefinitionRegistry.java | 2 +- 5 files changed, 31 insertions(+), 6 deletions(-) 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 index 242a9b0ef8..fefcce5d01 100644 --- 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 @@ -113,6 +113,8 @@ public void testCorporateActionKindsClassifyGenericNativeEntryType() CorporateActionKind.DEFAULT))); assertThat(CorporateActionKind.fromCode("SPIN_OFF").orElseThrow(), is(CorporateActionKind.SPIN_OFF)); assertThat(CorporateActionKind.fromCode("MATURITY").orElseThrow(), is(CorporateActionKind.MATURITY)); + assertTrue(CorporateActionKind.fromCode("CASH_DISTRIBUTION").isEmpty()); + assertTrue(CorporateActionKind.fromCode("CASH_DIVIDEND").isEmpty()); assertTrue(CorporateActionKind.fromCode("OTHER").isEmpty()); } 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 index 685ae406cd..d26fb9e972 100644 --- 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 @@ -270,7 +270,7 @@ public void testSpinOffDefinitionDescribesFunctionalLegs() assertFalse(sourceLeg.isPostingGroupExpected()); var targetLeg = assertLeg(definition, LedgerLegRole.TARGET_SECURITY_LEG, LedgerPostingType.SECURITY, - LedgerLegCardinality.REPEATABLE); + LedgerLegCardinality.AT_LEAST_ONE); assertTrue(targetLeg.getRequiredParameterTypes().contains(LedgerParameterType.CORPORATE_ACTION_LEG)); assertTrue(targetLeg.getRequiredParameterTypes().contains(LedgerParameterType.TARGET_SECURITY)); assertTrue(targetLeg.getRequiredParameterTypes().contains(LedgerParameterType.RATIO_NUMERATOR)); @@ -332,6 +332,11 @@ public void testCorporateActionKindDefinitionsRegisterRepresentableProfileSubset assertFalse(kind.name(), LedgerEntryDefinitionRegistry .lookup(LedgerEntryType.CORPORATE_ACTION, kind).isPresent()); + assertTrue("CASH_DISTRIBUTION is overview-only in Registry.md", + CorporateActionKind.fromCode("CASH_DISTRIBUTION").isEmpty()); + assertTrue("CASH_DIVIDEND is overview-only in Registry.md", + CorporateActionKind.fromCode("CASH_DIVIDEND").isEmpty()); + var stockDividend = LedgerEntryDefinitionRegistry .lookup(LedgerEntryType.CORPORATE_ACTION, CorporateActionKind.STOCK_DIVIDEND) .orElseThrow(); @@ -347,6 +352,12 @@ public void testCorporateActionKindDefinitionsRegisterRepresentableProfileSubset .lookup(LedgerEntryType.CORPORATE_ACTION, CorporateActionKind.SPIN_OFF).orElseThrow(); assertLeg(spinOff, LedgerLegRole.SECURITY_CONTEXT_LEG, LedgerPostingType.SECURITY, LedgerLegCardinality.AT_LEAST_ONE); + assertLeg(spinOff, LedgerLegRole.SOURCE_SECURITY_LEG, LedgerPostingType.SECURITY, + LedgerLegCardinality.REPEATABLE); + assertLeg(spinOff, LedgerLegRole.TARGET_SECURITY_LEG, LedgerPostingType.SECURITY, + LedgerLegCardinality.AT_LEAST_ONE); + assertLeg(spinOff, LedgerLegRole.CASH_COMPENSATION_LEG, LedgerPostingType.CASH_COMPENSATION, + LedgerLegCardinality.REPEATABLE); var bonusIssue = LedgerEntryDefinitionRegistry .lookup(LedgerEntryType.CORPORATE_ACTION, CorporateActionKind.BONUS_ISSUE) diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java index 3cd3ac8dab..fbc496caa7 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java @@ -126,18 +126,18 @@ public void testMissingSourceSecurityLegIsAccepted() } /** - * Checks that target security legs are optional after the spin-off cardinality cleanup. - * Missing movement rows no longer violate the configured native definition. + * Checks that Registry.md requires at least one received spin-off security. + * Missing target movement rows violate the supported native definition subset. */ @Test - public void testMissingTargetSecurityLegIsAccepted() + public void testMissingTargetSecurityLegIsRejected() { var entry = copyValidSpinOff(); var targetPosting = postingFor(entry, LedgerProjectionRole.NEW_SECURITY_LEG); entry.removePosting(targetPosting); - assertOK(entry); + assertIssue(entry, IssueCode.LEG_CARDINALITY_VIOLATED); } @Test diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/snapshot/ClientPerformanceSnapshotTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/snapshot/ClientPerformanceSnapshotTest.java index 02fd5e8c0b..e0dce02363 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/snapshot/ClientPerformanceSnapshotTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/snapshot/ClientPerformanceSnapshotTest.java @@ -47,6 +47,7 @@ import name.abuchen.portfolio.model.ledger.nativeentry.NativeFee; import name.abuchen.portfolio.model.ledger.nativeentry.NativeSecurityLeg; import name.abuchen.portfolio.model.ledger.nativeentry.NativeTax; +import name.abuchen.portfolio.model.ledger.nativeentry.Ratio; import name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor; import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionService; @@ -353,6 +354,17 @@ public void testLedgerNativeDepositProjectionUnitsCountedAsFeesAndTaxes() .groupKey("main") // .localKey("context-1") // .build()) // + .securityLeg(NativeSecurityLeg.target() // + .portfolio(portfolio) // + .security(security) // + .shares(0L) // + .amount(Money.of(CurrencyUnit.EUR, 0)) // + .sourceSecurity(security) // + .targetSecurity(security) // + .ratio(Ratio.of(BigDecimal.ONE, BigDecimal.ONE)) // + .groupKey("main") // + .localKey("target-1") // + .build()) // .cashCompensation(NativeCashCompensation.builder() // .account(account) // .amount(Money.of(CurrencyUnit.EUR, 5_00)) // 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 index 3d1fd99e87..95f07e1f3b 100644 --- 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 @@ -556,7 +556,7 @@ private static Set spinOffLegDefinitions() .optionalParameters(spinOffSourceSecurityLegOptionalParameters()) .projection(LedgerProjectionRole.OLD_SECURITY_LEG, true, false).build(), LedgerLegDefinition.of(LedgerLegRole.TARGET_SECURITY_LEG, LedgerPostingType.SECURITY, - LedgerLegCardinality.REPEATABLE) + LedgerLegCardinality.AT_LEAST_ONE) .requiredParameters(SETS.parameterTypes( LedgerParameterType.CORPORATE_ACTION_LEG, LedgerParameterType.TARGET_SECURITY, From 5dd6e4554a7d0a70dbdda8fc5807a0157c3d4d31 Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Sun, 5 Jul 2026 09:47:53 +0200 Subject: [PATCH 53/68] Add Ledger corporate action primary movement validation Added configurable primary movement requirement predicates for native Ledger corporate action definitions and wired them into native definition validation. This lets DEFAULTED_INTEREST, RESTRUCTURING, and DEFAULT require at least one real booked movement while excluding context, forex, valuation metadata, correction, reversal, and basis facts. This intentionally leaves UI, importer, report, tax, performance, BASIS, correction/reversal, interest-detail, legacy transaction mapping, and persistence schema behavior unchanged. --- ...ateActionMultiMovementPersistenceTest.java | 90 +++++++ .../ledger/LedgerEntryDefinitionTest.java | 66 ++++- ...gerNativeEntryDefinitionValidatorTest.java | 225 ++++++++++++++++++ .../LedgerEntryDefinitionRegistry.java | 72 +++++- .../LedgerNativeEntryDefinitionValidator.java | 12 +- .../rule/LedgerPrimaryMovement.java | 44 ++++ .../rule/LedgerRequirementGroup.java | 38 ++- 7 files changed, 536 insertions(+), 11 deletions(-) create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/rule/LedgerPrimaryMovement.java diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java index 0677e4c2f4..856d404133 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java @@ -95,6 +95,30 @@ public void testProtobufRoundtripPreservesRepeatedSpinOffMovementLegs() throws E assertNativeDefinitionValid(loadedEntry); } + @Test + public void testXmlAndProtobufRoundtripPreserveDefaultedInterestPrimaryMovement() throws Exception + { + var fixture = defaultedInterestFixture(); + + assertDefaultedInterestPrimaryMovement(fixture.entry()); + assertValid(fixture.client()); + assertNativeDefinitionValid(fixture.entry()); + + var loadedFromXml = loadXml(saveXml(fixture.client())); + var loadedXmlEntry = onlyLedgerEntry(loadedFromXml); + + assertDefaultedInterestPrimaryMovement(loadedXmlEntry); + assertValid(loadedFromXml); + assertNativeDefinitionValid(loadedXmlEntry); + + var loadedFromProto = ProtobufTestUtilities.load(ProtobufTestUtilities.save(fixture.client())); + var loadedProtoEntry = onlyLedgerEntry(loadedFromProto); + + assertDefaultedInterestPrimaryMovement(loadedProtoEntry); + assertValid(loadedFromProto); + assertNativeDefinitionValid(loadedProtoEntry); + } + private Fixture spinOffFixture() { var client = new Client(); @@ -160,6 +184,39 @@ private Fixture spinOffFixture() return new Fixture(client, entry); } + private Fixture defaultedInterestFixture() + { + var client = new Client(); + var account = new Account(); + var portfolio = new Portfolio(); + var contextSecurity = new Security("Defaulted Bond", CurrencyUnit.EUR); + + account.setName("Cash Account"); + account.setCurrencyCode(CurrencyUnit.EUR); + portfolio.setName("Portfolio"); + portfolio.setReferenceAccount(account); + account.setUpdatedAt(UPDATED_AT); + portfolio.setUpdatedAt(UPDATED_AT); + contextSecurity.setUpdatedAt(UPDATED_AT); + + client.addAccount(account); + client.addPortfolio(portfolio); + client.addSecurity(contextSecurity); + + var entry = new LedgerEntry("defaulted-interest-primary-movement"); + entry.setType(LedgerEntryType.CORPORATE_ACTION); + entry.setDateTime(DATE_TIME); + entry.setSource("DEFAULTED_INTEREST primary movement proof"); + entry.addParameter(LedgerParameter.ofCode(LedgerParameterType.CORPORATE_ACTION_KIND, + CorporateActionKind.DEFAULTED_INTEREST)); + entry.addPosting(securityContextPosting(portfolio, contextSecurity, "main", "context-1")); + entry.addPosting(cashPrimaryPosting(account, "cash-1", 33)); + + client.getLedger().addEntry(entry); + + return new Fixture(client, entry); + } + private LedgerPosting securityPosting(Portfolio portfolio, Security security, LedgerPostingDirection direction, CorporateActionLeg leg, String groupKey, String localKey, long shares, long amount) { @@ -239,6 +296,23 @@ private LedgerPosting cashPosting(Account account, LedgerPostingDirection direct return posting; } + private LedgerPosting cashPrimaryPosting(Account account, String localKey, long amount) + { + var posting = new LedgerPosting("proof-" + localKey); + + posting.setType(LedgerPostingType.CASH); + posting.setAccount(account); + posting.setAmount(Values.Amount.factorize(amount)); + posting.setCurrency(CurrencyUnit.EUR); + posting.setSemanticRole(LedgerPostingSemanticRole.CASH); + posting.setDirection(LedgerPostingDirection.NEUTRAL); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + posting.setGroupKey(localKey); + posting.setLocalKey(localKey); + + return posting; + } + private LedgerPosting unitPosting(LedgerPostingType type, LedgerPostingSemanticRole semanticRole, LedgerPostingUnitRole unitRole, CorporateActionLeg leg, String groupKey, String localKey, long amount) @@ -307,6 +381,22 @@ private void assertRepeatedSpinOff(LedgerEntry entry) assertTrue(runtimeProjectionIds.stream().anyMatch(id -> id.endsWith(":NEW_SECURITY_LEG:target-2"))); } + private void assertDefaultedInterestPrimaryMovement(LedgerEntry entry) + { + assertThat(entry.getType(), is(LedgerEntryType.CORPORATE_ACTION)); + assertTrue(entry.getParameters().stream() + .anyMatch(parameter -> parameter.getType() == LedgerParameterType.CORPORATE_ACTION_KIND + && CorporateActionKind.DEFAULTED_INTEREST.getCode() + .equals(parameter.getValue()))); + assertThat(postings(entry, LedgerPostingType.SECURITY, LedgerPostingDirection.NEUTRAL, + CorporateActionLeg.SECURITY_CONTEXT).size(), is(1)); + assertThat(entry.getPostings().stream() + .filter(posting -> posting.getType() == LedgerPostingType.CASH) + .filter(posting -> "cash-1".equals(posting.getLocalKey())) //$NON-NLS-1$ + .count(), is(1L)); + assertTrue(LedgerDescriptorTestSupport.descriptors(entry).isEmpty()); + } + private List postings(LedgerEntry entry, LedgerPostingType type, LedgerPostingDirection direction, CorporateActionLeg leg) { 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 index d26fb9e972..24ad381b62 100644 --- 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 @@ -28,6 +28,7 @@ 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.LedgerPrimaryMovement; import name.abuchen.portfolio.model.ledger.configuration.rule.LedgerProjectionRule; import name.abuchen.portfolio.model.ledger.configuration.rule.LedgerRequirement; import name.abuchen.portfolio.money.CurrencyUnit; @@ -153,11 +154,10 @@ public void testAlternativeRequirementGroupsAreRealAlternatives() { for (var group : definition.getAlternativeRequirementGroups()) { - var numberOfAlternatives = group.getPostingTypes().size() + group.getParameterTypes().size(); + var numberOfAlternatives = group.getPostingTypes().size() + group.getParameterTypes().size() + + group.getPrimaryMovements().size(); assertTrue(definition.getEntryType() + ":" + group.getName(), numberOfAlternatives >= 2); - assertTrue(definition.getEntryType() + ":" + group.getName(), - group.getPostingTypes().isEmpty() != group.getParameterTypes().isEmpty()); } } } @@ -329,7 +329,7 @@ public void testCorporateActionKindDefinitionsRegisterRepresentableProfileSubset for (var kind : new CorporateActionKind[] { CorporateActionKind.DEFAULTED_INTEREST, CorporateActionKind.RESTRUCTURING, CorporateActionKind.DEFAULT }) - assertFalse(kind.name(), LedgerEntryDefinitionRegistry + assertTrue(kind.name(), LedgerEntryDefinitionRegistry .lookup(LedgerEntryType.CORPORATE_ACTION, kind).isPresent()); assertTrue("CASH_DISTRIBUTION is overview-only in Registry.md", @@ -415,6 +415,43 @@ public void testCorporateActionKindDefinitionsRegisterRepresentableProfileSubset assertLeg(definition, LedgerLegRole.SECURITY_CONTEXT_LEG, LedgerPostingType.SECURITY, LedgerLegCardinality.AT_LEAST_ONE); } + + var defaultedInterest = LedgerEntryDefinitionRegistry + .lookup(LedgerEntryType.CORPORATE_ACTION, CorporateActionKind.DEFAULTED_INTEREST) + .orElseThrow(); + assertLeg(defaultedInterest, LedgerLegRole.SECURITY_CONTEXT_LEG, LedgerPostingType.SECURITY, + LedgerLegCardinality.AT_LEAST_ONE); + assertLeg(defaultedInterest, LedgerLegRole.TARGET_SECURITY_LEG, LedgerPostingType.SECURITY, + LedgerLegCardinality.REPEATABLE); + assertPrimaryMovementGroup(defaultedInterest, "DEFAULTED_INTEREST_PRIMARY_MOVEMENT", + LedgerPrimaryMovement.CASH, LedgerPrimaryMovement.TARGET_SECURITY, LedgerPrimaryMovement.FEE, + LedgerPrimaryMovement.TAX); + + var restructuring = LedgerEntryDefinitionRegistry + .lookup(LedgerEntryType.CORPORATE_ACTION, CorporateActionKind.RESTRUCTURING) + .orElseThrow(); + assertLeg(restructuring, LedgerLegRole.SECURITY_CONTEXT_LEG, LedgerPostingType.SECURITY, + LedgerLegCardinality.OPTIONAL); + assertLeg(restructuring, LedgerLegRole.SOURCE_SECURITY_LEG, LedgerPostingType.SECURITY, + LedgerLegCardinality.REPEATABLE); + assertLeg(restructuring, LedgerLegRole.TARGET_SECURITY_LEG, LedgerPostingType.SECURITY, + LedgerLegCardinality.REPEATABLE); + assertLeg(restructuring, LedgerLegRole.PRINCIPAL_REDEMPTION_LEG, LedgerPostingType.PRINCIPAL_REDEMPTION, + LedgerLegCardinality.REPEATABLE); + assertLeg(restructuring, LedgerLegRole.ACCRUED_INTEREST_LEG, LedgerPostingType.ACCRUED_INTEREST, + LedgerLegCardinality.REPEATABLE); + assertPrimaryMovementGroup(restructuring, "RESTRUCTURING_PRIMARY_MOVEMENT", + LedgerPrimaryMovement.SOURCE_SECURITY, LedgerPrimaryMovement.TARGET_SECURITY, + LedgerPrimaryMovement.CASH, LedgerPrimaryMovement.PRINCIPAL_REDEMPTION, + LedgerPrimaryMovement.ACCRUED_INTEREST); + + var defaultAction = LedgerEntryDefinitionRegistry + .lookup(LedgerEntryType.CORPORATE_ACTION, CorporateActionKind.DEFAULT).orElseThrow(); + assertLeg(defaultAction, LedgerLegRole.SECURITY_CONTEXT_LEG, LedgerPostingType.SECURITY, + LedgerLegCardinality.AT_LEAST_ONE); + assertPrimaryMovementGroup(defaultAction, "DEFAULT_PRIMARY_MOVEMENT", + LedgerPrimaryMovement.SOURCE_SECURITY, LedgerPrimaryMovement.TARGET_SECURITY, + LedgerPrimaryMovement.CASH, LedgerPrimaryMovement.FEE, LedgerPrimaryMovement.TAX); } /** @@ -628,6 +665,27 @@ private void assertAlternativeGroup( assertTrue(name, false); } + private void assertPrimaryMovementGroup( + name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinition definition, String name, + LedgerPrimaryMovement first, LedgerPrimaryMovement... rest) + { + var expected = EnumSet.of(first, rest); + + for (var group : definition.getAlternativeRequirementGroups()) + { + if (group.getName().equals(name)) + { + assertThat(group.getRequirement(), is(LedgerRequirement.REQUIRED)); + assertThat(group.getPrimaryMovements(), is(expected)); + assertTrue(group.getPostingTypes().isEmpty()); + assertTrue(group.getParameterTypes().isEmpty()); + return; + } + } + + assertTrue(name, false); + } + private boolean hasPostingRule(Iterable rules, LedgerPostingType postingType) { for (var rule : rules) diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java index fbc496caa7..8aa767db84 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java @@ -246,6 +246,143 @@ public void testMaturityDefinitionAcceptsSecurityContextSourceCashAndPrincipal() assertOK(entry); } + @Test + public void testDefaultedInterestRequiresPrimaryMovementBeyondContext() + { + var fixture = fixture(); + var entry = corporateActionEntry(CorporateActionKind.DEFAULTED_INTEREST); + + entry.addPosting(securityContextPosting(fixture, "context-1")); //$NON-NLS-1$ + + assertIssue(entry, IssueCode.REQUIRED_PRIMARY_MOVEMENT_MISSING); + } + + @Test + public void testDefaultedInterestAcceptsCashPrimaryMovement() + { + var fixture = fixture(); + var entry = corporateActionEntry(CorporateActionKind.DEFAULTED_INTEREST); + + entry.addPosting(securityContextPosting(fixture, "context-1")); //$NON-NLS-1$ + entry.addPosting(cashPosting(fixture, "cash-1")); //$NON-NLS-1$ + + assertOK(entry); + } + + @Test + public void testDefaultedInterestAcceptsFeeOrTaxPrimaryMovement() + { + var fixture = fixture(); + var feeEntry = corporateActionEntry(CorporateActionKind.DEFAULTED_INTEREST); + var taxEntry = corporateActionEntry(CorporateActionKind.DEFAULTED_INTEREST); + + feeEntry.addPosting(securityContextPosting(fixture, "context-1")); //$NON-NLS-1$ + feeEntry.addPosting(feePosting(fixture, "fee-1")); //$NON-NLS-1$ + taxEntry.addPosting(securityContextPosting(fixture, "context-1")); //$NON-NLS-1$ + taxEntry.addPosting(taxPosting(fixture, "tax-1")); //$NON-NLS-1$ + + assertOK(feeEntry); + assertOK(taxEntry); + } + + @Test + public void testDefaultedInterestAcceptsClaimSecurityPrimaryMovement() + { + var fixture = fixture(); + var entry = corporateActionEntry(CorporateActionKind.DEFAULTED_INTEREST); + + entry.addPosting(securityContextPosting(fixture, "context-1")); //$NON-NLS-1$ + entry.addPosting(targetSecurityPosting(fixture, "target-1")); //$NON-NLS-1$ + + assertOK(entry); + } + + @Test + public void testDefaultedInterestRejectsBasisLikeMetadataAsPrimaryMovement() + { + var fixture = fixture(); + var entry = corporateActionEntry(CorporateActionKind.DEFAULTED_INTEREST); + var context = securityContextPosting(fixture, "context-1"); //$NON-NLS-1$ + + context.addParameter(LedgerParameter.ofMoney(LedgerParameterType.FAIR_MARKET_VALUE, money(100))); + entry.addPosting(context); + + assertIssue(entry, IssueCode.REQUIRED_PRIMARY_MOVEMENT_MISSING); + } + + @Test + public void testRestructuringRequiresPrimaryMovementBeyondContext() + { + var fixture = fixture(); + var entry = corporateActionEntry(CorporateActionKind.RESTRUCTURING); + + entry.addPosting(securityContextPosting(fixture, "context-1")); //$NON-NLS-1$ + + assertIssue(entry, IssueCode.REQUIRED_PRIMARY_MOVEMENT_MISSING); + } + + @Test + public void testRestructuringAcceptsSecurityCashPrincipalOrInterestPrimaryMovement() + { + var fixture = fixture(); + + assertOK(corporateActionEntry(CorporateActionKind.RESTRUCTURING, + sourceSecurityPosting(fixture, "source-1"))); //$NON-NLS-1$ + assertOK(corporateActionEntry(CorporateActionKind.RESTRUCTURING, + targetSecurityPosting(fixture, "target-1"))); //$NON-NLS-1$ + assertOK(corporateActionEntry(CorporateActionKind.RESTRUCTURING, cashPosting(fixture, "cash-1"))); //$NON-NLS-1$ + assertOK(corporateActionEntry(CorporateActionKind.RESTRUCTURING, + principalPosting(fixture, "principal-1"))); //$NON-NLS-1$ + assertOK(corporateActionEntry(CorporateActionKind.RESTRUCTURING, + accruedInterestPosting(fixture, "interest-1"))); //$NON-NLS-1$ + } + + @Test + public void testRestructuringRejectsFeeOrTaxOnlyPrimaryMovement() + { + var fixture = fixture(); + + assertIssue(corporateActionEntry(CorporateActionKind.RESTRUCTURING, feePosting(fixture, "fee-1")), //$NON-NLS-1$ + IssueCode.REQUIRED_PRIMARY_MOVEMENT_MISSING); + assertIssue(corporateActionEntry(CorporateActionKind.RESTRUCTURING, taxPosting(fixture, "tax-1")), //$NON-NLS-1$ + IssueCode.REQUIRED_PRIMARY_MOVEMENT_MISSING); + } + + @Test + public void testDefaultRequiresPrimaryMovementBeyondContext() + { + var fixture = fixture(); + var entry = corporateActionEntry(CorporateActionKind.DEFAULT); + + entry.addPosting(securityContextPosting(fixture, "context-1")); //$NON-NLS-1$ + + assertIssue(entry, IssueCode.REQUIRED_PRIMARY_MOVEMENT_MISSING); + } + + @Test + public void testDefaultAcceptsOneBookedPrimaryMovement() + { + var fixture = fixture(); + + assertOK(corporateActionEntry(CorporateActionKind.DEFAULT, securityContextPosting(fixture, "context-1"), //$NON-NLS-1$ + sourceSecurityPosting(fixture, "source-1"))); //$NON-NLS-1$ + assertOK(corporateActionEntry(CorporateActionKind.DEFAULT, securityContextPosting(fixture, "context-1"), //$NON-NLS-1$ + cashPosting(fixture, "cash-1"))); //$NON-NLS-1$ + assertOK(corporateActionEntry(CorporateActionKind.DEFAULT, securityContextPosting(fixture, "context-1"), //$NON-NLS-1$ + taxPosting(fixture, "tax-1"))); //$NON-NLS-1$ + } + + @Test + public void testForexDoesNotSatisfyPrimaryMovement() + { + var fixture = fixture(); + var entry = corporateActionEntry(CorporateActionKind.RESTRUCTURING); + + entry.addPosting(forexPosting(fixture, "forex-1")); //$NON-NLS-1$ + + assertIssue(entry, IssueCode.REQUIRED_PRIMARY_MOVEMENT_MISSING); + } + /** * Checks that repeated source legs follow the same semantic-key policy as * repeated target and cash movement legs. @@ -641,6 +778,16 @@ private static LedgerEntry corporateActionEntry(CorporateActionKind kind) return entry; } + private static LedgerEntry corporateActionEntry(CorporateActionKind kind, LedgerPosting... postings) + { + var entry = corporateActionEntry(kind); + + for (var posting : postings) + entry.addPosting(posting); + + return entry; + } + private static LedgerPosting securityContextPosting(Fixture fixture, String localKey) { var posting = new LedgerPosting("security-context-" + localKey); //$NON-NLS-1$ @@ -743,6 +890,84 @@ private static LedgerPosting principalPosting(Fixture fixture, String localKey) return posting; } + private static LedgerPosting accruedInterestPosting(Fixture fixture, String localKey) + { + var posting = new LedgerPosting("interest-" + localKey); //$NON-NLS-1$ + + posting.setType(LedgerPostingType.ACCRUED_INTEREST); + posting.setAccount(fixture.account); + posting.setAmount(Values.Amount.factorize(1)); + posting.setCurrency(CurrencyUnit.EUR); + posting.setSemanticRole(LedgerPostingSemanticRole.ACCRUED_INTEREST); + posting.setDirection(LedgerPostingDirection.NEUTRAL); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + posting.setCorporateActionLeg(CorporateActionLeg.ACCRUED_INTEREST); + posting.setGroupKey(localKey); + posting.setLocalKey(localKey); + posting.addParameter(LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_LEG, + CorporateActionLeg.ACCRUED_INTEREST.getCode())); + + return posting; + } + + private static LedgerPosting feePosting(Fixture fixture, String localKey) + { + var posting = unitPosting(fixture, localKey, LedgerPostingType.FEE, LedgerPostingSemanticRole.FEE, + LedgerPostingUnitRole.FEE, CorporateActionLeg.FEE); + + posting.addParameter(LedgerParameter.ofCode(LedgerParameterType.FEE_REASON, + FeeReason.CORPORATE_ACTION_FEE)); + + return posting; + } + + private static LedgerPosting taxPosting(Fixture fixture, String localKey) + { + var posting = unitPosting(fixture, localKey, LedgerPostingType.TAX, LedgerPostingSemanticRole.TAX, + LedgerPostingUnitRole.TAX, CorporateActionLeg.TAX); + + posting.addParameter(LedgerParameter.ofCode(LedgerParameterType.TAX_REASON, + TaxReason.WITHHOLDING_TAX)); + + return posting; + } + + private static LedgerPosting unitPosting(Fixture fixture, String localKey, LedgerPostingType type, + LedgerPostingSemanticRole semanticRole, LedgerPostingUnitRole unitRole, CorporateActionLeg leg) + { + var posting = new LedgerPosting(type.getCode().toLowerCase() + "-" + localKey); //$NON-NLS-1$ + + posting.setType(type); + posting.setAccount(fixture.account); + posting.setAmount(Values.Amount.factorize(1)); + posting.setCurrency(CurrencyUnit.EUR); + posting.setSemanticRole(semanticRole); + posting.setDirection(LedgerPostingDirection.NEUTRAL); + posting.setUnitRole(unitRole); + posting.setCorporateActionLeg(leg); + posting.setGroupKey(localKey); + posting.setLocalKey(localKey); + posting.addParameter(LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_LEG, leg.getCode())); + + return posting; + } + + private static LedgerPosting forexPosting(Fixture fixture, String localKey) + { + var posting = new LedgerPosting("forex-" + localKey); //$NON-NLS-1$ + + posting.setType(LedgerPostingType.FOREX); + posting.setCurrency(CurrencyUnit.EUR); + posting.setSemanticRole(LedgerPostingSemanticRole.FOREX_CONTEXT); + posting.setDirection(LedgerPostingDirection.NEUTRAL); + posting.setUnitRole(LedgerPostingUnitRole.FOREX_CONTEXT); + posting.setGroupKey(localKey); + posting.setLocalKey(localKey); + posting.addParameter(LedgerParameter.ofMoney(LedgerParameterType.REFERENCE_PRICE, money(1))); + + return posting; + } + private static void removePosting(LedgerEntry entry, CorporateActionLeg leg) { entry.getPostings().stream().filter(posting -> posting.getCorporateActionLeg() == leg).findFirst() 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 index 95f07e1f3b..041a72818b 100644 --- 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 @@ -16,6 +16,7 @@ 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.LedgerPrimaryMovement; 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; @@ -106,12 +107,15 @@ private static Map corporateActionDe register(definitions, CorporateActionKind.RIGHTS_DISTRIBUTION, rightsDistribution()); register(definitions, CorporateActionKind.COUPON_PAYMENT, couponPayment()); register(definitions, CorporateActionKind.PIK_INTEREST, pikInterest()); + register(definitions, CorporateActionKind.DEFAULTED_INTEREST, defaultedInterest()); 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()); + register(definitions, CorporateActionKind.RESTRUCTURING, restructuring()); + register(definitions, CorporateActionKind.DEFAULT, defaultAction()); return Collections.unmodifiableMap(definitions); } @@ -243,6 +247,19 @@ private static LedgerEntryDefinition pikInterest() LedgerPerformanceTreatment.INCOME_DISTRIBUTION); } + private static LedgerEntryDefinition defaultedInterest() + { + return corporateActionDefinition( + SETS.legDefinitions(securityContextLeg(LedgerLegCardinality.AT_LEAST_ONE), + targetSecurityLeg(LedgerLegCardinality.REPEATABLE), + cashLeg(LedgerLegCardinality.REPEATABLE), feeLeg(), taxLeg()), + primaryMovementGroup("DEFAULTED_INTEREST_PRIMARY_MOVEMENT", //$NON-NLS-1$ + LedgerPrimaryMovement.CASH, LedgerPrimaryMovement.TARGET_SECURITY, + LedgerPrimaryMovement.FEE, LedgerPrimaryMovement.TAX), + LedgerReportingClass.FIXED_INCOME_COUPON, + LedgerPerformanceTreatment.INCOME_DISTRIBUTION); + } + private static LedgerEntryDefinition maturity() { return fixedIncomeRedemption(); @@ -299,8 +316,48 @@ private static LedgerEntryDefinition securityReorganization() LedgerPerformanceTreatment.COST_BASIS_REALLOCATION); } + private static LedgerEntryDefinition restructuring() + { + return corporateActionDefinition( + SETS.legDefinitions(securityContextLeg(LedgerLegCardinality.OPTIONAL), + sourceSecurityLeg(LedgerLegCardinality.REPEATABLE), + targetSecurityLeg(LedgerLegCardinality.REPEATABLE), + cashLeg(LedgerLegCardinality.REPEATABLE), + principalRedemptionLeg(LedgerLegCardinality.REPEATABLE), + accruedInterestLeg(LedgerLegCardinality.REPEATABLE), + feeLeg(), taxLeg(), forexLeg()), + primaryMovementGroup("RESTRUCTURING_PRIMARY_MOVEMENT", //$NON-NLS-1$ + LedgerPrimaryMovement.SOURCE_SECURITY, LedgerPrimaryMovement.TARGET_SECURITY, + LedgerPrimaryMovement.CASH, LedgerPrimaryMovement.PRINCIPAL_REDEMPTION, + LedgerPrimaryMovement.ACCRUED_INTEREST), + LedgerReportingClass.SECURITY_REORGANIZATION, + LedgerPerformanceTreatment.COST_BASIS_REALLOCATION); + } + + private static LedgerEntryDefinition defaultAction() + { + return corporateActionDefinition( + SETS.legDefinitions(securityContextLeg(LedgerLegCardinality.AT_LEAST_ONE), + sourceSecurityLeg(LedgerLegCardinality.REPEATABLE), + targetSecurityLeg(LedgerLegCardinality.REPEATABLE), + cashLeg(LedgerLegCardinality.REPEATABLE), feeLeg(), taxLeg()), + primaryMovementGroup("DEFAULT_PRIMARY_MOVEMENT", //$NON-NLS-1$ + LedgerPrimaryMovement.SOURCE_SECURITY, LedgerPrimaryMovement.TARGET_SECURITY, + LedgerPrimaryMovement.CASH, LedgerPrimaryMovement.FEE, + LedgerPrimaryMovement.TAX), + LedgerReportingClass.NONE, + LedgerPerformanceTreatment.PERFORMANCE_NEUTRAL); + } + private static LedgerEntryDefinition corporateActionDefinition(Set legDefinitions, LedgerReportingClass reportingClass, LedgerPerformanceTreatment performanceTreatment) + { + return corporateActionDefinition(legDefinitions, Set.of(), reportingClass, performanceTreatment); + } + + private static LedgerEntryDefinition corporateActionDefinition(Set legDefinitions, + Set alternativeRequirementGroups, LedgerReportingClass reportingClass, + LedgerPerformanceTreatment performanceTreatment) { return LedgerEntryDefinition.of(LedgerEntryType.CORPORATE_ACTION, LedgerNativeEntryShape.DUAL_INSTRUMENT_PLUS_ACCOUNT, @@ -309,7 +366,7 @@ private static LedgerEntryDefinition corporateActionDefinition(Set primaryMovementGroup(String name, LedgerPrimaryMovement first, + LedgerPrimaryMovement... rest) + { + return SETS.alternativeGroups(LedgerRequirementGroup.primaryMovements(name, LedgerRequirement.REQUIRED, + SETS.primaryMovements(first, rest))); + } + private static EnumSet requiredSecurityLegParameters() { return SETS.parameterTypes(LedgerParameterType.CORPORATE_ACTION_LEG, @@ -681,6 +745,12 @@ private EnumSet projectionRoles(LedgerProjectionRole first return EnumSet.of(first, rest); } + private EnumSet primaryMovements(LedgerPrimaryMovement first, + LedgerPrimaryMovement... rest) + { + return EnumSet.of(first, rest); + } + private Set postingRules(LedgerPostingRule first, LedgerPostingRule... rest) { return setOf(first, rest); diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java index 62f65e64c0..ac48e36ff8 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java @@ -36,6 +36,7 @@ public enum IssueCode REQUIRED_ENTRY_PARAMETER_MISSING, ENTRY_PARAMETER_NOT_ALLOWED, REQUIRED_ALTERNATIVE_GROUP_MISSING, + REQUIRED_PRIMARY_MOVEMENT_MISSING, POSTING_TYPE_NOT_ALLOWED, LEG_CARDINALITY_VIOLATED, AMBIGUOUS_LEG_MATCH, @@ -155,11 +156,15 @@ private static void validateAlternativeGroups(LedgerEntry entry, LedgerEntryDefi for (var group : definition.getAlternativeRequirementGroups()) { if (group.getRequirement() == LedgerRequirement.REQUIRED && !isSatisfied(entry, group)) - issues.add(issue(IssueCode.REQUIRED_ALTERNATIVE_GROUP_MISSING, + { + var issueCode = group.getPrimaryMovements().isEmpty() ? IssueCode.REQUIRED_ALTERNATIVE_GROUP_MISSING + : IssueCode.REQUIRED_PRIMARY_MOVEMENT_MISSING; + issues.add(issue(issueCode, LedgerDiagnosticCode.LEDGER_STRUCT_041.message( "Required native alternative group is missing: " + group.getName()), //$NON-NLS-1$ entry) .withDetail("groupName", group.getName())); //$NON-NLS-1$ + } } } @@ -182,6 +187,11 @@ private static boolean isSatisfied(LedgerEntry entry, LedgerRequirementGroup gro return entry.getPostings().stream().map(LedgerPosting::getType) .anyMatch(group.getPostingTypes()::contains); + if (!group.getPrimaryMovements().isEmpty()) + return entry.getPostings().stream() + .anyMatch(posting -> group.getPrimaryMovements().stream() + .anyMatch(movement -> movement.matches(posting))); + return false; } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/rule/LedgerPrimaryMovement.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/rule/LedgerPrimaryMovement.java new file mode 100644 index 0000000000..ec9f92641d --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/rule/LedgerPrimaryMovement.java @@ -0,0 +1,44 @@ +package name.abuchen.portfolio.model.ledger.configuration.rule; + +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerPostingDirection; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; + +/** + * Names semantic movement predicates that may satisfy a corporate action primary + * movement requirement. + */ +public enum LedgerPrimaryMovement +{ + SOURCE_SECURITY, + TARGET_SECURITY, + CASH, + PRINCIPAL_REDEMPTION, + ACCRUED_INTEREST, + FEE, + TAX; + + public boolean matches(LedgerPosting posting) + { + return switch (this) + { + case SOURCE_SECURITY -> posting.getType() == LedgerPostingType.SECURITY + && posting.getDirection() == LedgerPostingDirection.OUTBOUND + && posting.getCorporateActionLeg() == CorporateActionLeg.SOURCE_SECURITY; + case TARGET_SECURITY -> posting.getType() == LedgerPostingType.SECURITY + && posting.getDirection() == LedgerPostingDirection.INBOUND + && posting.getCorporateActionLeg() == CorporateActionLeg.TARGET_SECURITY; + case CASH -> posting.getType() == LedgerPostingType.CASH + || posting.getType() == LedgerPostingType.CASH_COMPENSATION; + case PRINCIPAL_REDEMPTION -> posting.getType() == LedgerPostingType.PRINCIPAL_REDEMPTION + && posting.getCorporateActionLeg() == CorporateActionLeg.PRINCIPAL; + case ACCRUED_INTEREST -> posting.getType() == LedgerPostingType.ACCRUED_INTEREST + && posting.getCorporateActionLeg() == CorporateActionLeg.ACCRUED_INTEREST; + case FEE -> posting.getType() == LedgerPostingType.FEE + && posting.getCorporateActionLeg() == CorporateActionLeg.FEE; + case TAX -> posting.getType() == LedgerPostingType.TAX + && posting.getCorporateActionLeg() == CorporateActionLeg.TAX; + }; + } +} 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 index 051533910b..9cde811eba 100644 --- 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 @@ -25,30 +25,41 @@ public final class LedgerRequirementGroup private final LedgerRequirement requirement; private final Set postingTypes; private final Set parameterTypes; + private final Set primaryMovements; private LedgerRequirementGroup(String name, LedgerRequirement requirement, Set postingTypes, - Set parameterTypes) + Set parameterTypes, Set primaryMovements) { this.name = requireName(name); this.requirement = Objects.requireNonNull(requirement); this.postingTypes = copyPostingTypes(postingTypes); this.parameterTypes = copyParameterTypes(parameterTypes); + this.primaryMovements = copyPrimaryMovements(primaryMovements); - if (this.postingTypes.isEmpty() && this.parameterTypes.isEmpty()) + if (this.postingTypes.isEmpty() && this.parameterTypes.isEmpty() && this.primaryMovements.isEmpty()) throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CORE_025 - .message("Ledger requirement group must contain postings or parameters")); //$NON-NLS-1$ + .message("Ledger requirement group must contain postings, parameters, or movements")); //$NON-NLS-1$ } public static LedgerRequirementGroup postingTypes(String name, LedgerRequirement requirement, Set postingTypes) { - return new LedgerRequirementGroup(name, requirement, postingTypes, EnumSet.noneOf(LedgerParameterType.class)); + return new LedgerRequirementGroup(name, requirement, postingTypes, EnumSet.noneOf(LedgerParameterType.class), + EnumSet.noneOf(LedgerPrimaryMovement.class)); } public static LedgerRequirementGroup parameterTypes(String name, LedgerRequirement requirement, Set parameterTypes) { - return new LedgerRequirementGroup(name, requirement, EnumSet.noneOf(LedgerPostingType.class), parameterTypes); + return new LedgerRequirementGroup(name, requirement, EnumSet.noneOf(LedgerPostingType.class), parameterTypes, + EnumSet.noneOf(LedgerPrimaryMovement.class)); + } + + public static LedgerRequirementGroup primaryMovements(String name, LedgerRequirement requirement, + Set primaryMovements) + { + return new LedgerRequirementGroup(name, requirement, EnumSet.noneOf(LedgerPostingType.class), + EnumSet.noneOf(LedgerParameterType.class), primaryMovements); } public String getName() @@ -81,6 +92,11 @@ public Set getParameterTypes() return parameterTypes; } + public Set getPrimaryMovements() + { + return primaryMovements; + } + private static String requireName(String name) { if (name == null || name.isBlank()) @@ -113,4 +129,16 @@ private static Set copyParameterTypes(Set copyPrimaryMovements(Set values) + { + Objects.requireNonNull(values); + + var copy = EnumSet.noneOf(LedgerPrimaryMovement.class); + + for (var value : values) + copy.add(Objects.requireNonNull(value)); + + return Collections.unmodifiableSet(copy); + } } From c3efc98c0099d4919a83fa2300195a1c3e1317e2 Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Sun, 5 Jul 2026 10:06:05 +0200 Subject: [PATCH 54/68] Register Ledger cash distribution corporate action Added CASH_DISTRIBUTION as the canonical corporate action kind and registered its security-context plus required cash movement definition. This models the Registry.md cash distribution row without adding a CASH_DIVIDEND synonym or changing projection behavior. This intentionally leaves BASIS, correction/reversal, mandatory interest-detail semantics, UI, importer, reporting, tax/performance logic, legacy transaction mapping, and persistence schema unchanged. --- .../model/ledger/LedgerCodeTest.java | 4 +- ...ateActionMultiMovementPersistenceTest.java | 93 +++++++++++++++ .../ledger/LedgerEntryDefinitionTest.java | 28 ++++- .../model/ledger/LedgerModelTest.java | 15 ++- ...gerNativeEntryDefinitionValidatorTest.java | 107 ++++++++++++++++++ .../ledger/LedgerParameterCodeDomainTest.java | 7 +- .../configuration/CorporateActionKind.java | 1 + .../LedgerEntryDefinitionRegistry.java | 12 ++ .../LedgerNativeEntryDefinitionValidator.java | 23 ++++ 9 files changed, 272 insertions(+), 18 deletions(-) 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 index fefcce5d01..bf8a47487a 100644 --- 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 @@ -96,6 +96,7 @@ public void testCorporateActionKindsClassifyGenericNativeEntryType() { assertTrue(LedgerEntryType.CORPORATE_ACTION.isLedgerNativeTargeted()); assertThat(List.of(CorporateActionKind.values()), is(List.of( + CorporateActionKind.CASH_DISTRIBUTION, CorporateActionKind.STOCK_DIVIDEND, CorporateActionKind.SPIN_OFF, CorporateActionKind.BONUS_ISSUE, @@ -111,9 +112,10 @@ public void testCorporateActionKindsClassifyGenericNativeEntryType() CorporateActionKind.EXCHANGE, CorporateActionKind.RESTRUCTURING, CorporateActionKind.DEFAULT))); + assertThat(CorporateActionKind.fromCode("CASH_DISTRIBUTION").orElseThrow(), + is(CorporateActionKind.CASH_DISTRIBUTION)); assertThat(CorporateActionKind.fromCode("SPIN_OFF").orElseThrow(), is(CorporateActionKind.SPIN_OFF)); assertThat(CorporateActionKind.fromCode("MATURITY").orElseThrow(), is(CorporateActionKind.MATURITY)); - assertTrue(CorporateActionKind.fromCode("CASH_DISTRIBUTION").isEmpty()); assertTrue(CorporateActionKind.fromCode("CASH_DIVIDEND").isEmpty()); assertTrue(CorporateActionKind.fromCode("OTHER").isEmpty()); } diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java index 856d404133..f5a71ccc78 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java @@ -119,6 +119,46 @@ public void testXmlAndProtobufRoundtripPreserveDefaultedInterestPrimaryMovement( assertNativeDefinitionValid(loadedProtoEntry); } + @Test + public void testXmlAndProtobufRoundtripPreserveCashDistribution() throws Exception + { + var fixture = cashDistributionFixture(); + + assertCashDistribution(fixture.entry()); + assertValid(fixture.client()); + assertNativeDefinitionValid(fixture.entry()); + + var xml = saveXml(fixture.client()); + + assertNoLedgerUuidTruth(xml); + + var loadedFromXml = loadXml(xml); + var loadedXmlEntry = onlyLedgerEntry(loadedFromXml); + + assertCashDistribution(loadedXmlEntry); + assertValid(loadedFromXml); + assertNativeDefinitionValid(loadedXmlEntry); + + var bytes = ProtobufTestUtilities.save(fixture.client()); + var proto = parseProto(bytes); + var protoEntry = proto.getLedger().getEntries(0); + + assertThat(protoEntry.getTypeCode(), is(LedgerEntryType.CORPORATE_ACTION.getCode())); + assertTrue(protoEntry.getParametersList().stream() + .anyMatch(parameter -> LedgerParameterType.CORPORATE_ACTION_KIND.getCode() + .equals(parameter.getTypeCode()) + && CorporateActionKind.CASH_DISTRIBUTION.getCode() + .equals(parameter.getStringValue()))); + assertNoCorporateActionSpecificLegacyTransactionType(proto); + + var loadedFromProto = ProtobufTestUtilities.load(bytes); + var loadedProtoEntry = onlyLedgerEntry(loadedFromProto); + + assertCashDistribution(loadedProtoEntry); + assertValid(loadedFromProto); + assertNativeDefinitionValid(loadedProtoEntry); + } + private Fixture spinOffFixture() { var client = new Client(); @@ -217,6 +257,40 @@ private Fixture defaultedInterestFixture() return new Fixture(client, entry); } + private Fixture cashDistributionFixture() + { + var client = new Client(); + var account = new Account(); + var portfolio = new Portfolio(); + var contextSecurity = new Security("Cash Distribution AG", CurrencyUnit.EUR); + + account.setName("Cash Account"); + account.setCurrencyCode(CurrencyUnit.EUR); + portfolio.setName("Portfolio"); + portfolio.setReferenceAccount(account); + account.setUpdatedAt(UPDATED_AT); + portfolio.setUpdatedAt(UPDATED_AT); + contextSecurity.setUpdatedAt(UPDATED_AT); + + client.addAccount(account); + client.addPortfolio(portfolio); + client.addSecurity(contextSecurity); + + var entry = new LedgerEntry("cash-distribution"); + + entry.setType(LedgerEntryType.CORPORATE_ACTION); + entry.setDateTime(DATE_TIME); + entry.setSource("CASH_DISTRIBUTION primary movement proof"); + entry.addParameter(LedgerParameter.ofCode(LedgerParameterType.CORPORATE_ACTION_KIND, + CorporateActionKind.CASH_DISTRIBUTION)); + entry.addPosting(securityContextPosting(portfolio, contextSecurity, "main", "context-1")); + entry.addPosting(cashPrimaryPosting(account, "cash-1", 44)); + + client.getLedger().addEntry(entry); + + return new Fixture(client, entry); + } + private LedgerPosting securityPosting(Portfolio portfolio, Security security, LedgerPostingDirection direction, CorporateActionLeg leg, String groupKey, String localKey, long shares, long amount) { @@ -328,6 +402,7 @@ private LedgerPosting unitPosting(LedgerPostingType type, LedgerPostingSemanticR posting.setUnitRole(unitRole); posting.setGroupKey(groupKey); posting.setLocalKey(localKey); + posting.addParameter(LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_LEG, leg.getCode())); return posting; } @@ -397,6 +472,24 @@ private void assertDefaultedInterestPrimaryMovement(LedgerEntry entry) assertTrue(LedgerDescriptorTestSupport.descriptors(entry).isEmpty()); } + private void assertCashDistribution(LedgerEntry entry) + { + assertThat(entry.getType(), is(LedgerEntryType.CORPORATE_ACTION)); + assertTrue(entry.getParameters().stream() + .anyMatch(parameter -> parameter.getType() == LedgerParameterType.CORPORATE_ACTION_KIND + && CorporateActionKind.CASH_DISTRIBUTION.getCode() + .equals(parameter.getValue()))); + assertThat(postings(entry, LedgerPostingType.SECURITY, LedgerPostingDirection.NEUTRAL, + CorporateActionLeg.SECURITY_CONTEXT).size(), is(1)); + assertThat(entry.getPostings().stream() + .filter(posting -> posting.getType() == LedgerPostingType.CASH) + .filter(posting -> "cash-1".equals(posting.getLocalKey())) //$NON-NLS-1$ + .filter(posting -> "cash-1".equals(posting.getGroupKey())) //$NON-NLS-1$ + .filter(posting -> posting.getAccount() != null) + .count(), is(1L)); + assertTrue(LedgerDescriptorTestSupport.descriptors(entry).isEmpty()); + } + private List postings(LedgerEntry entry, LedgerPostingType type, LedgerPostingDirection direction, CorporateActionLeg leg) { 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 index 24ad381b62..8228d32325 100644 --- 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 @@ -157,7 +157,10 @@ public void testAlternativeRequirementGroupsAreRealAlternatives() var numberOfAlternatives = group.getPostingTypes().size() + group.getParameterTypes().size() + group.getPrimaryMovements().size(); - assertTrue(definition.getEntryType() + ":" + group.getName(), numberOfAlternatives >= 2); + var minimumAlternatives = group.getPrimaryMovements().isEmpty() ? 2 : 1; + + assertTrue(definition.getEntryType() + ":" + group.getName(), + numberOfAlternatives >= minimumAlternatives); } } } @@ -317,7 +320,8 @@ public void testSpinOffDefinitionDescribesFunctionalLegs() @Test public void testCorporateActionKindDefinitionsRegisterRepresentableProfileSubsets() { - for (var kind : new CorporateActionKind[] { CorporateActionKind.STOCK_DIVIDEND, + for (var kind : new CorporateActionKind[] { CorporateActionKind.CASH_DISTRIBUTION, + CorporateActionKind.STOCK_DIVIDEND, CorporateActionKind.SPIN_OFF, CorporateActionKind.BONUS_ISSUE, CorporateActionKind.RIGHTS_DISTRIBUTION, CorporateActionKind.COUPON_PAYMENT, CorporateActionKind.PIK_INTEREST, CorporateActionKind.MATURITY, @@ -332,11 +336,25 @@ public void testCorporateActionKindDefinitionsRegisterRepresentableProfileSubset assertTrue(kind.name(), LedgerEntryDefinitionRegistry .lookup(LedgerEntryType.CORPORATE_ACTION, kind).isPresent()); - assertTrue("CASH_DISTRIBUTION is overview-only in Registry.md", - CorporateActionKind.fromCode("CASH_DISTRIBUTION").isEmpty()); - assertTrue("CASH_DIVIDEND is overview-only in Registry.md", + assertTrue("CASH_DIVIDEND is an unregistered synonym for CASH_DISTRIBUTION in Registry.md", CorporateActionKind.fromCode("CASH_DIVIDEND").isEmpty()); + var cashDistribution = LedgerEntryDefinitionRegistry + .lookup(LedgerEntryType.CORPORATE_ACTION, CorporateActionKind.CASH_DISTRIBUTION) + .orElseThrow(); + assertLeg(cashDistribution, LedgerLegRole.SECURITY_CONTEXT_LEG, LedgerPostingType.SECURITY, + LedgerLegCardinality.AT_LEAST_ONE); + assertLeg(cashDistribution, LedgerLegRole.CASH_LEG, LedgerPostingType.CASH, + LedgerLegCardinality.AT_LEAST_ONE); + assertLeg(cashDistribution, LedgerLegRole.FEE_LEG, LedgerPostingType.FEE, + LedgerLegCardinality.REPEATABLE); + assertLeg(cashDistribution, LedgerLegRole.TAX_LEG, LedgerPostingType.TAX, + LedgerLegCardinality.REPEATABLE); + assertTrue(cashDistribution.getLegDefinition(LedgerLegRole.SOURCE_SECURITY_LEG).isEmpty()); + assertTrue(cashDistribution.getLegDefinition(LedgerLegRole.TARGET_SECURITY_LEG).isEmpty()); + assertPrimaryMovementGroup(cashDistribution, "CASH_DISTRIBUTION_PRIMARY_MOVEMENT", + LedgerPrimaryMovement.CASH); + var stockDividend = LedgerEntryDefinitionRegistry .lookup(LedgerEntryType.CORPORATE_ACTION, CorporateActionKind.STOCK_DIVIDEND) .orElseThrow(); 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 index 4bcdb7d897..0e0d40d695 100644 --- 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 @@ -411,14 +411,13 @@ public void testLedgerParameterCodeDomainsAreExplicit() 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); + CorporateActionKind.CASH_DISTRIBUTION, 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, diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java index 8aa767db84..4dc8415a0b 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java @@ -219,6 +219,113 @@ public void testCouponPaymentDefinitionAcceptsSecurityContextAndCash() assertOK(entry); } + @Test + public void testCashDistributionAcceptsSecurityContextAndCash() + { + var fixture = fixture(); + var entry = corporateActionEntry(CorporateActionKind.CASH_DISTRIBUTION); + + entry.addPosting(securityContextPosting(fixture, "context-1")); //$NON-NLS-1$ + entry.addPosting(cashPosting(fixture, "cash-1")); //$NON-NLS-1$ + + assertOK(entry); + } + + @Test + public void testCashDistributionRejectsSecurityContextOnly() + { + var fixture = fixture(); + var entry = corporateActionEntry(CorporateActionKind.CASH_DISTRIBUTION); + + entry.addPosting(securityContextPosting(fixture, "context-1")); //$NON-NLS-1$ + + assertIssue(entry, IssueCode.REQUIRED_PRIMARY_MOVEMENT_MISSING); + } + + @Test + public void testCashDistributionRejectsCashWithoutSecurityContext() + { + var fixture = fixture(); + var entry = corporateActionEntry(CorporateActionKind.CASH_DISTRIBUTION); + + entry.addPosting(cashPosting(fixture, "cash-1")); //$NON-NLS-1$ + + assertIssue(entry, IssueCode.LEG_CARDINALITY_VIOLATED); + } + + @Test + public void testCashDistributionRejectsFeeOrTaxOnly() + { + var fixture = fixture(); + + assertIssue(corporateActionEntry(CorporateActionKind.CASH_DISTRIBUTION, + securityContextPosting(fixture, "context-1"), feePosting(fixture, "fee-1")), //$NON-NLS-1$ //$NON-NLS-2$ + IssueCode.REQUIRED_PRIMARY_MOVEMENT_MISSING); + assertIssue(corporateActionEntry(CorporateActionKind.CASH_DISTRIBUTION, + securityContextPosting(fixture, "context-1"), taxPosting(fixture, "tax-1")), //$NON-NLS-1$ //$NON-NLS-2$ + IssueCode.REQUIRED_PRIMARY_MOVEMENT_MISSING); + } + + @Test + public void testCashDistributionRejectsSecurityContextWithoutPortfolio() + { + var fixture = fixture(); + var entry = corporateActionEntry(CorporateActionKind.CASH_DISTRIBUTION); + var context = securityContextPosting(fixture, "context-1"); //$NON-NLS-1$ + + context.setPortfolio(null); + entry.addPosting(context); + entry.addPosting(cashPosting(fixture, "cash-1")); //$NON-NLS-1$ + + var ledger = new Ledger(); + + ledger.addEntry(entry); + + var result = LedgerStructuralValidator.validate(ledger); + + assertTrue(result.format(), result.hasIssue( + LedgerStructuralValidator.IssueCode.POSTING_PORTFOLIO_REQUIRED_FOR_SECURITY)); + } + + @Test + public void testCashDistributionAcceptsRepeatedCashWithDistinctLocalKeys() + { + var fixture = fixture(); + var entry = corporateActionEntry(CorporateActionKind.CASH_DISTRIBUTION); + + entry.addPosting(securityContextPosting(fixture, "context-1")); //$NON-NLS-1$ + entry.addPosting(cashPosting(fixture, "cash-1")); //$NON-NLS-1$ + entry.addPosting(cashPosting(fixture, "cash-2")); //$NON-NLS-1$ + + assertOK(entry); + } + + @Test + public void testCashDistributionRejectsDuplicateCashLocalKey() + { + var fixture = fixture(); + var entry = corporateActionEntry(CorporateActionKind.CASH_DISTRIBUTION); + + entry.addPosting(securityContextPosting(fixture, "context-1")); //$NON-NLS-1$ + entry.addPosting(cashPosting(fixture, "cash-1")); //$NON-NLS-1$ + entry.addPosting(cashPosting(fixture, "cash-1")); //$NON-NLS-1$ + + assertIssue(entry, IssueCode.LEG_LOCAL_KEY_DUPLICATE); + } + + @Test + public void testCashDistributionRejectsSourceOrTargetSecurityMovement() + { + var fixture = fixture(); + + assertIssue(corporateActionEntry(CorporateActionKind.CASH_DISTRIBUTION, + securityContextPosting(fixture, "context-1"), cashPosting(fixture, "cash-1"), //$NON-NLS-1$ //$NON-NLS-2$ + sourceSecurityPosting(fixture, "source-1")), IssueCode.LEG_POSTING_NOT_ALLOWED); //$NON-NLS-1$ + assertIssue(corporateActionEntry(CorporateActionKind.CASH_DISTRIBUTION, + securityContextPosting(fixture, "context-1"), cashPosting(fixture, "cash-1"), //$NON-NLS-1$ //$NON-NLS-2$ + targetSecurityPosting(fixture, "target-1")), IssueCode.LEG_POSTING_NOT_ALLOWED); //$NON-NLS-1$ + } + @Test public void testMaturityDefinitionRequiresSecurityContextLeg() { 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 index b45811fc4e..850a0fd562 100644 --- 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 @@ -29,11 +29,10 @@ public class LedgerParameterCodeDomainTest "REDEMPTION", "CONVERSION_SOURCE", "CONVERSION_TARGET", "OTHER")), Map.entry(LedgerParameterCodeDomain.CORPORATE_ACTION_KIND, - List.of("STOCK_DIVIDEND", "SPIN_OFF", "BONUS_ISSUE", + List.of("CASH_DISTRIBUTION", "STOCK_DIVIDEND", "SPIN_OFF", "BONUS_ISSUE", "RIGHTS_DISTRIBUTION", "COUPON_PAYMENT", "PIK_INTEREST", - "DEFAULTED_INTEREST", "MATURITY", "PARTIAL_REDEMPTION", - "CALL", "PUT", "CONVERSION", "EXCHANGE", "RESTRUCTURING", - "DEFAULT")), + "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, 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 index 08b96083fe..13ddd06f79 100644 --- 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 @@ -18,6 +18,7 @@ @SuppressWarnings("nls") public enum CorporateActionKind implements LedgerCode { + CASH_DISTRIBUTION("CASH_DISTRIBUTION"), STOCK_DIVIDEND("STOCK_DIVIDEND"), SPIN_OFF("SPIN_OFF"), BONUS_ISSUE("BONUS_ISSUE"), 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 index 041a72818b..ec11ac1b73 100644 --- 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 @@ -101,6 +101,7 @@ private static Map corporateActionDe { var definitions = new EnumMap(CorporateActionKind.class); + register(definitions, CorporateActionKind.CASH_DISTRIBUTION, cashDistribution()); register(definitions, CorporateActionKind.STOCK_DIVIDEND, stockDividend()); register(definitions, CorporateActionKind.SPIN_OFF, spinOff()); register(definitions, CorporateActionKind.BONUS_ISSUE, bonusIssue()); @@ -205,6 +206,17 @@ private static LedgerEntryDefinition stockDividend() LedgerPerformanceTreatment.SECURITY_DISTRIBUTION); } + private static LedgerEntryDefinition cashDistribution() + { + return corporateActionDefinition( + SETS.legDefinitions(securityContextLeg(LedgerLegCardinality.AT_LEAST_ONE), + cashLeg(LedgerLegCardinality.AT_LEAST_ONE), feeLeg(), taxLeg()), + primaryMovementGroup("CASH_DISTRIBUTION_PRIMARY_MOVEMENT", //$NON-NLS-1$ + LedgerPrimaryMovement.CASH), + LedgerReportingClass.CASH_DIVIDEND, + LedgerPerformanceTreatment.INCOME_DISTRIBUTION); + } + private static LedgerEntryDefinition bonusIssue() { return corporateActionDefinition( diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java index ac48e36ff8..dd7adf95ad 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java @@ -39,6 +39,7 @@ public enum IssueCode REQUIRED_PRIMARY_MOVEMENT_MISSING, POSTING_TYPE_NOT_ALLOWED, LEG_CARDINALITY_VIOLATED, + LEG_POSTING_NOT_ALLOWED, AMBIGUOUS_LEG_MATCH, REQUIRED_LEG_PARAMETER_MISSING, LEG_PARAMETER_NOT_ALLOWED, @@ -198,6 +199,8 @@ private static boolean isSatisfied(LedgerEntry entry, LedgerRequirementGroup gro private static void validateLegs(LedgerEntry entry, LedgerEntryDefinition definition, List issues) { + validatePostingsMatchConfiguredLegs(entry, definition, issues); + for (var leg : definition.getLegDefinitions()) { var match = matchLeg(entry, definition, leg, issues); @@ -207,6 +210,26 @@ private static void validateLegs(LedgerEntry entry, LedgerEntryDefinition defini } } + private static void validatePostingsMatchConfiguredLegs(LedgerEntry entry, LedgerEntryDefinition definition, + List issues) + { + for (var posting : entry.getPostings()) + { + if (posting.getType() == null || !definition.getPostingTypes().contains(posting.getType())) + continue; + + var matchesConfiguredLeg = definition.getLegDefinitions().stream() + .anyMatch(leg -> postingMatchesLeg(entry.getType(), posting, leg)); + + if (!matchesConfiguredLeg) + issues.add(issue(IssueCode.LEG_POSTING_NOT_ALLOWED, + LedgerDiagnosticCode.LEDGER_STRUCT_040.message( + "Posting does not match any configured native leg: " //$NON-NLS-1$ + + posting.getType()), + entry).withPosting(posting)); + } + } + private static LegMatch matchLeg(LedgerEntry entry, LedgerEntryDefinition definition, LedgerLegDefinition leg, List issues) { From ab8867aa4c0734f04a4f07c3943a4ca4f80781c6 Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Sun, 5 Jul 2026 10:22:59 +0200 Subject: [PATCH 55/68] Validate Ledger corporate action interest components Add a reusable Ledger component requirement that lets native corporate action definitions require a detail leg on the same groupKey as a primary movement. Wire the rule into coupon payment cash legs and PIK interest target security legs, and validate missing or mismatched accrued-interest details. This is needed so mandatory interest-detail semantics are checked by definition metadata instead of being faked as detached required legs or kind-specific validator branches. Correction and reversal semantics, BASIS support, UI/report/importer work, projection behavior, protobuf schema, and legacy PTransaction mappings are intentionally left unchanged. --- ...ateActionMultiMovementPersistenceTest.java | 138 ++++++++++++++++++ .../ledger/LedgerEntryDefinitionTest.java | 40 +++++ ...gerNativeEntryDefinitionValidatorTest.java | 99 ++++++++++++- .../configuration/LedgerEntryDefinition.java | 30 +++- .../LedgerEntryDefinitionRegistry.java | 42 +++++- .../LedgerNativeEntryDefinitionValidator.java | 47 ++++++ .../rule/LedgerComponentRequirement.java | 66 +++++++++ 7 files changed, 454 insertions(+), 8 deletions(-) create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/rule/LedgerComponentRequirement.java diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java index f5a71ccc78..c4da5d11c6 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java @@ -159,6 +159,46 @@ public void testXmlAndProtobufRoundtripPreserveCashDistribution() throws Excepti assertNativeDefinitionValid(loadedProtoEntry); } + @Test + public void testXmlAndProtobufRoundtripPreserveCouponPaymentInterestDetail() throws Exception + { + var fixture = couponPaymentFixture(); + + assertCouponPaymentInterestDetail(fixture.entry()); + assertValid(fixture.client()); + assertNativeDefinitionValid(fixture.entry()); + + var xml = saveXml(fixture.client()); + + assertNoLedgerUuidTruth(xml); + + var loadedFromXml = loadXml(xml); + var loadedXmlEntry = onlyLedgerEntry(loadedFromXml); + + assertCouponPaymentInterestDetail(loadedXmlEntry); + assertValid(loadedFromXml); + assertNativeDefinitionValid(loadedXmlEntry); + + var bytes = ProtobufTestUtilities.save(fixture.client()); + var proto = parseProto(bytes); + var protoEntry = proto.getLedger().getEntries(0); + + assertThat(protoEntry.getTypeCode(), is(LedgerEntryType.CORPORATE_ACTION.getCode())); + assertTrue(protoEntry.getParametersList().stream() + .anyMatch(parameter -> LedgerParameterType.CORPORATE_ACTION_KIND.getCode() + .equals(parameter.getTypeCode()) + && CorporateActionKind.COUPON_PAYMENT.getCode() + .equals(parameter.getStringValue()))); + assertNoCorporateActionSpecificLegacyTransactionType(proto); + + var loadedFromProto = ProtobufTestUtilities.load(bytes); + var loadedProtoEntry = onlyLedgerEntry(loadedFromProto); + + assertCouponPaymentInterestDetail(loadedProtoEntry); + assertValid(loadedFromProto); + assertNativeDefinitionValid(loadedProtoEntry); + } + private Fixture spinOffFixture() { var client = new Client(); @@ -291,6 +331,41 @@ private Fixture cashDistributionFixture() return new Fixture(client, entry); } + private Fixture couponPaymentFixture() + { + var client = new Client(); + var account = new Account(); + var portfolio = new Portfolio(); + var contextSecurity = new Security("Coupon Bond", CurrencyUnit.EUR); + + account.setName("Cash Account"); + account.setCurrencyCode(CurrencyUnit.EUR); + portfolio.setName("Portfolio"); + portfolio.setReferenceAccount(account); + account.setUpdatedAt(UPDATED_AT); + portfolio.setUpdatedAt(UPDATED_AT); + contextSecurity.setUpdatedAt(UPDATED_AT); + + client.addAccount(account); + client.addPortfolio(portfolio); + client.addSecurity(contextSecurity); + + var entry = new LedgerEntry("coupon-payment-interest-detail"); + + entry.setType(LedgerEntryType.CORPORATE_ACTION); + entry.setDateTime(DATE_TIME); + entry.setSource("COUPON_PAYMENT interest detail proof"); + entry.addParameter(LedgerParameter.ofCode(LedgerParameterType.CORPORATE_ACTION_KIND, + CorporateActionKind.COUPON_PAYMENT)); + entry.addPosting(securityContextPosting(portfolio, contextSecurity, "main", "context-1")); + entry.addPosting(cashPrimaryPosting(account, "cash-1", 55)); + entry.addPosting(accruedInterestPosting(account, "cash-1", "interest-1", 55)); + + client.getLedger().addEntry(entry); + + return new Fixture(client, entry); + } + private LedgerPosting securityPosting(Portfolio portfolio, Security security, LedgerPostingDirection direction, CorporateActionLeg leg, String groupKey, String localKey, long shares, long amount) { @@ -387,6 +462,36 @@ private LedgerPosting cashPrimaryPosting(Account account, String localKey, long return posting; } + private LedgerPosting accruedInterestPosting(Account account, String groupKey, String localKey, long amount) + { + var posting = new LedgerPosting("proof-" + localKey); + + posting.setType(LedgerPostingType.ACCRUED_INTEREST); + posting.setAccount(account); + posting.setAmount(Values.Amount.factorize(amount)); + posting.setCurrency(CurrencyUnit.EUR); + posting.setSemanticRole(LedgerPostingSemanticRole.ACCRUED_INTEREST); + posting.setDirection(LedgerPostingDirection.NEUTRAL); + posting.setCorporateActionLeg(CorporateActionLeg.ACCRUED_INTEREST); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + posting.setGroupKey(groupKey); + posting.setLocalKey(localKey); + posting.addParameter(LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_LEG, + CorporateActionLeg.ACCRUED_INTEREST.getCode())); + posting.addParameter(LedgerParameter.ofMoney(LedgerParameterType.ACCRUED_INTEREST_AMOUNT, + Money.of(CurrencyUnit.EUR, Values.Amount.factorize(amount)))); + posting.addParameter(LedgerParameter.ofDecimal(LedgerParameterType.COUPON_RATE, + new BigDecimal("4.25"))); + posting.addParameter(LedgerParameter.ofLocalDate(LedgerParameterType.INTEREST_PERIOD_START, + DATE_TIME.toLocalDate().minusMonths(6))); + posting.addParameter(LedgerParameter.ofLocalDate(LedgerParameterType.INTEREST_PERIOD_END, + DATE_TIME.toLocalDate())); + posting.addParameter(LedgerParameter.ofLocalDate(LedgerParameterType.PAYMENT_DATE, + DATE_TIME.toLocalDate())); + + return posting; + } + private LedgerPosting unitPosting(LedgerPostingType type, LedgerPostingSemanticRole semanticRole, LedgerPostingUnitRole unitRole, CorporateActionLeg leg, String groupKey, String localKey, long amount) @@ -490,6 +595,39 @@ private void assertCashDistribution(LedgerEntry entry) assertTrue(LedgerDescriptorTestSupport.descriptors(entry).isEmpty()); } + private void assertCouponPaymentInterestDetail(LedgerEntry entry) + { + assertThat(entry.getType(), is(LedgerEntryType.CORPORATE_ACTION)); + assertTrue(entry.getParameters().stream() + .anyMatch(parameter -> parameter.getType() == LedgerParameterType.CORPORATE_ACTION_KIND + && CorporateActionKind.COUPON_PAYMENT.getCode() + .equals(parameter.getValue()))); + assertThat(postings(entry, LedgerPostingType.SECURITY, LedgerPostingDirection.NEUTRAL, + CorporateActionLeg.SECURITY_CONTEXT).size(), is(1)); + + var cash = entry.getPostings().stream() + .filter(posting -> posting.getType() == LedgerPostingType.CASH) + .filter(posting -> "cash-1".equals(posting.getLocalKey())) //$NON-NLS-1$ + .findFirst().orElseThrow(); + var interest = entry.getPostings().stream() + .filter(posting -> posting.getType() == LedgerPostingType.ACCRUED_INTEREST) + .filter(posting -> "interest-1".equals(posting.getLocalKey())) //$NON-NLS-1$ + .findFirst().orElseThrow(); + + assertThat(cash.getGroupKey(), is("cash-1")); + assertThat(interest.getGroupKey(), is("cash-1")); + assertThat(interest.getCorporateActionLeg(), is(CorporateActionLeg.ACCRUED_INTEREST)); + assertTrue(interest.getParameters().stream() + .anyMatch(parameter -> parameter.getType() == LedgerParameterType.ACCRUED_INTEREST_AMOUNT)); + assertTrue(interest.getParameters().stream() + .anyMatch(parameter -> parameter.getType() == LedgerParameterType.COUPON_RATE)); + assertTrue(interest.getParameters().stream() + .anyMatch(parameter -> parameter.getType() == LedgerParameterType.INTEREST_PERIOD_START)); + assertTrue(interest.getParameters().stream() + .anyMatch(parameter -> parameter.getType() == LedgerParameterType.INTEREST_PERIOD_END)); + assertTrue(LedgerDescriptorTestSupport.descriptors(entry).isEmpty()); + } + private List postings(LedgerEntry entry, LedgerPostingType type, LedgerPostingDirection direction, CorporateActionLeg leg) { 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 index 8228d32325..4227920407 100644 --- 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 @@ -26,6 +26,7 @@ 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.LedgerComponentRequirement; 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.LedgerPrimaryMovement; @@ -138,6 +139,7 @@ public void testRuleKeysAreUniqueWithinDefinitionCategories() assertUniqueProjectionRules(definition); assertUniquePostingGroupRules(definition); assertUniqueAlternativeGroupRules(definition); + assertUniqueComponentRequirements(definition); assertUniqueLegDefinitions(definition); } } @@ -397,12 +399,20 @@ public void testCorporateActionKindDefinitionsRegisterRepresentableProfileSubset assertLeg(coupon, LedgerLegRole.CASH_LEG, LedgerPostingType.CASH, LedgerLegCardinality.AT_LEAST_ONE); assertLeg(coupon, LedgerLegRole.ACCRUED_INTEREST_LEG, LedgerPostingType.ACCRUED_INTEREST, LedgerLegCardinality.OPTIONAL); + assertComponentRequirement(coupon, "COUPON_PAYMENT_INTEREST_DETAIL", LedgerLegRole.CASH_LEG, //$NON-NLS-1$ + LedgerLegRole.ACCRUED_INTEREST_LEG); var pikInterest = LedgerEntryDefinitionRegistry .lookup(LedgerEntryType.CORPORATE_ACTION, CorporateActionKind.PIK_INTEREST) .orElseThrow(); assertLeg(pikInterest, LedgerLegRole.SECURITY_CONTEXT_LEG, LedgerPostingType.SECURITY, LedgerLegCardinality.AT_LEAST_ONE); + assertLeg(pikInterest, LedgerLegRole.TARGET_SECURITY_LEG, LedgerPostingType.SECURITY, + LedgerLegCardinality.AT_LEAST_ONE); + assertLeg(pikInterest, LedgerLegRole.ACCRUED_INTEREST_LEG, LedgerPostingType.ACCRUED_INTEREST, + LedgerLegCardinality.OPTIONAL); + assertComponentRequirement(pikInterest, "PIK_INTEREST_INTEREST_DETAIL", //$NON-NLS-1$ + LedgerLegRole.TARGET_SECURITY_LEG, LedgerLegRole.ACCRUED_INTEREST_LEG); var maturity = LedgerEntryDefinitionRegistry .lookup(LedgerEntryType.CORPORATE_ACTION, CorporateActionKind.MATURITY) @@ -414,6 +424,7 @@ public void testCorporateActionKindDefinitionsRegisterRepresentableProfileSubset assertLeg(maturity, LedgerLegRole.CASH_LEG, LedgerPostingType.CASH, LedgerLegCardinality.AT_LEAST_ONE); assertLeg(maturity, LedgerLegRole.PRINCIPAL_REDEMPTION_LEG, LedgerPostingType.PRINCIPAL_REDEMPTION, LedgerLegCardinality.AT_LEAST_ONE); + assertTrue(maturity.getComponentRequirements().isEmpty()); var conversion = LedgerEntryDefinitionRegistry .lookup(LedgerEntryType.CORPORATE_ACTION, CorporateActionKind.CONVERSION) @@ -424,6 +435,7 @@ public void testCorporateActionKindDefinitionsRegisterRepresentableProfileSubset LedgerLegCardinality.AT_LEAST_ONE); assertLeg(conversion, LedgerLegRole.TARGET_SECURITY_LEG, LedgerPostingType.SECURITY, LedgerLegCardinality.AT_LEAST_ONE); + assertTrue(conversion.getComponentRequirements().isEmpty()); for (var kind : new CorporateActionKind[] { CorporateActionKind.PARTIAL_REDEMPTION, CorporateActionKind.CALL, CorporateActionKind.PUT, CorporateActionKind.EXCHANGE }) @@ -704,6 +716,24 @@ private void assertPrimaryMovementGroup( assertTrue(name, false); } + private void assertComponentRequirement( + name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinition definition, String name, + LedgerLegRole primaryLegRole, LedgerLegRole componentLegRole) + { + for (var requirement : definition.getComponentRequirements()) + { + if (requirement.getName().equals(name)) + { + assertThat(requirement.getPrimaryLegRole(), is(primaryLegRole)); + assertThat(requirement.getComponentLegRole(), is(componentLegRole)); + assertThat(requirement.getRelation(), is(LedgerComponentRequirement.Relation.SAME_GROUP_KEY)); + return; + } + } + + assertTrue(name, false); + } + private boolean hasPostingRule(Iterable rules, LedgerPostingType postingType) { for (var rule : rules) @@ -770,6 +800,16 @@ private void assertUniqueAlternativeGroupRules( assertTrue(definition.getEntryType() + ": alternative group " + group.getName(), seen.add(group.getName())); } + private void assertUniqueComponentRequirements( + name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinition definition) + { + var seen = new HashSet(); + + for (var requirement : definition.getComponentRequirements()) + assertTrue(definition.getEntryType() + ": component requirement " + requirement.getName(), //$NON-NLS-1$ + seen.add(requirement.getName())); + } + private void assertUniqueLegDefinitions( name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinition definition) { diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java index 4dc8415a0b..a21fddfee4 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java @@ -208,7 +208,7 @@ public void testCouponPaymentDefinitionRequiresSecurityContextLeg() } @Test - public void testCouponPaymentDefinitionAcceptsSecurityContextAndCash() + public void testCouponPaymentDefinitionRejectsCashWithoutInterestDetail() { var fixture = fixture(); var entry = corporateActionEntry(CorporateActionKind.COUPON_PAYMENT); @@ -216,9 +216,106 @@ public void testCouponPaymentDefinitionAcceptsSecurityContextAndCash() entry.addPosting(securityContextPosting(fixture, "context-1")); //$NON-NLS-1$ entry.addPosting(cashPosting(fixture, "cash-1")); //$NON-NLS-1$ + assertIssue(entry, IssueCode.REQUIRED_COMPONENT_MISSING); + } + + @Test + public void testCouponPaymentDefinitionAcceptsCashWithSameGroupInterestDetail() + { + var fixture = fixture(); + var entry = corporateActionEntry(CorporateActionKind.COUPON_PAYMENT); + var interest = accruedInterestPosting(fixture, "interest-1"); //$NON-NLS-1$ + + interest.setGroupKey("cash-1"); //$NON-NLS-1$ + entry.addPosting(securityContextPosting(fixture, "context-1")); //$NON-NLS-1$ + entry.addPosting(cashPosting(fixture, "cash-1")); //$NON-NLS-1$ + entry.addPosting(interest); + assertOK(entry); } + @Test + public void testCouponPaymentDefinitionRejectsDifferentGroupInterestDetail() + { + var fixture = fixture(); + var entry = corporateActionEntry(CorporateActionKind.COUPON_PAYMENT); + + entry.addPosting(securityContextPosting(fixture, "context-1")); //$NON-NLS-1$ + entry.addPosting(cashPosting(fixture, "cash-1")); //$NON-NLS-1$ + entry.addPosting(accruedInterestPosting(fixture, "interest-1")); //$NON-NLS-1$ + + assertIssue(entry, IssueCode.REQUIRED_COMPONENT_MISSING); + } + + @Test + public void testCouponPaymentDefinitionRejectsCashWithoutGroupKey() + { + var fixture = fixture(); + var entry = corporateActionEntry(CorporateActionKind.COUPON_PAYMENT); + var cash = cashPosting(fixture, "cash-1"); //$NON-NLS-1$ + var interest = accruedInterestPosting(fixture, "interest-1"); //$NON-NLS-1$ + + cash.setGroupKey(null); + interest.setGroupKey("cash-1"); //$NON-NLS-1$ + entry.addPosting(securityContextPosting(fixture, "context-1")); //$NON-NLS-1$ + entry.addPosting(cash); + entry.addPosting(interest); + + assertIssue(entry, IssueCode.REQUIRED_COMPONENT_MISSING); + } + + @Test + public void testCouponPaymentDefinitionRejectsInterestWithoutCash() + { + var fixture = fixture(); + var entry = corporateActionEntry(CorporateActionKind.COUPON_PAYMENT); + + entry.addPosting(securityContextPosting(fixture, "context-1")); //$NON-NLS-1$ + entry.addPosting(accruedInterestPosting(fixture, "interest-1")); //$NON-NLS-1$ + + assertIssue(entry, IssueCode.LEG_CARDINALITY_VIOLATED); + } + + @Test + public void testPikInterestDefinitionRejectsTargetSecurityWithoutInterestDetail() + { + var fixture = fixture(); + var entry = corporateActionEntry(CorporateActionKind.PIK_INTEREST); + + entry.addPosting(securityContextPosting(fixture, "context-1")); //$NON-NLS-1$ + entry.addPosting(targetSecurityPosting(fixture, "target-1")); //$NON-NLS-1$ + + assertIssue(entry, IssueCode.REQUIRED_COMPONENT_MISSING); + } + + @Test + public void testPikInterestDefinitionAcceptsTargetSecurityWithSameGroupInterestDetail() + { + var fixture = fixture(); + var entry = corporateActionEntry(CorporateActionKind.PIK_INTEREST); + var interest = accruedInterestPosting(fixture, "interest-1"); //$NON-NLS-1$ + + interest.setGroupKey("main"); //$NON-NLS-1$ + entry.addPosting(securityContextPosting(fixture, "context-1")); //$NON-NLS-1$ + entry.addPosting(targetSecurityPosting(fixture, "target-1")); //$NON-NLS-1$ + entry.addPosting(interest); + + assertOK(entry); + } + + @Test + public void testPikInterestDefinitionRejectsDifferentGroupInterestDetail() + { + var fixture = fixture(); + var entry = corporateActionEntry(CorporateActionKind.PIK_INTEREST); + + entry.addPosting(securityContextPosting(fixture, "context-1")); //$NON-NLS-1$ + entry.addPosting(targetSecurityPosting(fixture, "target-1")); //$NON-NLS-1$ + entry.addPosting(accruedInterestPosting(fixture, "interest-1")); //$NON-NLS-1$ + + assertIssue(entry, IssueCode.REQUIRED_COMPONENT_MISSING); + } + @Test public void testCashDistributionAcceptsSecurityContextAndCash() { 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 index 347c5158e6..b09d34f28a 100644 --- 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 @@ -9,6 +9,7 @@ import name.abuchen.portfolio.model.LedgerDiagnosticCode; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.configuration.rule.LedgerComponentRequirement; 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; @@ -37,6 +38,7 @@ public final class LedgerEntryDefinition private final Set projectionRules; private final Set postingGroupRules; private final Set alternativeRequirementGroups; + private final Set componentRequirements; private final Set legDefinitions; private final Set postingTypes; private final Set entryParameterTypes; @@ -51,6 +53,7 @@ private LedgerEntryDefinition(LedgerEntryType entryType, LedgerNativeEntryShape Set postingParameterRules, Set projectionRules, Set postingGroupRules, Set alternativeRequirementGroups, + Set componentRequirements, Set legDefinitions, LedgerReportingClass reportingClass, LedgerPerformanceTreatment performanceTreatment, Set downstreamResultsNotPersisted) @@ -63,6 +66,7 @@ private LedgerEntryDefinition(LedgerEntryType entryType, LedgerNativeEntryShape this.projectionRules = copyRuleSet(projectionRules); this.postingGroupRules = copyRuleSet(postingGroupRules); this.alternativeRequirementGroups = copyRuleSet(alternativeRequirementGroups); + this.componentRequirements = copyRuleSet(componentRequirements); this.legDefinitions = copyLegDefinitions(legDefinitions); this.postingTypes = postingTypesFrom(this.postingRules); this.entryParameterTypes = parameterTypesFrom(this.entryParameterRules); @@ -81,7 +85,7 @@ static LedgerEntryDefinition of(LedgerEntryType entryType, LedgerNativeEntryShap { return new LedgerEntryDefinition(entryType, nativeShape, optionalPostingRules(postingTypes), optionalParameterRules(entryParameterTypes), optionalParameterRules(postingParameterTypes), - optionalProjectionRules(projectionRoles), Set.of(), Set.of(), Set.of(), reportingClass, + optionalProjectionRules(projectionRoles), Set.of(), Set.of(), Set.of(), Set.of(), reportingClass, performanceTreatment, downstreamResultsNotPersisted); } @@ -95,7 +99,7 @@ static LedgerEntryDefinition of(LedgerEntryType entryType, LedgerNativeEntryShap { return new LedgerEntryDefinition(entryType, nativeShape, postingRules, entryParameterRules, postingParameterRules, projectionRules, postingGroupRules, alternativeRequirementGroups, Set.of(), - reportingClass, performanceTreatment, downstreamResultsNotPersisted); + Set.of(), reportingClass, performanceTreatment, downstreamResultsNotPersisted); } static LedgerEntryDefinition of(LedgerEntryType entryType, LedgerNativeEntryShape nativeShape, @@ -108,7 +112,22 @@ static LedgerEntryDefinition of(LedgerEntryType entryType, LedgerNativeEntryShap { return new LedgerEntryDefinition(entryType, nativeShape, postingRules, entryParameterRules, postingParameterRules, projectionRules, postingGroupRules, alternativeRequirementGroups, - legDefinitions, reportingClass, performanceTreatment, downstreamResultsNotPersisted); + Set.of(), legDefinitions, reportingClass, performanceTreatment, downstreamResultsNotPersisted); + } + + static LedgerEntryDefinition of(LedgerEntryType entryType, LedgerNativeEntryShape nativeShape, + Set postingRules, Set entryParameterRules, + Set postingParameterRules, Set projectionRules, + Set postingGroupRules, + Set alternativeRequirementGroups, + Set componentRequirements, Set legDefinitions, + LedgerReportingClass reportingClass, LedgerPerformanceTreatment performanceTreatment, + Set downstreamResultsNotPersisted) + { + return new LedgerEntryDefinition(entryType, nativeShape, postingRules, entryParameterRules, + postingParameterRules, projectionRules, postingGroupRules, alternativeRequirementGroups, + componentRequirements, legDefinitions, reportingClass, performanceTreatment, + downstreamResultsNotPersisted); } public LedgerEntryType getEntryType() @@ -229,6 +248,11 @@ public Set getAlternativeRequirementGroups() return alternativeRequirementGroups; } + public Set getComponentRequirements() + { + return componentRequirements; + } + public Set getLegDefinitions() { return legDefinitions; 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 index ec11ac1b73..856a3ff982 100644 --- 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 @@ -1,10 +1,10 @@ package name.abuchen.portfolio.model.ledger.configuration; +import java.util.ArrayList; 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; @@ -13,6 +13,7 @@ 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.LedgerComponentRequirement; 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; @@ -239,22 +240,26 @@ private static LedgerEntryDefinition rightsDistribution() private static LedgerEntryDefinition couponPayment() { - return corporateActionDefinition( + return corporateActionDefinitionWithComponents( SETS.legDefinitions(securityContextLeg(LedgerLegCardinality.AT_LEAST_ONE), cashLeg(LedgerLegCardinality.AT_LEAST_ONE), accruedInterestLeg(LedgerLegCardinality.OPTIONAL), feeLeg(), taxLeg(), forexLeg()), + SETS.componentRequirements(interestComponentRequirement( + "COUPON_PAYMENT_INTEREST_DETAIL", LedgerLegRole.CASH_LEG)), //$NON-NLS-1$ LedgerReportingClass.FIXED_INCOME_COUPON, LedgerPerformanceTreatment.INCOME_DISTRIBUTION); } private static LedgerEntryDefinition pikInterest() { - return corporateActionDefinition( + return corporateActionDefinitionWithComponents( SETS.legDefinitions(securityContextLeg(LedgerLegCardinality.AT_LEAST_ONE), targetSecurityLeg(LedgerLegCardinality.AT_LEAST_ONE), cashCompensationLeg(), accruedInterestLeg(LedgerLegCardinality.OPTIONAL), feeLeg(), taxLeg(), forexLeg()), + SETS.componentRequirements(interestComponentRequirement( + "PIK_INTEREST_INTEREST_DETAIL", LedgerLegRole.TARGET_SECURITY_LEG)), //$NON-NLS-1$ LedgerReportingClass.FIXED_INCOME_COUPON, LedgerPerformanceTreatment.INCOME_DISTRIBUTION); } @@ -364,12 +369,29 @@ private static LedgerEntryDefinition defaultAction() private static LedgerEntryDefinition corporateActionDefinition(Set legDefinitions, LedgerReportingClass reportingClass, LedgerPerformanceTreatment performanceTreatment) { - return corporateActionDefinition(legDefinitions, Set.of(), reportingClass, performanceTreatment); + return corporateActionDefinition(legDefinitions, Set.of(), Set.of(), reportingClass, performanceTreatment); + } + + private static LedgerEntryDefinition corporateActionDefinitionWithComponents(Set legDefinitions, + Set componentRequirements, LedgerReportingClass reportingClass, + LedgerPerformanceTreatment performanceTreatment) + { + return corporateActionDefinition(legDefinitions, Set.of(), componentRequirements, reportingClass, + performanceTreatment); } private static LedgerEntryDefinition corporateActionDefinition(Set legDefinitions, Set alternativeRequirementGroups, LedgerReportingClass reportingClass, LedgerPerformanceTreatment performanceTreatment) + { + return corporateActionDefinition(legDefinitions, alternativeRequirementGroups, Set.of(), reportingClass, + performanceTreatment); + } + + private static LedgerEntryDefinition corporateActionDefinition(Set legDefinitions, + Set alternativeRequirementGroups, + Set componentRequirements, LedgerReportingClass reportingClass, + LedgerPerformanceTreatment performanceTreatment) { return LedgerEntryDefinition.of(LedgerEntryType.CORPORATE_ACTION, LedgerNativeEntryShape.DUAL_INSTRUMENT_PLUS_ACCOUNT, @@ -379,6 +401,7 @@ private static LedgerEntryDefinition corporateActionDefinition(Set primaryMovementGroup(String name, Led SETS.primaryMovements(first, rest))); } + private static LedgerComponentRequirement interestComponentRequirement(String name, LedgerLegRole primaryLegRole) + { + return LedgerComponentRequirement.sameGroupKey(name, primaryLegRole, LedgerLegRole.ACCRUED_INTEREST_LEG); + } + private static EnumSet requiredSecurityLegParameters() { return SETS.parameterTypes(LedgerParameterType.CORPORATE_ACTION_LEG, @@ -790,6 +818,12 @@ private Set alternativeGroups(LedgerRequirementGroup fir return setOf(first, rest); } + private Set componentRequirements(LedgerComponentRequirement first, + LedgerComponentRequirement... rest) + { + return setOf(first, rest); + } + private Set legDefinitions(LedgerLegDefinition first, LedgerLegDefinition... rest) { return setOf(first, rest); diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java index dd7adf95ad..419f3a5573 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java @@ -37,6 +37,7 @@ public enum IssueCode ENTRY_PARAMETER_NOT_ALLOWED, REQUIRED_ALTERNATIVE_GROUP_MISSING, REQUIRED_PRIMARY_MOVEMENT_MISSING, + REQUIRED_COMPONENT_MISSING, POSTING_TYPE_NOT_ALLOWED, LEG_CARDINALITY_VIOLATED, LEG_POSTING_NOT_ALLOWED, @@ -94,6 +95,7 @@ public static ValidationResult validate(LedgerEntry entry) validatePostingTypes(entry, definition.get(), issues); validateAlternativeGroups(entry, definition.get(), issues); validateLegs(entry, definition.get(), issues); + validateComponentRequirements(entry, definition.get(), issues); return new ValidationResult(issues); } @@ -210,6 +212,51 @@ private static void validateLegs(LedgerEntry entry, LedgerEntryDefinition defini } } + private static void validateComponentRequirements(LedgerEntry entry, LedgerEntryDefinition definition, + List issues) + { + for (var requirement : definition.getComponentRequirements()) + { + var primaryLeg = definition.getLegDefinition(requirement.getPrimaryLegRole()); + var componentLeg = definition.getLegDefinition(requirement.getComponentLegRole()); + + if (primaryLeg.isEmpty() || componentLeg.isEmpty()) + continue; + + var primaryPostings = entry.getPostings().stream() + .filter(posting -> postingMatchesLeg(entry.getType(), posting, primaryLeg.get())) + .toList(); + var componentPostings = entry.getPostings().stream() + .filter(posting -> postingMatchesLeg(entry.getType(), posting, componentLeg.get())) + .toList(); + + for (var primaryPosting : primaryPostings) + { + if (isBlank(primaryPosting.getGroupKey()) || componentPostings.stream() + .noneMatch(componentPosting -> sameNonBlankGroupKey(primaryPosting, + componentPosting))) + issues.add(issue(IssueCode.REQUIRED_COMPONENT_MISSING, + LedgerDiagnosticCode.LEDGER_STRUCT_041.message( + "Required native component detail is missing: " //$NON-NLS-1$ + + requirement.getName()), + entry) + .withPosting(primaryPosting) + .withDetail("componentRequirement", requirement.getName()) //$NON-NLS-1$ + .withDetail("primaryLegRole", requirement.getPrimaryLegRole()) //$NON-NLS-1$ + .withDetail("componentLegRole", //$NON-NLS-1$ + requirement.getComponentLegRole()) + .withDetail("groupKey", primaryPosting.getGroupKey())); //$NON-NLS-1$ + } + } + } + + private static boolean sameNonBlankGroupKey(LedgerPosting primaryPosting, LedgerPosting componentPosting) + { + var groupKey = primaryPosting.getGroupKey(); + + return !isBlank(groupKey) && groupKey.equals(componentPosting.getGroupKey()); + } + private static void validatePostingsMatchConfiguredLegs(LedgerEntry entry, LedgerEntryDefinition definition, List issues) { diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/rule/LedgerComponentRequirement.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/rule/LedgerComponentRequirement.java new file mode 100644 index 0000000000..3fef639b76 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/rule/LedgerComponentRequirement.java @@ -0,0 +1,66 @@ +package name.abuchen.portfolio.model.ledger.configuration.rule; + +import java.util.Objects; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.ledger.configuration.LedgerLegRole; + +/** + * Describes a required component/detail relation between two native Ledger legs. + */ +public final class LedgerComponentRequirement +{ + public enum Relation + { + SAME_GROUP_KEY + } + + private final String name; + private final LedgerLegRole primaryLegRole; + private final LedgerLegRole componentLegRole; + private final Relation relation; + + private LedgerComponentRequirement(String name, LedgerLegRole primaryLegRole, LedgerLegRole componentLegRole, + Relation relation) + { + this.name = requireName(name); + this.primaryLegRole = Objects.requireNonNull(primaryLegRole); + this.componentLegRole = Objects.requireNonNull(componentLegRole); + this.relation = Objects.requireNonNull(relation); + } + + public static LedgerComponentRequirement sameGroupKey(String name, LedgerLegRole primaryLegRole, + LedgerLegRole componentLegRole) + { + return new LedgerComponentRequirement(name, primaryLegRole, componentLegRole, Relation.SAME_GROUP_KEY); + } + + public String getName() + { + return name; + } + + public LedgerLegRole getPrimaryLegRole() + { + return primaryLegRole; + } + + public LedgerLegRole getComponentLegRole() + { + return componentLegRole; + } + + public Relation getRelation() + { + return relation; + } + + private static String requireName(String name) + { + if (name == null || name.isBlank()) + throw new IllegalArgumentException( + LedgerDiagnosticCode.LEDGER_CORE_026.message("Ledger component requirement name is required")); //$NON-NLS-1$ + + return name; + } +} From 57a389d4ac1b456b3bdeb4f8cd941d9b17d04e63 Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Sun, 5 Jul 2026 16:39:13 +0200 Subject: [PATCH 56/68] Add Ledger corporate action basis treatment Add entry-level corporate action basis treatment metadata with status and method code domains plus semantic allocation rows persisted through existing Ledger parameters. Validate provided percentage allocations by semantic leg role, local key, and optional group key so allocations do not depend on posting UUIDs, list order, projections, or legacy transaction types. Leave automatic cost-basis calculation, amount-allocation validation, provider/source categories, BASIS hard requirements, UI, importers, reports, tax logic, FIFO, correction, and reversal semantics unchanged. --- .../model/ledger/LedgerCodeTest.java | 4 + ...ateActionMultiMovementPersistenceTest.java | 62 +++++ .../ledger/LedgerEntryDefinitionTest.java | 29 +++ .../model/ledger/LedgerModelTest.java | 10 + ...gerNativeEntryDefinitionValidatorTest.java | 217 ++++++++++++++++++ .../ledger/LedgerParameterCodeDomainTest.java | 6 + .../CorporateActionBasisAllocation.java | 139 +++++++++++ .../CorporateActionBasisMethod.java | 43 ++++ .../CorporateActionBasisStatus.java | 40 ++++ .../LedgerEntryDefinitionRegistry.java | 20 +- .../LedgerEventParameterDefinition.java | 5 + .../LedgerNativeEntryDefinitionValidator.java | 182 +++++++++++++++ .../LedgerParameterCodeDomain.java | 4 + .../configuration/LedgerParameterType.java | 10 + 14 files changed, 762 insertions(+), 9 deletions(-) create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/CorporateActionBasisAllocation.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/CorporateActionBasisMethod.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/CorporateActionBasisStatus.java 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 index bf8a47487a..633379529d 100644 --- 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 @@ -16,6 +16,8 @@ import org.junit.Test; import name.abuchen.portfolio.model.ledger.configuration.CashCompensationKind; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionBasisMethod; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionBasisStatus; import name.abuchen.portfolio.model.ledger.configuration.CorporateActionKind; import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; import name.abuchen.portfolio.model.ledger.configuration.CorporateActionSubtype; @@ -125,6 +127,8 @@ private List allCodes() return Stream.of( CorporateActionLeg.values(), CorporateActionKind.values(), + CorporateActionBasisStatus.values(), + CorporateActionBasisMethod.values(), CorporateActionSubtype.values(), EventStage.values(), CashCompensationKind.values(), diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java index c4da5d11c6..292e48f2f0 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java @@ -25,9 +25,12 @@ import name.abuchen.portfolio.model.Portfolio; import name.abuchen.portfolio.model.ProtobufTestUtilities; import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionBasisMethod; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionBasisStatus; 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.LedgerLegRole; import name.abuchen.portfolio.model.ledger.configuration.LedgerNativeEntryDefinitionValidator; import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; @@ -238,6 +241,17 @@ private Fixture spinOffFixture() entry.addParameter(LedgerParameter.ofCode(LedgerParameterType.CORPORATE_ACTION_KIND, CorporateActionKind.SPIN_OFF)); entry.addParameter(LedgerParameter.ofLocalDate(LedgerParameterType.EFFECTIVE_DATE, DATE_TIME.toLocalDate())); + entry.addParameter(LedgerParameter.ofCode(LedgerParameterType.CORPORATE_ACTION_BASIS_STATUS, + CorporateActionBasisStatus.PROVIDED)); + entry.addParameter(LedgerParameter.ofCode(LedgerParameterType.CORPORATE_ACTION_BASIS_METHOD, + CorporateActionBasisMethod.PERCENTAGE_ALLOCATION)); + entry.addParameter(LedgerParameter.ofLocalDate(LedgerParameterType.CORPORATE_ACTION_BASIS_VALUATION_DATE, + DATE_TIME.toLocalDate())); + entry.addParameter(LedgerParameter.ofBoolean(LedgerParameterType.CORPORATE_ACTION_BASIS_MANUAL_OVERRIDE, + Boolean.TRUE)); + entry.addParameter(basisAllocation(LedgerLegRole.SECURITY_CONTEXT_LEG, "context-1", "main", "75")); + entry.addParameter(basisAllocation(LedgerLegRole.TARGET_SECURITY_LEG, "target-1", "main", "20")); + entry.addParameter(basisAllocation(LedgerLegRole.TARGET_SECURITY_LEG, "target-2", "main", "5")); entry.addPosting(spinOffSecurityPosting(portfolio, sourceSecurity, sourceSecurity, targetSecurityA, LedgerPostingDirection.OUTBOUND, CorporateActionLeg.SOURCE_SECURITY, "main", "source-1", @@ -512,6 +526,15 @@ private LedgerPosting unitPosting(LedgerPostingType type, LedgerPostingSemanticR return posting; } + private LedgerParameter basisAllocation(LedgerLegRole targetRole, String targetLocalKey, + String targetGroupKey, String percent) + { + return LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_BASIS_ALLOCATION, + CorporateActionBasisAllocation + .percentage(targetRole, targetLocalKey, targetGroupKey, new BigDecimal(percent)) + .toParameterValue()); + } + private void assertRepeatedSpinOff(LedgerEntry entry) { assertThat(entry.getType(), is(LedgerEntryType.CORPORATE_ACTION)); @@ -559,6 +582,45 @@ private void assertRepeatedSpinOff(LedgerEntry entry) assertThat(runtimeProjectionIds.size(), is(2)); assertTrue(runtimeProjectionIds.stream().anyMatch(id -> id.endsWith(":NEW_SECURITY_LEG:target-1"))); assertTrue(runtimeProjectionIds.stream().anyMatch(id -> id.endsWith(":NEW_SECURITY_LEG:target-2"))); + assertBasisTreatment(entry); + } + + private void assertBasisTreatment(LedgerEntry entry) + { + assertTrue(entry.getParameters().stream() + .anyMatch(parameter -> parameter.getType() == LedgerParameterType.CORPORATE_ACTION_BASIS_STATUS + && CorporateActionBasisStatus.PROVIDED.getCode() + .equals(parameter.getValue()))); + assertTrue(entry.getParameters().stream() + .anyMatch(parameter -> parameter.getType() == LedgerParameterType.CORPORATE_ACTION_BASIS_METHOD + && CorporateActionBasisMethod.PERCENTAGE_ALLOCATION.getCode() + .equals(parameter.getValue()))); + assertTrue(entry.getParameters().stream() + .anyMatch(parameter -> parameter.getType() == LedgerParameterType.CORPORATE_ACTION_BASIS_VALUATION_DATE)); + assertTrue(entry.getParameters().stream() + .anyMatch(parameter -> parameter.getType() == LedgerParameterType.CORPORATE_ACTION_BASIS_MANUAL_OVERRIDE + && Boolean.TRUE.equals(parameter.getValue()))); + + var allocations = entry.getParameters().stream() + .filter(parameter -> parameter.getType() == LedgerParameterType.CORPORATE_ACTION_BASIS_ALLOCATION) + .map(LedgerParameter::getValue) + .map(String.class::cast) + .map(CorporateActionBasisAllocation::parse) + .toList(); + + assertThat(allocations.size(), is(3)); + assertTrue(allocations.stream() + .anyMatch(allocation -> allocation.getTargetRole() == LedgerLegRole.SECURITY_CONTEXT_LEG + && "context-1".equals(allocation.getTargetLocalKey()) + && allocation.getPercent().orElseThrow().compareTo(new BigDecimal("75")) == 0)); + assertTrue(allocations.stream() + .anyMatch(allocation -> allocation.getTargetRole() == LedgerLegRole.TARGET_SECURITY_LEG + && "target-1".equals(allocation.getTargetLocalKey()) + && allocation.getPercent().orElseThrow().compareTo(new BigDecimal("20")) == 0)); + assertTrue(allocations.stream() + .anyMatch(allocation -> allocation.getTargetRole() == LedgerLegRole.TARGET_SECURITY_LEG + && "target-2".equals(allocation.getTargetLocalKey()) + && allocation.getPercent().orElseThrow().compareTo(new BigDecimal("5")) == 0)); } private void assertDefaultedInterestPrimaryMovement(LedgerEntry entry) 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 index 4227920407..28774757e6 100644 --- 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 @@ -11,6 +11,8 @@ import org.junit.Test; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionBasisMethod; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionBasisStatus; import name.abuchen.portfolio.model.ledger.configuration.CorporateActionKind; import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; import name.abuchen.portfolio.model.ledger.configuration.LedgerDownstreamResult; @@ -223,6 +225,10 @@ public void testSpinOffDefinitionDescribesNativeDataModel() 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.CORPORATE_ACTION_BASIS_STATUS)); + assertTrue(definition.getEntryParameterTypes().contains(LedgerParameterType.CORPORATE_ACTION_BASIS_METHOD)); + assertTrue(definition.getEntryParameterTypes() + .contains(LedgerParameterType.CORPORATE_ACTION_BASIS_ALLOCATION)); assertTrue(definition.getEntryParameterTypes().contains(LedgerParameterType.EX_DATE)); assertTrue(definition.getPostingParameterTypes().contains(LedgerParameterType.CORPORATE_ACTION_LEG)); assertTrue(definition.getPostingParameterTypes().contains(LedgerParameterType.SOURCE_SECURITY)); @@ -236,6 +242,9 @@ public void testSpinOffDefinitionDescribesNativeDataModel() assertOptionalPosting(definition, LedgerPostingType.TAX); assertOptionalPosting(definition, LedgerPostingType.FOREX); assertRequiredEntryParameter(definition, LedgerParameterType.CORPORATE_ACTION_KIND); + assertOptionalEntryParameter(definition, LedgerParameterType.CORPORATE_ACTION_BASIS_STATUS); + assertOptionalEntryParameter(definition, LedgerParameterType.CORPORATE_ACTION_BASIS_METHOD); + assertRepeatableParameter(definition, LedgerParameterType.CORPORATE_ACTION_BASIS_ALLOCATION); assertOptionalEntryParameter(definition, LedgerParameterType.EX_DATE); assertOptionalEntryParameter(definition, LedgerParameterType.EFFECTIVE_DATE); assertRequiredPostingParameter(definition, LedgerParameterType.CORPORATE_ACTION_LEG); @@ -554,6 +563,11 @@ public void testDeepResearchCriticalVocabularyIsCoveredByNativeDefinitions() } assertTrue(allEntryParameters.contains(LedgerParameterType.CORPORATE_ACTION_KIND)); + assertTrue(allEntryParameters.contains(LedgerParameterType.CORPORATE_ACTION_BASIS_STATUS)); + assertTrue(allEntryParameters.contains(LedgerParameterType.CORPORATE_ACTION_BASIS_METHOD)); + assertTrue(allEntryParameters.contains(LedgerParameterType.CORPORATE_ACTION_BASIS_ALLOCATION)); + assertTrue(allEntryParameters.contains(LedgerParameterType.CORPORATE_ACTION_BASIS_VALUATION_DATE)); + assertTrue(allEntryParameters.contains(LedgerParameterType.CORPORATE_ACTION_BASIS_MANUAL_OVERRIDE)); assertTrue(allEntryParameters.contains(LedgerParameterType.EVENT_STAGE)); assertTrue(allEntryParameters.contains(LedgerParameterType.RECORD_DATE)); assertTrue(allEntryParameters.contains(LedgerParameterType.PAYMENT_DATE)); @@ -587,6 +601,21 @@ public void testCorporateActionLegHasControlledCodeDomain() assertFalse(LedgerParameterType.CORPORATE_ACTION_LEG.supportsCode("SOURCE")); } + @Test + public void testCorporateActionBasisHasControlledCodeDomains() + { + assertThat(LedgerParameterType.CORPORATE_ACTION_BASIS_STATUS.getCodeDomain(), + is(LedgerParameterCodeDomain.CORPORATE_ACTION_BASIS_STATUS)); + assertThat(LedgerParameterType.CORPORATE_ACTION_BASIS_METHOD.getCodeDomain(), + is(LedgerParameterCodeDomain.CORPORATE_ACTION_BASIS_METHOD)); + assertTrue(LedgerParameterType.CORPORATE_ACTION_BASIS_STATUS + .supportsCode(CorporateActionBasisStatus.PROVIDED.getCode())); + assertTrue(LedgerParameterType.CORPORATE_ACTION_BASIS_METHOD + .supportsCode(CorporateActionBasisMethod.PERCENTAGE_ALLOCATION.getCode())); + assertFalse(LedgerParameterType.CORPORATE_ACTION_BASIS_STATUS.supportsCode("BROKER_PROVIDED")); + assertFalse(LedgerParameterType.CORPORATE_ACTION_BASIS_METHOD.supportsCode("BROKER_PROVIDED")); + } + /** * Checks the ledger rule scenario: definition layer allows partial native completeness. * Invalid entry shapes must be rejected before they can be stored. 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 index 0e0d40d695..26c791395c 100644 --- 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 @@ -308,6 +308,9 @@ public void testLedgerParameterTypePoliciesAreExplicit() LedgerParameterType.CASH_COMPENSATION_KIND, LedgerParameterType.FEE_REASON, LedgerParameterType.TAX_REASON, LedgerParameterType.CORPORATE_ACTION_KIND, LedgerParameterType.CORPORATE_ACTION_SUBTYPE, + LedgerParameterType.CORPORATE_ACTION_BASIS_STATUS, + LedgerParameterType.CORPORATE_ACTION_BASIS_METHOD, + LedgerParameterType.CORPORATE_ACTION_BASIS_ALLOCATION, LedgerParameterType.EVENT_REFERENCE, LedgerParameterType.EVENT_STAGE, LedgerParameterType.FRACTION_TREATMENT, LedgerParameterType.ROUNDING_MODE, LedgerParameterType.COST_ALLOCATION_METHOD, LedgerParameterType.QUOTATION_STYLE); @@ -315,6 +318,7 @@ public void testLedgerParameterTypePoliciesAreExplicit() LedgerParameterType.RECORD_DATE, LedgerParameterType.PAYMENT_DATE, LedgerParameterType.EFFECTIVE_DATE, LedgerParameterType.SETTLEMENT_DATE, LedgerParameterType.ELECTION_DEADLINE, + LedgerParameterType.CORPORATE_ACTION_BASIS_VALUATION_DATE, LedgerParameterType.INTEREST_PERIOD_START, LedgerParameterType.INTEREST_PERIOD_END); assertParameterTypePolicies(LedgerParameterType.Scope.CORPORATE_ACTION, ValueKind.SECURITY, @@ -341,6 +345,7 @@ public void testLedgerParameterTypePoliciesAreExplicit() assertParameterTypePolicies(LedgerParameterType.Scope.CORPORATE_ACTION, ValueKind.BOOLEAN, LedgerParameterType.CASH_IN_LIEU_APPLIED, LedgerParameterType.TAXABLE_DISTRIBUTION, + LedgerParameterType.CORPORATE_ACTION_BASIS_MANUAL_OVERRIDE, LedgerParameterType.MANUAL_VALUATION_OVERRIDE, LedgerParameterType.SAME_SECURITY_AS_SOURCE, LedgerParameterType.FRACTION_ROUNDED, LedgerParameterType.RECLAIMABLE_TAX, LedgerParameterType.WITHHOLDING_TAX, @@ -604,6 +609,11 @@ public void testEnumSkeletonContainsRequiredPostingAndProjectionShapes() LedgerParameterType.TAX_REASON, LedgerParameterType.CORPORATE_ACTION_KIND, LedgerParameterType.CORPORATE_ACTION_SUBTYPE, + LedgerParameterType.CORPORATE_ACTION_BASIS_STATUS, + LedgerParameterType.CORPORATE_ACTION_BASIS_METHOD, + LedgerParameterType.CORPORATE_ACTION_BASIS_ALLOCATION, + LedgerParameterType.CORPORATE_ACTION_BASIS_VALUATION_DATE, + LedgerParameterType.CORPORATE_ACTION_BASIS_MANUAL_OVERRIDE, LedgerParameterType.EVENT_REFERENCE, LedgerParameterType.EVENT_STAGE, LedgerParameterType.RECORD_DATE, diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java index a21fddfee4..4d37425021 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java @@ -16,6 +16,8 @@ import name.abuchen.portfolio.model.Portfolio; import name.abuchen.portfolio.model.Security; import name.abuchen.portfolio.model.ledger.configuration.CashCompensationKind; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionBasisMethod; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionBasisStatus; import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; import name.abuchen.portfolio.model.ledger.configuration.CorporateActionKind; import name.abuchen.portfolio.model.ledger.configuration.CorporateActionSubtype; @@ -24,6 +26,7 @@ import name.abuchen.portfolio.model.ledger.configuration.FractionTreatment; import name.abuchen.portfolio.model.ledger.configuration.LedgerNativeEntryDefinitionValidator; import name.abuchen.portfolio.model.ledger.configuration.LedgerNativeEntryDefinitionValidator.IssueCode; +import name.abuchen.portfolio.model.ledger.configuration.LedgerLegRole; import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; import name.abuchen.portfolio.model.ledger.configuration.RoundingModeCode; @@ -587,6 +590,173 @@ public void testForexDoesNotSatisfyPrimaryMovement() assertIssue(entry, IssueCode.REQUIRED_PRIMARY_MOVEMENT_MISSING); } + @Test + public void testBasisUnknownWithoutAllocationsIsAccepted() + { + var fixture = fixture(); + var entry = spinOffWithContextAndTarget(fixture); + + addBasisStatus(entry, CorporateActionBasisStatus.UNKNOWN); + + assertOK(entry); + } + + @Test + public void testBasisNotApplicableWithoutAllocationsIsAccepted() + { + var fixture = fixture(); + var entry = spinOffWithContextAndTarget(fixture); + + addBasisStatus(entry, CorporateActionBasisStatus.NOT_APPLICABLE); + + assertOK(entry); + } + + @Test + public void testBasisNotApplicableWithAllocationsIsRejected() + { + var fixture = fixture(); + var entry = spinOffWithContextAndTarget(fixture); + + addBasisStatus(entry, CorporateActionBasisStatus.NOT_APPLICABLE); + addBasisMethod(entry, CorporateActionBasisMethod.PERCENTAGE_ALLOCATION); + addBasisAllocation(entry, LedgerLegRole.SECURITY_CONTEXT_LEG, "context-1", "main", "100"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ + + assertIssue(entry, IssueCode.BASIS_ALLOCATION_NOT_ALLOWED); + } + + @Test + public void testBasisProvidedWithoutAllocationsIsRejected() + { + var fixture = fixture(); + var entry = spinOffWithContextAndTarget(fixture); + + addBasisStatus(entry, CorporateActionBasisStatus.PROVIDED); + addBasisMethod(entry, CorporateActionBasisMethod.PERCENTAGE_ALLOCATION); + + assertIssue(entry, IssueCode.BASIS_ALLOCATION_REQUIRED); + } + + @Test + public void testBasisPercentageAllocationTotalingOneHundredIsAccepted() + { + var fixture = fixture(); + var entry = spinOffWithContextAndTarget(fixture); + + addProvidedPercentageBasis(entry); + addBasisAllocation(entry, LedgerLegRole.SECURITY_CONTEXT_LEG, "context-1", "main", "80"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ + addBasisAllocation(entry, LedgerLegRole.TARGET_SECURITY_LEG, "target-1", "main", "20"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ + + assertOK(entry); + } + + @Test + public void testBasisPercentageAllocationAcrossTwoTargetsIsAccepted() + { + var fixture = fixture(); + var entry = spinOffWithContextAndTarget(fixture); + + entry.addPosting(spinOffTargetSecurityPosting(fixture, "target-2")); //$NON-NLS-1$ + addProvidedPercentageBasis(entry); + addBasisAllocation(entry, LedgerLegRole.SECURITY_CONTEXT_LEG, "context-1", "main", "75"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ + addBasisAllocation(entry, LedgerLegRole.TARGET_SECURITY_LEG, "target-1", "main", "20"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ + addBasisAllocation(entry, LedgerLegRole.TARGET_SECURITY_LEG, "target-2", "main", "5"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ + + assertOK(entry); + } + + @Test + public void testBasisPercentageAllocationTotalingNinetyIsRejected() + { + var fixture = fixture(); + var entry = spinOffWithContextAndTarget(fixture); + + addProvidedPercentageBasis(entry); + addBasisAllocation(entry, LedgerLegRole.SECURITY_CONTEXT_LEG, "context-1", "main", "70"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ + addBasisAllocation(entry, LedgerLegRole.TARGET_SECURITY_LEG, "target-1", "main", "20"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ + + assertIssue(entry, IssueCode.BASIS_PERCENT_TOTAL_INVALID); + } + + @Test + public void testBasisPercentageAllocationTotalingOneHundredTenIsRejected() + { + var fixture = fixture(); + var entry = spinOffWithContextAndTarget(fixture); + + addProvidedPercentageBasis(entry); + addBasisAllocation(entry, LedgerLegRole.SECURITY_CONTEXT_LEG, "context-1", "main", "90"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ + addBasisAllocation(entry, LedgerLegRole.TARGET_SECURITY_LEG, "target-1", "main", "20"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ + + assertIssue(entry, IssueCode.BASIS_PERCENT_TOTAL_INVALID); + } + + @Test + public void testBasisNegativePercentIsRejected() + { + var fixture = fixture(); + var entry = spinOffWithContextAndTarget(fixture); + + addProvidedPercentageBasis(entry); + addBasisAllocation(entry, LedgerLegRole.SECURITY_CONTEXT_LEG, "context-1", "main", "110"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ + addBasisAllocation(entry, LedgerLegRole.TARGET_SECURITY_LEG, "target-1", "main", "-10"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ + + assertIssue(entry, IssueCode.BASIS_PERCENT_INVALID); + } + + @Test + public void testBasisUnknownAllocationTargetIsRejected() + { + var fixture = fixture(); + var entry = spinOffWithContextAndTarget(fixture); + + addProvidedPercentageBasis(entry); + addBasisAllocation(entry, LedgerLegRole.SECURITY_CONTEXT_LEG, "missing", "main", "100"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ + + assertIssue(entry, IssueCode.BASIS_ALLOCATION_TARGET_NOT_FOUND); + } + + @Test + public void testBasisDuplicateAllocationTargetIsRejected() + { + var fixture = fixture(); + var entry = spinOffWithContextAndTarget(fixture); + + addProvidedPercentageBasis(entry); + addBasisAllocation(entry, LedgerLegRole.TARGET_SECURITY_LEG, "target-1", "main", "50"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ + addBasisAllocation(entry, LedgerLegRole.TARGET_SECURITY_LEG, "target-1", "main", "50"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ + + assertIssue(entry, IssueCode.BASIS_ALLOCATION_TARGET_DUPLICATE); + } + + @Test + public void testBasisDoesNotSatisfyPrimaryMovement() + { + var fixture = fixture(); + var entry = corporateActionEntry(CorporateActionKind.DEFAULT); + + entry.addPosting(securityContextPosting(fixture, "context-1")); //$NON-NLS-1$ + addProvidedPercentageBasis(entry); + addBasisAllocation(entry, LedgerLegRole.SECURITY_CONTEXT_LEG, "context-1", "main", "100"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ + + assertIssue(entry, IssueCode.REQUIRED_PRIMARY_MOVEMENT_MISSING); + } + + @Test + public void testConversionBasisAllocationResolvesSemanticTarget() + { + var fixture = fixture(); + var entry = corporateActionEntry(CorporateActionKind.CONVERSION); + + entry.addPosting(securityContextPosting(fixture, "context-1")); //$NON-NLS-1$ + entry.addPosting(sourceSecurityPosting(fixture, "source-1")); //$NON-NLS-1$ + entry.addPosting(targetSecurityPosting(fixture, "target-1")); //$NON-NLS-1$ + addProvidedPercentageBasis(entry); + addBasisAllocation(entry, LedgerLegRole.TARGET_SECURITY_LEG, "target-1", "main", "100"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ + + assertOK(entry); + } + /** * Checks that repeated source legs follow the same semantic-key policy as * repeated target and cash movement legs. @@ -1025,6 +1195,16 @@ private static LedgerPosting targetSecurityPosting(Fixture fixture, String local return posting; } + private static LedgerPosting spinOffTargetSecurityPosting(Fixture fixture, String localKey) + { + var posting = targetSecurityPosting(fixture, localKey); + + posting.addParameter(LedgerParameter.ofDecimal(LedgerParameterType.RATIO_NUMERATOR, BigDecimal.ONE)); + posting.addParameter(LedgerParameter.ofDecimal(LedgerParameterType.RATIO_DENOMINATOR, BigDecimal.TEN)); + + return posting; + } + private static LedgerPosting sourceSecurityPosting(Fixture fixture, String localKey) { var posting = securityPosting(fixture, localKey, LedgerPostingDirection.OUTBOUND, @@ -1114,6 +1294,43 @@ private static LedgerPosting accruedInterestPosting(Fixture fixture, String loca return posting; } + private static LedgerEntry spinOffWithContextAndTarget(Fixture fixture) + { + var entry = corporateActionEntry(CorporateActionKind.SPIN_OFF, + securityContextPosting(fixture, "context-1"), //$NON-NLS-1$ + spinOffTargetSecurityPosting(fixture, "target-1")); //$NON-NLS-1$ + + entry.addParameter(LedgerParameter.ofLocalDate(LedgerParameterType.EFFECTIVE_DATE, + LocalDate.of(2026, 1, 2))); + + return entry; + } + + private static void addProvidedPercentageBasis(LedgerEntry entry) + { + addBasisStatus(entry, CorporateActionBasisStatus.PROVIDED); + addBasisMethod(entry, CorporateActionBasisMethod.PERCENTAGE_ALLOCATION); + } + + private static void addBasisStatus(LedgerEntry entry, CorporateActionBasisStatus status) + { + entry.addParameter(LedgerParameter.ofCode(LedgerParameterType.CORPORATE_ACTION_BASIS_STATUS, status)); + } + + private static void addBasisMethod(LedgerEntry entry, CorporateActionBasisMethod method) + { + entry.addParameter(LedgerParameter.ofCode(LedgerParameterType.CORPORATE_ACTION_BASIS_METHOD, method)); + } + + private static void addBasisAllocation(LedgerEntry entry, LedgerLegRole targetRole, String targetLocalKey, + String targetGroupKey, String percent) + { + entry.addParameter(LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_BASIS_ALLOCATION, + CorporateActionBasisAllocation + .percentage(targetRole, targetLocalKey, targetGroupKey, new BigDecimal(percent)) + .toParameterValue())); + } + private static LedgerPosting feePosting(Fixture fixture, String localKey) { var posting = unitPosting(fixture, localKey, LedgerPostingType.FEE, LedgerPostingSemanticRole.FEE, 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 index 850a0fd562..8b7a5113c8 100644 --- 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 @@ -35,6 +35,12 @@ public class LedgerParameterCodeDomainTest "PUT", "CONVERSION", "EXCHANGE", "RESTRUCTURING", "DEFAULT")), Map.entry(LedgerParameterCodeDomain.CORPORATE_ACTION_SUBTYPE, List.of("STANDARD", "OPTIONAL", "MANDATORY", "CASH_AND_STOCK", "OTHER")), + Map.entry(LedgerParameterCodeDomain.CORPORATE_ACTION_BASIS_STATUS, + List.of("NOT_APPLICABLE", "UNKNOWN", "PROVIDED")), + Map.entry(LedgerParameterCodeDomain.CORPORATE_ACTION_BASIS_METHOD, + List.of("UNSPECIFIED", "PERCENTAGE_ALLOCATION", "AMOUNT_ALLOCATION", + "REFERENCE_PRICE_RATIO", "FAIR_MARKET_VALUE_RATIO", + "MANUAL_OVERRIDE")), Map.entry(LedgerParameterCodeDomain.EVENT_STAGE, List.of("ANNOUNCED", "RECORD", "EX_DATE", "PAYMENT", "ISSUED", "EXERCISED", "SOLD", "EXPIRED", "SETTLED", "OTHER")), diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/CorporateActionBasisAllocation.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/CorporateActionBasisAllocation.java new file mode 100644 index 0000000000..530aea901b --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/CorporateActionBasisAllocation.java @@ -0,0 +1,139 @@ +package name.abuchen.portfolio.model.ledger; + +import java.math.BigDecimal; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.ledger.configuration.LedgerLegRole; + +/** + * Encodes one entry-level Corporate Action basis allocation row as a stable Ledger + * string parameter value. + */ +public final class CorporateActionBasisAllocation +{ + private static final String ROLE = "role"; //$NON-NLS-1$ + private static final String LOCAL_KEY = "localKey"; //$NON-NLS-1$ + private static final String GROUP_KEY = "groupKey"; //$NON-NLS-1$ + private static final String PERCENT = "percent"; //$NON-NLS-1$ + + private final LedgerLegRole targetRole; + private final String targetLocalKey; + private final String targetGroupKey; + private final BigDecimal percent; + + private CorporateActionBasisAllocation(LedgerLegRole targetRole, String targetLocalKey, String targetGroupKey, + BigDecimal percent) + { + this.targetRole = Objects.requireNonNull(targetRole); + this.targetLocalKey = requireNonBlank(targetLocalKey, "Basis allocation target localKey is required"); //$NON-NLS-1$ + this.targetGroupKey = blankToNull(targetGroupKey); + this.percent = percent; + } + + public static CorporateActionBasisAllocation percentage(LedgerLegRole targetRole, String targetLocalKey, + String targetGroupKey, BigDecimal percent) + { + return new CorporateActionBasisAllocation(targetRole, targetLocalKey, targetGroupKey, + Objects.requireNonNull(percent)); + } + + public static CorporateActionBasisAllocation parse(String value) + { + var fields = parseFields(value); + var role = LedgerLegRole.valueOf(required(fields, ROLE)); + var localKey = required(fields, LOCAL_KEY); + var groupKey = fields.get(GROUP_KEY); + var percentValue = fields.get(PERCENT); + var percent = percentValue == null || percentValue.isBlank() ? null : new BigDecimal(percentValue); + + return new CorporateActionBasisAllocation(role, localKey, groupKey, percent); + } + + public LedgerLegRole getTargetRole() + { + return targetRole; + } + + public String getTargetLocalKey() + { + return targetLocalKey; + } + + public Optional getTargetGroupKey() + { + return Optional.ofNullable(targetGroupKey); + } + + public Optional getPercent() + { + return Optional.ofNullable(percent); + } + + public String toParameterValue() + { + var fields = new LinkedHashMap(); + + fields.put(ROLE, targetRole.name()); + fields.put(LOCAL_KEY, targetLocalKey); + + if (targetGroupKey != null) + fields.put(GROUP_KEY, targetGroupKey); + + if (percent != null) + fields.put(PERCENT, percent.toPlainString()); + + return encode(fields); + } + + private static String encode(LinkedHashMap fields) + { + return fields.entrySet().stream() + .map(entry -> entry.getKey() + "=" + entry.getValue()) //$NON-NLS-1$ + .reduce((left, right) -> left + ";" + right) //$NON-NLS-1$ + .orElseThrow(); + } + + private static Map parseFields(String value) + { + var fields = new LinkedHashMap(); + + if (value == null || value.isBlank()) + throw new IllegalArgumentException( + LedgerDiagnosticCode.LEDGER_CORE_026.message("Basis allocation value is required")); //$NON-NLS-1$ + + for (var token : value.split(";")) //$NON-NLS-1$ + { + var separator = token.indexOf('='); + + if (separator <= 0) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CORE_026 + .message("Basis allocation field is malformed: " + token)); //$NON-NLS-1$ + + fields.put(token.substring(0, separator), token.substring(separator + 1)); + } + + return fields; + } + + private static String required(Map fields, String key) + { + return requireNonBlank(fields.get(key), "Basis allocation field is required: " + key); //$NON-NLS-1$ + } + + private static String requireNonBlank(String value, String message) + { + if (value == null || value.isBlank()) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CORE_026.message(message)); + + return value; + } + + private static String blankToNull(String value) + { + return value == null || value.isBlank() ? null : value; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/CorporateActionBasisMethod.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/CorporateActionBasisMethod.java new file mode 100644 index 0000000000..c76f7cf29a --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/CorporateActionBasisMethod.java @@ -0,0 +1,43 @@ +package name.abuchen.portfolio.model.ledger.configuration; + +/** + * Defines persisted Corporate Action basis treatment method codes. + */ +@SuppressWarnings("nls") +public enum CorporateActionBasisMethod implements LedgerCode +{ + UNSPECIFIED("UNSPECIFIED"), + PERCENTAGE_ALLOCATION("PERCENTAGE_ALLOCATION"), + AMOUNT_ALLOCATION("AMOUNT_ALLOCATION"), + REFERENCE_PRICE_RATIO("REFERENCE_PRICE_RATIO"), + FAIR_MARKET_VALUE_RATIO("FAIR_MARKET_VALUE_RATIO"), + MANUAL_OVERRIDE("MANUAL_OVERRIDE"); + + private final String code; + + private CorporateActionBasisMethod(String code) + { + this.code = code; + } + + @Override + public LedgerParameterCodeDomain getDomain() + { + return LedgerParameterCodeDomain.CORPORATE_ACTION_BASIS_METHOD; + } + + @Override + public String getCode() + { + return code; + } + + public static CorporateActionBasisMethod valueOfCode(String code) + { + for (var value : values()) + if (value.code.equals(code)) + return value; + + throw new IllegalArgumentException("Unknown CorporateActionBasisMethod code: " + code); //$NON-NLS-1$ + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/CorporateActionBasisStatus.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/CorporateActionBasisStatus.java new file mode 100644 index 0000000000..f55e9c3a7b --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/CorporateActionBasisStatus.java @@ -0,0 +1,40 @@ +package name.abuchen.portfolio.model.ledger.configuration; + +/** + * Defines persisted Corporate Action basis treatment status codes. + */ +@SuppressWarnings("nls") +public enum CorporateActionBasisStatus implements LedgerCode +{ + NOT_APPLICABLE("NOT_APPLICABLE"), + UNKNOWN("UNKNOWN"), + PROVIDED("PROVIDED"); + + private final String code; + + private CorporateActionBasisStatus(String code) + { + this.code = code; + } + + @Override + public LedgerParameterCodeDomain getDomain() + { + return LedgerParameterCodeDomain.CORPORATE_ACTION_BASIS_STATUS; + } + + @Override + public String getCode() + { + return code; + } + + public static CorporateActionBasisStatus valueOfCode(String code) + { + for (var value : values()) + if (value.code.equals(code)) + return value; + + throw new IllegalArgumentException("Unknown CorporateActionBasisStatus code: " + code); //$NON-NLS-1$ + } +} 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 index 856a3ff982..b531457e79 100644 --- 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 @@ -153,15 +153,7 @@ private static LedgerEntryDefinition spinOff() 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)), + corporateActionEntryParameterRules(), SETS.parameterRules(repeatableRequiredPostingParameter(LedgerParameterType.CORPORATE_ACTION_LEG), repeatableRequiredPostingParameter(LedgerParameterType.SOURCE_SECURITY), repeatableRequiredPostingParameter(LedgerParameterType.TARGET_SECURITY), @@ -428,6 +420,11 @@ private static Set corporateActionEntryParameterRules() return SETS.parameterRules(requiredEntryParameter(LedgerParameterType.CORPORATE_ACTION_KIND), optionalEntryParameter(LedgerParameterType.EX_DATE), optionalEntryParameter(LedgerParameterType.CORPORATE_ACTION_SUBTYPE), + optionalEntryParameter(LedgerParameterType.CORPORATE_ACTION_BASIS_STATUS), + optionalEntryParameter(LedgerParameterType.CORPORATE_ACTION_BASIS_METHOD), + repeatableOptionalEntryParameter(LedgerParameterType.CORPORATE_ACTION_BASIS_ALLOCATION), + optionalEntryParameter(LedgerParameterType.CORPORATE_ACTION_BASIS_VALUATION_DATE), + optionalEntryParameter(LedgerParameterType.CORPORATE_ACTION_BASIS_MANUAL_OVERRIDE), optionalEntryParameter(LedgerParameterType.EVENT_REFERENCE), optionalEntryParameter(LedgerParameterType.EVENT_STAGE), optionalEntryParameter(LedgerParameterType.RECORD_DATE), @@ -611,6 +608,11 @@ private static LedgerParameterRule repeatableRequiredPostingParameter(LedgerPara return LedgerParameterRule.repeatable(parameterType, LedgerRequirement.REQUIRED); } + private static LedgerParameterRule repeatableOptionalEntryParameter(LedgerParameterType parameterType) + { + return LedgerParameterRule.repeatable(parameterType, LedgerRequirement.OPTIONAL); + } + private static LedgerParameterRule repeatableOptionalPostingParameter(LedgerParameterType parameterType) { return LedgerParameterRule.repeatable(parameterType, LedgerRequirement.OPTIONAL); 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 index 1868bffe53..d148948d14 100644 --- 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 @@ -19,6 +19,11 @@ public final class LedgerEventParameterDefinition private static final Set PARAMETER_TYPES = Collections .unmodifiableSet(EnumSet.of(LedgerParameterType.CORPORATE_ACTION_KIND, LedgerParameterType.CORPORATE_ACTION_SUBTYPE, + LedgerParameterType.CORPORATE_ACTION_BASIS_STATUS, + LedgerParameterType.CORPORATE_ACTION_BASIS_METHOD, + LedgerParameterType.CORPORATE_ACTION_BASIS_ALLOCATION, + LedgerParameterType.CORPORATE_ACTION_BASIS_VALUATION_DATE, + LedgerParameterType.CORPORATE_ACTION_BASIS_MANUAL_OVERRIDE, LedgerParameterType.EVENT_REFERENCE, LedgerParameterType.EVENT_STAGE, LedgerParameterType.EX_DATE, LedgerParameterType.RECORD_DATE, LedgerParameterType.PAYMENT_DATE, LedgerParameterType.EFFECTIVE_DATE, diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java index 419f3a5573..4830605aa8 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java @@ -1,8 +1,10 @@ package name.abuchen.portfolio.model.ledger.configuration; +import java.math.BigDecimal; import java.util.ArrayList; import java.util.Collections; import java.util.EnumSet; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -12,6 +14,7 @@ import java.util.stream.Collectors; import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.ledger.CorporateActionBasisAllocation; import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerParameter; import name.abuchen.portfolio.model.ledger.LedgerPosting; @@ -38,6 +41,14 @@ public enum IssueCode REQUIRED_ALTERNATIVE_GROUP_MISSING, REQUIRED_PRIMARY_MOVEMENT_MISSING, REQUIRED_COMPONENT_MISSING, + BASIS_STATUS_REQUIRED, + BASIS_ALLOCATION_NOT_ALLOWED, + BASIS_ALLOCATION_REQUIRED, + BASIS_ALLOCATION_INVALID, + BASIS_ALLOCATION_TARGET_NOT_FOUND, + BASIS_ALLOCATION_TARGET_DUPLICATE, + BASIS_PERCENT_INVALID, + BASIS_PERCENT_TOTAL_INVALID, POSTING_TYPE_NOT_ALLOWED, LEG_CARDINALITY_VIOLATED, LEG_POSTING_NOT_ALLOWED, @@ -96,6 +107,7 @@ public static ValidationResult validate(LedgerEntry entry) validateAlternativeGroups(entry, definition.get(), issues); validateLegs(entry, definition.get(), issues); validateComponentRequirements(entry, definition.get(), issues); + validateBasisTreatment(entry, definition.get(), issues); return new ValidationResult(issues); } @@ -257,6 +269,157 @@ private static boolean sameNonBlankGroupKey(LedgerPosting primaryPosting, Ledger return !isBlank(groupKey) && groupKey.equals(componentPosting.getGroupKey()); } + private static void validateBasisTreatment(LedgerEntry entry, LedgerEntryDefinition definition, + List issues) + { + if (entry.getType() != LedgerEntryType.CORPORATE_ACTION) + return; + + var statusValue = parameterValue(entry.getParameters(), LedgerParameterType.CORPORATE_ACTION_BASIS_STATUS); + var allocationParameters = parameterValues(entry.getParameters(), + LedgerParameterType.CORPORATE_ACTION_BASIS_ALLOCATION); + var method = parameterValue(entry.getParameters(), LedgerParameterType.CORPORATE_ACTION_BASIS_METHOD) + .map(CorporateActionBasisMethod::valueOfCode) + .orElse(CorporateActionBasisMethod.UNSPECIFIED); + + if (statusValue.isEmpty()) + { + if (!allocationParameters.isEmpty()) + issues.add(issue(IssueCode.BASIS_STATUS_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_041 + .message("Corporate Action basis status is required"), //$NON-NLS-1$ + entry)); + return; + } + + var status = CorporateActionBasisStatus.valueOfCode(statusValue.get()); + var allocations = parseBasisAllocations(entry, allocationParameters, issues); + + if (status == CorporateActionBasisStatus.NOT_APPLICABLE || status == CorporateActionBasisStatus.UNKNOWN) + { + if (!allocationParameters.isEmpty()) + issues.add(issue(IssueCode.BASIS_ALLOCATION_NOT_ALLOWED, + LedgerDiagnosticCode.LEDGER_STRUCT_041.message( + "Corporate Action basis allocations are not allowed for status " //$NON-NLS-1$ + + status), + entry)); + return; + } + + if (allocations.isEmpty()) + { + issues.add(issue(IssueCode.BASIS_ALLOCATION_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_041 + .message("Provided Corporate Action basis requires allocations"), //$NON-NLS-1$ + entry)); + return; + } + + validateBasisAllocationTargets(entry, definition, allocations, issues); + + if (method == CorporateActionBasisMethod.PERCENTAGE_ALLOCATION) + validateBasisPercentages(entry, allocations, issues); + } + + private static List parseBasisAllocations(LedgerEntry entry, + List values, List issues) + { + var allocations = new ArrayList(); + + for (var value : values) + { + try + { + allocations.add(CorporateActionBasisAllocation.parse(value)); + } + catch (RuntimeException e) + { + issues.add(issue(IssueCode.BASIS_ALLOCATION_INVALID, + LedgerDiagnosticCode.LEDGER_STRUCT_041.message( + "Corporate Action basis allocation is invalid: " + e.getMessage()), //$NON-NLS-1$ + entry).withDetail("basisAllocation", value)); //$NON-NLS-1$ + } + } + + return allocations; + } + + private static void validateBasisAllocationTargets(LedgerEntry entry, LedgerEntryDefinition definition, + List allocations, List issues) + { + var keys = new HashSet(); + + for (var allocation : allocations) + { + if (!isAllowedBasisTargetRole(allocation.getTargetRole()) || definition + .getLegDefinition(allocation.getTargetRole()).isEmpty()) + { + issues.add(basisTargetIssue(entry, allocation, IssueCode.BASIS_ALLOCATION_TARGET_NOT_FOUND, + "Corporate Action basis allocation target role is not configured")); //$NON-NLS-1$ + continue; + } + + var key = BasisAllocationTargetKey.of(allocation); + + if (!keys.add(key)) + issues.add(basisTargetIssue(entry, allocation, IssueCode.BASIS_ALLOCATION_TARGET_DUPLICATE, + "Corporate Action basis allocation target is duplicated")); //$NON-NLS-1$ + + var leg = definition.getLegDefinition(allocation.getTargetRole()).orElseThrow(); + var targetFound = entry.getPostings().stream() + .filter(posting -> postingMatchesLeg(entry.getType(), posting, leg)) + .filter(posting -> allocation.getTargetLocalKey().equals(posting.getLocalKey())) + .anyMatch(posting -> allocation.getTargetGroupKey().isEmpty() + || allocation.getTargetGroupKey().get().equals(posting.getGroupKey())); + + if (!targetFound) + issues.add(basisTargetIssue(entry, allocation, IssueCode.BASIS_ALLOCATION_TARGET_NOT_FOUND, + "Corporate Action basis allocation target was not found")); //$NON-NLS-1$ + } + } + + private static void validateBasisPercentages(LedgerEntry entry, List allocations, + List issues) + { + var total = BigDecimal.ZERO; + + for (var allocation : allocations) + { + var percent = allocation.getPercent(); + + if (percent.isEmpty() || percent.get().signum() < 0) + { + issues.add(basisTargetIssue(entry, allocation, IssueCode.BASIS_PERCENT_INVALID, + "Corporate Action basis allocation percent must be non-negative")); //$NON-NLS-1$ + continue; + } + + total = total.add(percent.get()); + } + + if (total.compareTo(new BigDecimal("100")) != 0) //$NON-NLS-1$ + issues.add(issue(IssueCode.BASIS_PERCENT_TOTAL_INVALID, + LedgerDiagnosticCode.LEDGER_STRUCT_041.message( + "Corporate Action basis percentage allocations must total 100"), //$NON-NLS-1$ + entry).withDetail("actualPercentTotal", total)); //$NON-NLS-1$ + } + + private static boolean isAllowedBasisTargetRole(LedgerLegRole role) + { + return role == LedgerLegRole.SECURITY_CONTEXT_LEG || role == LedgerLegRole.SOURCE_SECURITY_LEG + || role == LedgerLegRole.TARGET_SECURITY_LEG || role == LedgerLegRole.CASH_LEG + || role == LedgerLegRole.CASH_COMPENSATION_LEG; + } + + private static ValidationIssue basisTargetIssue(LedgerEntry entry, CorporateActionBasisAllocation allocation, + IssueCode code, String message) + { + return issue(code, LedgerDiagnosticCode.LEDGER_STRUCT_041.message(message), entry) + .withDetail("targetRole", allocation.getTargetRole()) //$NON-NLS-1$ + .withDetail("targetLocalKey", allocation.getTargetLocalKey()) //$NON-NLS-1$ + .withDetail("targetGroupKey", allocation.getTargetGroupKey().orElse(null)); //$NON-NLS-1$ + } + private static void validatePostingsMatchConfiguredLegs(LedgerEntry entry, LedgerEntryDefinition definition, List issues) { @@ -659,6 +822,16 @@ private static Optional parameterValue(List> paramete .findFirst(); } + private static List parameterValues(List> parameters, LedgerParameterType type) + { + return parameters.stream() // + .filter(parameter -> parameter.getType() == type) // + .map(LedgerParameter::getValue) // + .filter(String.class::isInstance) // + .map(String.class::cast) // + .toList(); + } + private static boolean isBlank(String value) { return value == null || value.isBlank(); @@ -690,6 +863,15 @@ private record LegMatch(List postings) } } + private record BasisAllocationTargetKey(LedgerLegRole role, String localKey, String groupKey) + { + private static BasisAllocationTargetKey of(CorporateActionBasisAllocation allocation) + { + return new BasisAllocationTargetKey(allocation.getTargetRole(), allocation.getTargetLocalKey(), + allocation.getTargetGroupKey().orElse(null)); + } + } + public static final class ValidationResult { private final List issues; 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 index 20e2924986..305c99fbf5 100644 --- 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 @@ -19,6 +19,8 @@ public enum LedgerParameterCodeDomain CORPORATE_ACTION_LEG, CORPORATE_ACTION_KIND, CORPORATE_ACTION_SUBTYPE, + CORPORATE_ACTION_BASIS_STATUS, + CORPORATE_ACTION_BASIS_METHOD, EVENT_STAGE, CASH_COMPENSATION_KIND, FRACTION_TREATMENT, @@ -35,6 +37,8 @@ public List getAllowedCodes() case CORPORATE_ACTION_LEG -> codes(CorporateActionLeg.values()); case CORPORATE_ACTION_KIND -> codes(CorporateActionKind.values()); case CORPORATE_ACTION_SUBTYPE -> codes(CorporateActionSubtype.values()); + case CORPORATE_ACTION_BASIS_STATUS -> codes(CorporateActionBasisStatus.values()); + case CORPORATE_ACTION_BASIS_METHOD -> codes(CorporateActionBasisMethod.values()); case EVENT_STAGE -> codes(EventStage.values()); case CASH_COMPENSATION_KIND -> codes(CashCompensationKind.values()); case FRACTION_TREATMENT -> codes(FractionTreatment.values()); 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 index 38d39390c1..1a816219f0 100644 --- 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 @@ -34,6 +34,16 @@ public enum LedgerParameterType LedgerParameterCodeDomain.CORPORATE_ACTION_KIND), CORPORATE_ACTION_SUBTYPE("CORPORATE_ACTION_SUBTYPE", Scope.CORPORATE_ACTION, ValueKind.STRING, LedgerParameterCodeDomain.CORPORATE_ACTION_SUBTYPE), + CORPORATE_ACTION_BASIS_STATUS("CORPORATE_ACTION_BASIS_STATUS", Scope.CORPORATE_ACTION, ValueKind.STRING, + LedgerParameterCodeDomain.CORPORATE_ACTION_BASIS_STATUS), + CORPORATE_ACTION_BASIS_METHOD("CORPORATE_ACTION_BASIS_METHOD", Scope.CORPORATE_ACTION, ValueKind.STRING, + LedgerParameterCodeDomain.CORPORATE_ACTION_BASIS_METHOD), + CORPORATE_ACTION_BASIS_ALLOCATION("CORPORATE_ACTION_BASIS_ALLOCATION", Scope.CORPORATE_ACTION, + ValueKind.STRING), + CORPORATE_ACTION_BASIS_VALUATION_DATE("CORPORATE_ACTION_BASIS_VALUATION_DATE", Scope.CORPORATE_ACTION, + ValueKind.LOCAL_DATE), + CORPORATE_ACTION_BASIS_MANUAL_OVERRIDE("CORPORATE_ACTION_BASIS_MANUAL_OVERRIDE", Scope.CORPORATE_ACTION, + ValueKind.BOOLEAN), 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), From f7a64fc95b9f2ca3c9b7be8db9c21eac9fd9f8fd Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Sun, 5 Jul 2026 17:16:43 +0200 Subject: [PATCH 57/68] Add Ledger corporate action fluent API Adds a contributor-facing corporate action builder on top of the native Ledger assembler, including helpers for security context, security in/out, cash, fee, tax, principal, accrued interest, event dates, and basis allocations. This gives tests and service code a semantic way to create and edit native corporate actions without constructing low-level postings or selecting by UUID. Projection generalization, UI, importers, reporting, automatic basis calculation, correction/reversal, and legacy transaction type changes are intentionally left unchanged. --- .../LedgerCorporateActionFluentApiTest.java | 249 ++++++++++ .../LedgerCorporateActionBuilder.java | 454 ++++++++++++++++++ .../LedgerCorporateActionEditSupport.java | 126 +++++ .../LedgerNativeEntryAssembler.java | 10 + 4 files changed, 839 insertions(+) create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionFluentApiTest.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionBuilder.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionEditSupport.java diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionFluentApiTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionFluentApiTest.java new file mode 100644 index 0000000000..0d7a28aadf --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionFluentApiTest.java @@ -0,0 +1,249 @@ +package name.abuchen.portfolio.model.ledger.nativeentry; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +import org.junit.Test; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.ledger.LedgerMutationContext; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionBasisMethod; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionBasisStatus; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionKind; +import name.abuchen.portfolio.model.ledger.configuration.LedgerLegRole; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.Values; + +@SuppressWarnings("nls") +public class LedgerCorporateActionFluentApiTest +{ + private static final LocalDateTime DATE = LocalDateTime.of(2020, 9, 28, 0, 0); + + @Test + public void testCreatesCashDistribution() + { + var fixture = fixture(); + var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.CASH_DISTRIBUTION) // + .date(DATE) // + .securityContext("context-1", "main", fixture.portfolio, fixture.source) // + .cash("cash-1", fixture.account, money(15)) // + .fee("fee-1", fixture.account, money(2), "cash-1") // + .tax("tax-1", fixture.account, money(1), "cash-1") // + .buildDetached().getEntry(); + + assertThat(entry.getPostings().size(), is(4)); + assertThat(posting(entry, LedgerLegRole.CASH_LEG, "cash-1").getAmount(), is(money(15).getAmount())); + } + + @Test + public void testCreatesCouponPaymentWithSameGroupInterestDetail() + { + var fixture = fixture(); + var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.COUPON_PAYMENT) // + .date(DATE) // + .securityContext("context-1", "main", fixture.portfolio, fixture.source) // + .cash("cash-1", fixture.account, money(12)) // + .accruedInterest("interest-1", "cash-1", fixture.account, money(12)) // + .buildDetached().getEntry(); + + assertThat(posting(entry, LedgerLegRole.CASH_LEG, "cash-1").getGroupKey(), is("cash-1")); + assertThat(posting(entry, LedgerLegRole.ACCRUED_INTEREST_LEG, "interest-1").getGroupKey(), is("cash-1")); + } + + @Test + public void testCouponPaymentDifferentGroupInterestFailsValidation() + { + var fixture = fixture(); + var exception = assertThrows(LedgerNativeEntryAssemblyException.class, + () -> LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.COUPON_PAYMENT) // + .date(DATE) // + .securityContext("context-1", "main", fixture.portfolio, fixture.source) // + .cash("cash-1", fixture.account, money(12)) // + .accruedInterest("interest-1", "other", fixture.account, money(12)) // + .buildDetached()); + + assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.NATIVE_DEFINITION_VALIDATION_FAILED)); + } + + @Test + public void testCreatesSpinOffWithRepeatedTargetsAndBasis() + { + var fixture = fixture(); + var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.SPIN_OFF) // + .date(DATE) // + .effectiveDate(LocalDate.of(2020, 9, 28)) // + .securityContext("context-1", "main", fixture.portfolio, fixture.source) // + .securityIn("target-1", "main", fixture.portfolio, fixture.target, Values.Share.factorize(5)) // + .securityIn("target-2", "main", fixture.portfolio, fixture.secondTarget, + Values.Share.factorize(2)) // + .cash("cash-1", fixture.account, money(3)) // + .basis(CorporateActionBasisStatus.PROVIDED) // + .basisMethod(CorporateActionBasisMethod.PERCENTAGE_ALLOCATION) // + .basisPercentageAllocation(LedgerLegRole.SECURITY_CONTEXT_LEG, "context-1", "main", + new BigDecimal("75")) // + .basisPercentageAllocation(LedgerLegRole.TARGET_SECURITY_LEG, "target-1", "main", + new BigDecimal("20")) // + .basisPercentageAllocation(LedgerLegRole.TARGET_SECURITY_LEG, "target-2", "main", + new BigDecimal("5")) // + .buildDetached().getEntry(); + + assertThat(posting(entry, LedgerLegRole.TARGET_SECURITY_LEG, "target-2").getSecurity(), + is(fixture.secondTarget)); + assertThat(entry.getPostings().stream().filter(posting -> posting.getLocalKey().startsWith("target-")) + .count(), is(2L)); + } + + @Test + public void testCreatesConversionWithBasis() + { + var fixture = fixture(); + var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.CONVERSION) // + .date(DATE) // + .securityContext("context-1", "main", fixture.portfolio, fixture.source) // + .securityOut("source-1", "main", fixture.portfolio, fixture.source, + Values.Share.factorize(10)) // + .securityIn("target-1", "main", fixture.portfolio, fixture.target, + Values.Share.factorize(5)) // + .basis(CorporateActionBasisStatus.PROVIDED) // + .basisMethod(CorporateActionBasisMethod.PERCENTAGE_ALLOCATION) // + .basisPercentageAllocation(LedgerLegRole.TARGET_SECURITY_LEG, "target-1", "main", + new BigDecimal("100")) // + .buildDetached().getEntry(); + + assertThat(posting(entry, LedgerLegRole.SOURCE_SECURITY_LEG, "source-1").getShares(), + is(Values.Share.factorize(10))); + } + + @Test + public void testCreatesMaturityWithPrincipal() + { + var fixture = fixture(); + var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.MATURITY) // + .date(DATE) // + .securityContext("context-1", "main", fixture.portfolio, fixture.source) // + .securityOut("source-1", "main", fixture.portfolio, fixture.source, + Values.Share.factorize(10)) // + .cash("cash-1", fixture.account, money(100)) // + .principal("principal-1", fixture.account, money(100)) // + .buildDetached().getEntry(); + + assertThat(posting(entry, LedgerLegRole.PRINCIPAL_REDEMPTION_LEG, "principal-1").getAmount(), + is(money(100).getAmount())); + } + + @Test + public void testSemanticEditUpdatesOnlySelectedRepeatedLeg() + { + var fixture = fixture(); + var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.SPIN_OFF) // + .date(DATE) // + .effectiveDate(LocalDate.of(2020, 9, 28)) // + .securityContext("context-1", "main", fixture.portfolio, fixture.source) // + .securityIn("target-1", "main", fixture.portfolio, fixture.target, Values.Share.factorize(5)) // + .securityIn("target-2", "main", fixture.portfolio, fixture.secondTarget, + Values.Share.factorize(2)) // + .buildAndAdd().getEntry(); + + LedgerCorporateActionEditSupport.mutatePosting(fixture.client, entry, LedgerLegRole.TARGET_SECURITY_LEG, + "target-2", "main", posting -> posting.setShares(Values.Share.factorize(8))); + + var liveEntry = fixture.client.getLedger().getEntries().get(0); + assertThat(posting(liveEntry, LedgerLegRole.TARGET_SECURITY_LEG, "target-1").getShares(), + is(Values.Share.factorize(5))); + assertThat(posting(liveEntry, LedgerLegRole.TARGET_SECURITY_LEG, "target-2").getShares(), + is(Values.Share.factorize(8))); + } + + @Test + public void testSecurityWithoutPortfolioFailsValidation() + { + var fixture = fixture(); + var exception = assertThrows(LedgerNativeEntryAssemblyException.class, + () -> LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.CASH_DISTRIBUTION) // + .date(DATE) // + .securityContext("context-1", "main", null, fixture.source) // + .cash("cash-1", fixture.account, money(15)) // + .buildDetached()); + + assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.STRUCTURAL_VALIDATION_FAILED)); + } + + @Test + public void testFluentCreatedEntryCanBeDeletedThroughNativeMutationContext() + { + var fixture = fixture(); + var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.SPIN_OFF) // + .date(DATE) // + .effectiveDate(LocalDate.of(2020, 9, 28)) // + .securityContext("context-1", "main", fixture.portfolio, fixture.source) // + .securityIn("target-1", "main", fixture.portfolio, fixture.target, Values.Share.factorize(5)) // + .cash("cash-1", fixture.account, money(3)) // + .buildAndAdd().getEntry(); + + assertThat(fixture.client.getLedger().getEntries().size(), is(1)); + + new LedgerMutationContext(fixture.client).removeEntry(entry); + + assertThat(fixture.client.getLedger().getEntries().size(), is(0)); + assertTrue(fixture.account.getTransactions().isEmpty()); + assertTrue(fixture.portfolio.getTransactions().isEmpty()); + } + + private static LedgerPosting posting(name.abuchen.portfolio.model.ledger.LedgerEntry entry, LedgerLegRole role, + String localKey) + { + return LedgerCorporateActionEditSupport.postingBySemanticKey(entry, role, localKey, null); + } + + private static Money money(long amount) + { + return Money.of(CurrencyUnit.EUR, Values.Amount.factorize(amount)); + } + + private static Fixture fixture() + { + var client = new Client(); + var account = new Account(); + var portfolio = new Portfolio(); + var source = new Security("Source AG", CurrencyUnit.EUR); + var target = new Security("Target AG", CurrencyUnit.EUR); + var secondTarget = new Security("Second Target AG", CurrencyUnit.EUR); + + account.setName("Cash"); + account.setCurrencyCode(CurrencyUnit.EUR); + portfolio.setName("Portfolio"); + + client.addAccount(account); + client.addPortfolio(portfolio); + client.addSecurity(source); + client.addSecurity(target); + client.addSecurity(secondTarget); + + return new Fixture(client, account, portfolio, source, target, secondTarget); + } + + private record Fixture(Client client, Account account, Portfolio portfolio, Security source, Security target, + Security secondTarget) + { + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionBuilder.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionBuilder.java new file mode 100644 index 0000000000..8cf1f2092a --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionBuilder.java @@ -0,0 +1,454 @@ +package name.abuchen.portfolio.model.ledger.nativeentry; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.ledger.CorporateActionBasisAllocation; +import name.abuchen.portfolio.model.ledger.Ledger; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerMutationContext; +import name.abuchen.portfolio.model.ledger.LedgerParameter; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerPostingDirection; +import name.abuchen.portfolio.model.ledger.LedgerPostingSemanticRole; +import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; +import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionBasisMethod; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionBasisStatus; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionKind; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinitionRegistry; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerLegRole; +import name.abuchen.portfolio.model.ledger.configuration.LedgerNativeEntryDefinitionValidator; +import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.money.Money; + +/** + * Contributor-facing fluent builder for native Ledger Corporate Action entries. + * It writes the persisted Ledger truth directly and delegates business validation + * to the native definition and structural validators. + */ +public final class LedgerCorporateActionBuilder +{ + private final Client client; + private final List> postingWriters = new ArrayList<>(); + private final List basisAllocations = new ArrayList<>(); + private CorporateActionKind kind; + private LocalDateTime dateTime; + private String note; + private String source; + private LocalDate effectiveDate; + private LocalDate paymentDate; + private LocalDate settlementDate; + private CorporateActionBasisStatus basisStatus; + private CorporateActionBasisMethod basisMethod; + + LedgerCorporateActionBuilder(Client client) + { + this.client = Objects.requireNonNull(client); + } + + public LedgerCorporateActionBuilder kind(CorporateActionKind kind) + { + this.kind = Objects.requireNonNull(kind); + return this; + } + + public LedgerCorporateActionBuilder date(LocalDateTime dateTime) + { + this.dateTime = Objects.requireNonNull(dateTime); + return this; + } + + public LedgerCorporateActionBuilder note(String note) + { + this.note = note; + return this; + } + + public LedgerCorporateActionBuilder source(String source) + { + this.source = source; + return this; + } + + public LedgerCorporateActionBuilder effectiveDate(LocalDate effectiveDate) + { + this.effectiveDate = Objects.requireNonNull(effectiveDate); + return this; + } + + public LedgerCorporateActionBuilder paymentDate(LocalDate paymentDate) + { + this.paymentDate = Objects.requireNonNull(paymentDate); + return this; + } + + public LedgerCorporateActionBuilder settlementDate(LocalDate settlementDate) + { + this.settlementDate = Objects.requireNonNull(settlementDate); + return this; + } + + public LedgerCorporateActionBuilder securityContext(String localKey, Portfolio portfolio, Security security) + { + return securityContext(localKey, localKey, portfolio, security); + } + + public LedgerCorporateActionBuilder securityContext(String localKey, String groupKey, Portfolio portfolio, + Security security) + { + postingWriters.add(entry -> entry.addPosting(securityPosting(LedgerLegRole.SECURITY_CONTEXT_LEG, + CorporateActionLeg.SECURITY_CONTEXT, LedgerPostingDirection.NEUTRAL, localKey, groupKey, + portfolio, security, 0L))); + return this; + } + + public LedgerCorporateActionBuilder securityIn(String localKey, Portfolio portfolio, Security security, + long shares) + { + return securityIn(localKey, localKey, portfolio, security, shares); + } + + public LedgerCorporateActionBuilder securityIn(String localKey, String groupKey, Portfolio portfolio, + Security security, long shares) + { + postingWriters.add(entry -> entry.addPosting(securityPosting(LedgerLegRole.TARGET_SECURITY_LEG, + CorporateActionLeg.TARGET_SECURITY, LedgerPostingDirection.INBOUND, localKey, groupKey, + portfolio, security, shares))); + return this; + } + + public LedgerCorporateActionBuilder securityOut(String localKey, Portfolio portfolio, Security security, + long shares) + { + return securityOut(localKey, localKey, portfolio, security, shares); + } + + public LedgerCorporateActionBuilder securityOut(String localKey, String groupKey, Portfolio portfolio, + Security security, long shares) + { + postingWriters.add(entry -> entry.addPosting(securityPosting(LedgerLegRole.SOURCE_SECURITY_LEG, + CorporateActionLeg.SOURCE_SECURITY, LedgerPostingDirection.OUTBOUND, localKey, groupKey, + portfolio, security, shares))); + return this; + } + + public LedgerCorporateActionBuilder cash(String localKey, Account account, Money amount) + { + return cash(localKey, localKey, account, amount); + } + + public LedgerCorporateActionBuilder cash(String localKey, String groupKey, Account account, Money amount) + { + postingWriters.add(entry -> entry.addPosting(cashPosting(localKey, groupKey, account, amount))); + return this; + } + + public LedgerCorporateActionBuilder fee(String localKey, Account account, Money amount, String groupKey) + { + postingWriters.add(entry -> entry.addPosting(unitPosting(LedgerPostingType.FEE, LedgerPostingSemanticRole.FEE, + LedgerPostingUnitRole.FEE, CorporateActionLeg.FEE, localKey, groupKey, account, amount))); + return this; + } + + public LedgerCorporateActionBuilder tax(String localKey, Account account, Money amount, String groupKey) + { + postingWriters.add(entry -> entry.addPosting(unitPosting(LedgerPostingType.TAX, LedgerPostingSemanticRole.TAX, + LedgerPostingUnitRole.TAX, CorporateActionLeg.TAX, localKey, groupKey, account, amount))); + return this; + } + + public LedgerCorporateActionBuilder principal(String localKey, Account account, Money amount) + { + return principal(localKey, localKey, account, amount); + } + + public LedgerCorporateActionBuilder principal(String localKey, String groupKey, Account account, Money amount) + { + postingWriters.add(entry -> entry.addPosting(primaryMoneyPosting(LedgerPostingType.PRINCIPAL_REDEMPTION, + LedgerPostingSemanticRole.PRINCIPAL_REDEMPTION, CorporateActionLeg.PRINCIPAL, localKey, + groupKey, account, amount, LedgerParameterType.CASH_ACCOUNT))); + return this; + } + + public LedgerCorporateActionBuilder accruedInterest(String localKey, Account account, Money amount) + { + return accruedInterest(localKey, localKey, account, amount); + } + + public LedgerCorporateActionBuilder accruedInterest(String localKey, String groupKey, Account account, + Money amount) + { + postingWriters.add(entry -> entry.addPosting(primaryMoneyPosting(LedgerPostingType.ACCRUED_INTEREST, + LedgerPostingSemanticRole.ACCRUED_INTEREST, CorporateActionLeg.ACCRUED_INTEREST, localKey, + groupKey, account, amount, null))); + return this; + } + + public LedgerCorporateActionBuilder basis(CorporateActionBasisStatus status) + { + this.basisStatus = Objects.requireNonNull(status); + return this; + } + + public LedgerCorporateActionBuilder basisMethod(CorporateActionBasisMethod method) + { + this.basisMethod = Objects.requireNonNull(method); + return this; + } + + public LedgerCorporateActionBuilder basisPercentageAllocation(LedgerLegRole targetRole, String targetLocalKey, + String targetGroupKey, BigDecimal percent) + { + basisAllocations.add(CorporateActionBasisAllocation.percentage(targetRole, targetLocalKey, targetGroupKey, + percent)); + return this; + } + + public LedgerNativeEntryBuildResult buildDetached() + { + var entry = assemble(); + validateNativeDefinition(entry); + var validationResult = validateDetached(entry); + + if (!validationResult.isOK()) + throw LedgerNativeEntryAssembler.issue(LedgerNativeEntryAssemblyIssue.STRUCTURAL_VALIDATION_FAILED, + validationResult.format()); + + return new LedgerNativeEntryBuildResult(entry, validationResult); + } + + public LedgerNativeEntryBuildResult buildAndAdd() + { + var detached = buildDetached(); + var context = new LedgerMutationContext(client); + var liveEntry = context.attachEntry(detached.getEntry()); + + context.refresh(); + + var validationResult = LedgerStructuralValidator.validate(client.getLedger()); + + if (!validationResult.isOK()) + throw LedgerNativeEntryAssembler.issue(LedgerNativeEntryAssemblyIssue.STRUCTURAL_VALIDATION_FAILED, + validationResult.format()); + + return new LedgerNativeEntryBuildResult(liveEntry, validationResult); + } + + private LedgerEntry assemble() + { + if (kind == null) + throw LedgerNativeEntryAssembler.issue(LedgerNativeEntryAssemblyIssue.REQUIRED_VALUE_MISSING, + "Corporate Action kind is required"); //$NON-NLS-1$ + if (dateTime == null) + throw LedgerNativeEntryAssembler.issue(LedgerNativeEntryAssemblyIssue.REQUIRED_VALUE_MISSING, + "Corporate Action date is required"); //$NON-NLS-1$ + + LedgerEntryDefinitionRegistry.lookup(LedgerEntryType.CORPORATE_ACTION, kind) + .orElseThrow(() -> LedgerNativeEntryAssembler.issue( + LedgerNativeEntryAssemblyIssue.ENTRY_DEFINITION_MISSING, + "Missing LedgerEntryDefinition for " + kind)); //$NON-NLS-1$ + + var entry = new LedgerEntry(); + entry.setType(LedgerEntryType.CORPORATE_ACTION); + entry.setDateTime(dateTime); + entry.setNote(note); + entry.setSource(source); + entry.addParameter(LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_KIND, kind.getCode())); + applyEventDates(entry); + + applyBasis(entry); + postingWriters.forEach(writer -> writer.accept(entry)); + + return entry; + } + + private void applyEventDates(LedgerEntry entry) + { + if (effectiveDate != null) + entry.addParameter(LedgerParameter.ofLocalDate(LedgerParameterType.EFFECTIVE_DATE, effectiveDate)); + + if (paymentDate != null) + entry.addParameter(LedgerParameter.ofLocalDate(LedgerParameterType.PAYMENT_DATE, paymentDate)); + + if (settlementDate != null) + entry.addParameter(LedgerParameter.ofLocalDate(LedgerParameterType.SETTLEMENT_DATE, settlementDate)); + } + + private void applyBasis(LedgerEntry entry) + { + if (basisStatus != null) + entry.addParameter(LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_BASIS_STATUS, + basisStatus.getCode())); + + if (basisMethod != null) + entry.addParameter(LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_BASIS_METHOD, + basisMethod.getCode())); + + for (var allocation : basisAllocations) + entry.addParameter(LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_BASIS_ALLOCATION, + allocation.toParameterValue())); + } + + private LedgerPosting securityPosting(LedgerLegRole role, CorporateActionLeg leg, LedgerPostingDirection direction, + String localKey, String groupKey, Portfolio portfolio, Security security, long shares) + { + var posting = primaryPosting(LedgerPostingType.SECURITY, LedgerPostingSemanticRole.SECURITY, leg, direction, + localKey, groupKey); + + posting.setPortfolio(portfolio); + posting.setSecurity(security); + posting.setShares(shares); + posting.setAmount(0L); + posting.setCurrency(client.getBaseCurrency()); + posting.addParameter(LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_LEG, leg.getCode())); + + if (role == LedgerLegRole.SOURCE_SECURITY_LEG) + { + posting.addParameter(LedgerParameter.ofSecurity(LedgerParameterType.SOURCE_SECURITY, security)); + addDefaultRatio(posting); + } + else if (role == LedgerLegRole.TARGET_SECURITY_LEG) + { + posting.addParameter(LedgerParameter.ofSecurity(LedgerParameterType.TARGET_SECURITY, security)); + addDefaultRatio(posting); + } + + return posting; + } + + private void addDefaultRatio(LedgerPosting posting) + { + posting.addParameter(LedgerParameter.ofDecimal(LedgerParameterType.RATIO_NUMERATOR, BigDecimal.ONE)); + posting.addParameter(LedgerParameter.ofDecimal(LedgerParameterType.RATIO_DENOMINATOR, BigDecimal.ONE)); + } + + private LedgerPosting cashPosting(String localKey, String groupKey, Account account, Money amount) + { + if (usesCashCompensationLeg()) + return cashCompensationPosting(localKey, groupKey, account, amount); + + return primaryMoneyPosting(LedgerPostingType.CASH, LedgerPostingSemanticRole.CASH, null, localKey, groupKey, + account, amount, LedgerParameterType.CASH_ACCOUNT); + } + + private boolean usesCashCompensationLeg() + { + return kind == CorporateActionKind.SPIN_OFF || kind == CorporateActionKind.STOCK_DIVIDEND + || kind == CorporateActionKind.BONUS_ISSUE || kind == CorporateActionKind.RIGHTS_DISTRIBUTION + || kind == CorporateActionKind.PIK_INTEREST || kind == CorporateActionKind.CONVERSION + || kind == CorporateActionKind.EXCHANGE; + } + + private LedgerPosting cashCompensationPosting(String localKey, String groupKey, Account account, Money amount) + { + return primaryMoneyPosting(LedgerPostingType.CASH_COMPENSATION, + LedgerPostingSemanticRole.CASH_COMPENSATION, CorporateActionLeg.CASH_COMPENSATION, localKey, + groupKey, account, amount, LedgerParameterType.CASH_ACCOUNT); + } + + private LedgerPosting primaryMoneyPosting(LedgerPostingType type, LedgerPostingSemanticRole semanticRole, + CorporateActionLeg leg, String localKey, String groupKey, Account account, Money amount, + LedgerParameterType accountParameterType) + { + var posting = primaryPosting(type, semanticRole, leg, LedgerPostingDirection.NEUTRAL, localKey, groupKey); + + posting.setAccount(account); + applyMoney(posting, amount); + + if (leg != null) + posting.addParameter(LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_LEG, leg.getCode())); + + if (account != null && accountParameterType != null) + posting.addParameter(accountParameter(accountParameterType, account)); + + if (type == LedgerPostingType.ACCRUED_INTEREST && amount != null) + posting.addParameter(LedgerParameter.ofMoney(LedgerParameterType.ACCRUED_INTEREST_AMOUNT, amount)); + + return posting; + } + + private LedgerPosting unitPosting(LedgerPostingType type, LedgerPostingSemanticRole semanticRole, + LedgerPostingUnitRole unitRole, CorporateActionLeg leg, String localKey, String groupKey, + Account account, Money amount) + { + var posting = new LedgerPosting(); + + posting.setType(type); + posting.setSemanticRole(semanticRole); + posting.setUnitRole(unitRole); + posting.setCorporateActionLeg(leg); + posting.setLocalKey(localKey); + posting.setGroupKey(groupKey); + posting.setAccount(account); + applyMoney(posting, amount); + posting.addParameter(LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_LEG, leg.getCode())); + + return posting; + } + + private LedgerPosting primaryPosting(LedgerPostingType type, LedgerPostingSemanticRole semanticRole, + CorporateActionLeg leg, LedgerPostingDirection direction, String localKey, String groupKey) + { + var posting = new LedgerPosting(); + + posting.setType(type); + posting.setSemanticRole(semanticRole); + posting.setDirection(direction); + posting.setCorporateActionLeg(leg); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + posting.setLocalKey(localKey); + posting.setGroupKey(groupKey); + + return posting; + } + + private void applyMoney(LedgerPosting posting, Money amount) + { + if (amount == null) + throw LedgerNativeEntryAssembler.issue(LedgerNativeEntryAssemblyIssue.REQUIRED_VALUE_MISSING, + posting.getType() + " posting amount is required"); //$NON-NLS-1$ + + posting.setAmount(amount.getAmount()); + posting.setCurrency(amount.getCurrencyCode()); + } + + private LedgerParameter accountParameter(LedgerParameterType type, Account account) + { + if (type == LedgerParameterType.CASH_ACCOUNT) + return LedgerParameter.ofAccount(type, account); + + throw new IllegalArgumentException("Unsupported account parameter type: " + type); //$NON-NLS-1$ + } + + private void validateNativeDefinition(LedgerEntry entry) + { + var result = LedgerNativeEntryDefinitionValidator.validate(entry); + + if (!result.isOK()) + throw LedgerNativeEntryAssembler.issue(LedgerNativeEntryAssemblyIssue.NATIVE_DEFINITION_VALIDATION_FAILED, + result.format()); + } + + private LedgerStructuralValidator.ValidationResult validateDetached(LedgerEntry entry) + { + var candidate = new Ledger(); + + client.getLedger().getEntries().forEach(candidate::addEntry); + candidate.addEntry(entry); + + return LedgerStructuralValidator.validate(candidate); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionEditSupport.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionEditSupport.java new file mode 100644 index 0000000000..68df5e3192 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionEditSupport.java @@ -0,0 +1,126 @@ +package name.abuchen.portfolio.model.ledger.nativeentry; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Consumer; + +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerMutationContext; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerLegRole; +import name.abuchen.portfolio.model.ledger.configuration.LedgerNativeEntryDefinitionValidator; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; + +/** + * Semantic edit helpers for native Corporate Action Ledger entries. + */ +public final class LedgerCorporateActionEditSupport +{ + private LedgerCorporateActionEditSupport() + { + } + + public static Optional findPostingBySemanticKey(LedgerEntry entry, LedgerLegRole role, + String localKey) + { + return findPostingBySemanticKey(entry, role, localKey, null); + } + + public static Optional findPostingBySemanticKey(LedgerEntry entry, LedgerLegRole role, + String localKey, String groupKey) + { + var matches = matchingPostings(entry, role, localKey, groupKey); + + if (matches.size() > 1) + throw new IllegalArgumentException("Corporate Action semantic posting key is ambiguous: " + role //$NON-NLS-1$ + + "/" + localKey); //$NON-NLS-1$ + + return matches.stream().findFirst(); + } + + public static LedgerPosting postingBySemanticKey(LedgerEntry entry, LedgerLegRole role, String localKey, + String groupKey) + { + return findPostingBySemanticKey(entry, role, localKey, groupKey) + .orElseThrow(() -> new IllegalArgumentException( + "Corporate Action semantic posting was not found: " + role + "/" //$NON-NLS-1$ //$NON-NLS-2$ + + localKey)); + } + + public static void mutatePosting(Client client, LedgerEntry entry, LedgerLegRole role, String localKey, + String groupKey, Consumer mutation) + { + Objects.requireNonNull(client); + Objects.requireNonNull(entry); + Objects.requireNonNull(mutation); + + new LedgerMutationContext(client).mutateEntry(entry, liveEntry -> { + mutation.accept(postingBySemanticKey(liveEntry, role, localKey, groupKey)); + + var result = LedgerNativeEntryDefinitionValidator.validate(liveEntry); + if (!result.isOK()) + throw LedgerNativeEntryAssembler.issue( + LedgerNativeEntryAssemblyIssue.NATIVE_DEFINITION_VALIDATION_FAILED, + result.format()); + }); + } + + private static List matchingPostings(LedgerEntry entry, LedgerLegRole role, String localKey, + String groupKey) + { + Objects.requireNonNull(entry); + Objects.requireNonNull(role); + Objects.requireNonNull(localKey); + + if (entry.getType() != LedgerEntryType.CORPORATE_ACTION) + return List.of(); + + return entry.getPostings().stream() // + .filter(posting -> posting.getType() == postingType(role)) // + .filter(posting -> posting.getCorporateActionLeg() == corporateActionLeg(role)) // + .filter(posting -> localKey.equals(posting.getLocalKey())) // + .filter(posting -> groupKey == null || groupKey.equals(posting.getGroupKey())) // + .toList(); + } + + private static LedgerPostingType postingType(LedgerLegRole role) + { + return switch (role) + { + case SOURCE_SECURITY_LEG, TARGET_SECURITY_LEG, SECURITY_CONTEXT_LEG, RECEIVED_SECURITY_LEG, + DISTRIBUTED_SECURITY_LEG -> LedgerPostingType.SECURITY; + case DISTRIBUTED_RIGHT_LEG -> LedgerPostingType.RIGHT; + case SOURCE_BOND_LEG -> LedgerPostingType.BOND; + case CASH_LEG -> LedgerPostingType.CASH; + case CASH_COMPENSATION_LEG -> LedgerPostingType.CASH_COMPENSATION; + case ACCRUED_INTEREST_LEG -> LedgerPostingType.ACCRUED_INTEREST; + case PRINCIPAL_REDEMPTION_LEG -> LedgerPostingType.PRINCIPAL_REDEMPTION; + case FEE_LEG -> LedgerPostingType.FEE; + case TAX_LEG -> LedgerPostingType.TAX; + case FOREX_CONTEXT_LEG -> LedgerPostingType.FOREX; + }; + } + + private static CorporateActionLeg corporateActionLeg(LedgerLegRole role) + { + return switch (role) + { + case SOURCE_SECURITY_LEG, SOURCE_BOND_LEG -> CorporateActionLeg.SOURCE_SECURITY; + case TARGET_SECURITY_LEG, RECEIVED_SECURITY_LEG -> CorporateActionLeg.TARGET_SECURITY; + case SECURITY_CONTEXT_LEG -> CorporateActionLeg.SECURITY_CONTEXT; + case DISTRIBUTED_SECURITY_LEG -> CorporateActionLeg.DISTRIBUTED_SECURITY; + case DISTRIBUTED_RIGHT_LEG -> CorporateActionLeg.RIGHT_SECURITY; + case CASH_LEG -> null; + case CASH_COMPENSATION_LEG -> CorporateActionLeg.CASH_COMPENSATION; + case ACCRUED_INTEREST_LEG -> CorporateActionLeg.ACCRUED_INTEREST; + case PRINCIPAL_REDEMPTION_LEG -> CorporateActionLeg.PRINCIPAL; + case FEE_LEG -> CorporateActionLeg.FEE; + case TAX_LEG -> CorporateActionLeg.TAX; + case FOREX_CONTEXT_LEG -> null; + }; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssembler.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssembler.java index e3734d4112..86b4d91bc3 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssembler.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssembler.java @@ -72,6 +72,16 @@ public static LedgerNativeEntryAssembler forClient(Client client) LedgerPostingTypeDefinitionRegistry::lookup); } + public static LedgerCorporateActionBuilder corporateAction(Client client) + { + return new LedgerCorporateActionBuilder(client); + } + + public LedgerCorporateActionBuilder corporateAction() + { + return new LedgerCorporateActionBuilder(client); + } + public EntryBuilder forType(LedgerEntryType entryType) { Objects.requireNonNull(entryType); From 70f4ce686e5ca07137222300be84f72de1484aeb Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Sun, 5 Jul 2026 17:39:38 +0200 Subject: [PATCH 58/68] Project Ledger cash corporate actions Adds cash-oriented corporate action descriptors and materialization for cash distributions and coupon payments, including repeated cash leg semantic keys and existing account transaction type mapping. This makes supported cash corporate actions visible through Ledger-backed account projections while preserving no-UUID selection by role, localKey, and groupKey. UI, importers, reports, tax/FIFO/performance, automatic basis calculation, correction/reversal, protobuf/XML schema, and new legacy transaction types are intentionally unchanged. --- ...ateActionMultiMovementPersistenceTest.java | 18 +- ...dgerCorporateActionCashProjectionTest.java | 233 ++++++++++++++++++ .../DerivedProjectionDescriptorService.java | 27 +- .../LedgerBackedAccountTransaction.java | 12 +- .../projection/LedgerProjectionSupport.java | 36 ++- 5 files changed, 318 insertions(+), 8 deletions(-) create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerCorporateActionCashProjectionTest.java diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java index 292e48f2f0..7168badd72 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java @@ -654,7 +654,13 @@ private void assertCashDistribution(LedgerEntry entry) .filter(posting -> "cash-1".equals(posting.getGroupKey())) //$NON-NLS-1$ .filter(posting -> posting.getAccount() != null) .count(), is(1L)); - assertTrue(LedgerDescriptorTestSupport.descriptors(entry).isEmpty()); + var descriptors = LedgerDescriptorTestSupport.descriptors(entry); + assertThat(descriptors.size(), is(1)); + assertThat(descriptors.get(0).getRole(), is(LedgerProjectionRole.ACCOUNT)); + assertThat(descriptors.get(0).getSemanticInstanceKey().orElseThrow(), is("cash-1")); + assertThat(descriptors.get(0).getPrimaryPosting().getType(), is(LedgerPostingType.CASH)); + assertFalse(descriptors.stream().map(DerivedProjectionDescriptor::getPrimaryPosting) + .anyMatch(posting -> posting.getCorporateActionLeg() == CorporateActionLeg.SECURITY_CONTEXT)); } private void assertCouponPaymentInterestDetail(LedgerEntry entry) @@ -687,7 +693,15 @@ private void assertCouponPaymentInterestDetail(LedgerEntry entry) .anyMatch(parameter -> parameter.getType() == LedgerParameterType.INTEREST_PERIOD_START)); assertTrue(interest.getParameters().stream() .anyMatch(parameter -> parameter.getType() == LedgerParameterType.INTEREST_PERIOD_END)); - assertTrue(LedgerDescriptorTestSupport.descriptors(entry).isEmpty()); + var descriptors = LedgerDescriptorTestSupport.descriptors(entry); + assertThat(descriptors.size(), is(1)); + assertThat(descriptors.get(0).getRole(), is(LedgerProjectionRole.ACCOUNT)); + assertThat(descriptors.get(0).getSemanticInstanceKey().orElseThrow(), is("cash-1")); + assertThat(descriptors.get(0).getPrimaryPosting().getType(), is(LedgerPostingType.CASH)); + assertFalse(descriptors.stream().map(DerivedProjectionDescriptor::getPrimaryPosting) + .anyMatch(posting -> posting.getType() == LedgerPostingType.ACCRUED_INTEREST)); + assertFalse(descriptors.stream().map(DerivedProjectionDescriptor::getPrimaryPosting) + .anyMatch(posting -> posting.getCorporateActionLeg() == CorporateActionLeg.SECURITY_CONTEXT)); } private List postings(LedgerEntry entry, LedgerPostingType type, LedgerPostingDirection direction, diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerCorporateActionCashProjectionTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerCorporateActionCashProjectionTest.java new file mode 100644 index 0000000000..b6e197d994 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerCorporateActionCashProjectionTest.java @@ -0,0 +1,233 @@ +package name.abuchen.portfolio.model.ledger.projection; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import org.junit.Test; + +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.Security; +import name.abuchen.portfolio.model.Transaction.Unit; +import name.abuchen.portfolio.model.ledger.LedgerMutationContext; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionBasisStatus; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionKind; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.model.ledger.nativeentry.LedgerNativeEntryAssembler; +import name.abuchen.portfolio.model.ledger.nativeentry.LedgerNativeEntryAssemblyException; +import name.abuchen.portfolio.model.ledger.nativeentry.LedgerNativeEntryAssemblyIssue; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.Values; + +@SuppressWarnings("nls") +public class LedgerCorporateActionCashProjectionTest +{ + private static final LocalDateTime DATE = LocalDateTime.of(2026, 1, 2, 0, 0); + + @Test + public void testCashDistributionMaterializesSingleCashProjection() + { + var fixture = fixture(); + var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.CASH_DISTRIBUTION) // + .date(DATE) // + .note("cash distribution") // + .source("issuer") // + .securityContext("context-1", "main", fixture.portfolio, fixture.security) // + .cash("cash-1", fixture.account, money(15)) // + .fee("fee-1", fixture.account, money(2), "cash-1") // + .tax("tax-1", fixture.account, money(1), "cash-1") // + .basis(CorporateActionBasisStatus.UNKNOWN) // + .buildAndAdd().getEntry(); + + LedgerProjectionService.materialize(fixture.client); + + var projection = ledgerBackedAccountProjection(fixture.account, LedgerProjectionRole.ACCOUNT, "cash-1"); + + assertThat(fixture.account.getTransactions().size(), is(1)); + assertThat(projection.getType(), is(AccountTransaction.Type.DIVIDENDS)); + assertThat(projection.getDateTime(), is(DATE)); + assertThat(projection.getNote(), is("cash distribution")); + assertThat(projection.getSource(), is("issuer")); + assertThat(projection.getAmount(), is(money(15).getAmount())); + assertSame(fixture.security, projection.getSecurity()); + assertThat(projection.getUnit(Unit.Type.FEE).orElseThrow().getAmount().getAmount(), is(money(2).getAmount())); + assertThat(projection.getUnit(Unit.Type.TAX).orElseThrow().getAmount().getAmount(), is(money(1).getAmount())); + assertThat(LedgerProjectionSupport.descriptors(entry).size(), is(1)); + assertFalse(LedgerProjectionSupport.descriptors(entry).stream() + .anyMatch(descriptor -> descriptor.getPrimaryPosting() + .getCorporateActionLeg() == CorporateActionLeg.SECURITY_CONTEXT)); + } + + @Test + public void testCashDistributionMaterializesRepeatedCashProjections() + { + var fixture = fixture(); + var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.CASH_DISTRIBUTION) // + .date(DATE) // + .securityContext("context-1", "main", fixture.portfolio, fixture.security) // + .cash("cash-1", fixture.account, money(15)) // + .cash("cash-2", fixture.secondAccount, money(7)) // + .buildAndAdd().getEntry(); + + var descriptors = LedgerProjectionSupport.descriptors(entry); + var accountDescriptors = descriptors.stream() + .filter(descriptor -> descriptor.getRole() == LedgerProjectionRole.ACCOUNT).toList(); + + assertThat(accountDescriptors.size(), is(2)); + assertThat(semanticKeys(accountDescriptors), is(Set.of("cash-1", "cash-2"))); + assertThrows(IllegalArgumentException.class, + () -> new LedgerProjectionFactory().createProjection(entry, LedgerProjectionRole.ACCOUNT)); + assertThat(new LedgerProjectionFactory().createProjection(entry, LedgerProjectionRole.ACCOUNT, "cash-1") + .getUUID(), is(entry.getUUID() + ":ACCOUNT:cash-1")); + + LedgerProjectionService.materialize(fixture.client); + LedgerProjectionService.materialize(fixture.client); + + var first = ledgerBackedAccountProjection(fixture.account, LedgerProjectionRole.ACCOUNT, "cash-1"); + var second = ledgerBackedAccountProjection(fixture.secondAccount, LedgerProjectionRole.ACCOUNT, "cash-2"); + + assertThat(first.getAmount(), is(money(15).getAmount())); + assertThat(second.getAmount(), is(money(7).getAmount())); + assertThat(fixture.account.getTransactions().size(), is(1)); + assertThat(fixture.secondAccount.getTransactions().size(), is(1)); + assertThat(runtimeProjectionIds(List.of(first, second)), + is(accountDescriptors.stream().map(DerivedProjectionDescriptor::getRuntimeProjectionId) + .collect(Collectors.toSet()))); + } + + @Test + public void testCouponPaymentMaterializesCashProjectionWithGroupedDetails() + { + var fixture = fixture(); + var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.COUPON_PAYMENT) // + .date(DATE) // + .securityContext("context-1", "main", fixture.portfolio, fixture.security) // + .cash("coupon-1", fixture.account, money(12)) // + .accruedInterest("interest-1", "coupon-1", fixture.account, money(12)) // + .fee("fee-1", fixture.account, money(2), "coupon-1") // + .tax("tax-1", fixture.account, money(1), "coupon-1") // + .buildAndAdd().getEntry(); + + LedgerProjectionService.materialize(fixture.client); + + var projection = ledgerBackedAccountProjection(fixture.account, LedgerProjectionRole.ACCOUNT, "coupon-1"); + + assertThat(fixture.account.getTransactions().size(), is(1)); + assertThat(projection.getType(), is(AccountTransaction.Type.INTEREST)); + assertThat(projection.getAmount(), is(money(12).getAmount())); + assertSame(fixture.security, projection.getSecurity()); + assertThat(projection.getUnit(Unit.Type.FEE).orElseThrow().getAmount().getAmount(), is(money(2).getAmount())); + assertThat(projection.getUnit(Unit.Type.TAX).orElseThrow().getAmount().getAmount(), is(money(1).getAmount())); + assertThat(LedgerProjectionSupport.descriptors(entry).size(), is(1)); + assertTrue(LedgerProjectionSupport.descriptor(entry, LedgerProjectionRole.ACCOUNT, "coupon-1") + .getUnitPostings().stream() + .noneMatch(posting -> posting.getType() == LedgerPostingType.ACCRUED_INTEREST)); + } + + @Test + public void testCouponPaymentDifferentInterestGroupIsRejectedBeforeProjection() + { + var fixture = fixture(); + var exception = assertThrows(LedgerNativeEntryAssemblyException.class, + () -> LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.COUPON_PAYMENT) // + .date(DATE) // + .securityContext("context-1", "main", fixture.portfolio, fixture.security) // + .cash("coupon-1", fixture.account, money(12)) // + .accruedInterest("interest-1", "other", fixture.account, money(12)) // + .buildDetached()); + + assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.NATIVE_DEFINITION_VALIDATION_FAILED)); + } + + @Test + public void testDeletingCashDistributionRemovesCashProjection() + { + var fixture = fixture(); + var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.CASH_DISTRIBUTION) // + .date(DATE) // + .securityContext("context-1", "main", fixture.portfolio, fixture.security) // + .cash("cash-1", fixture.account, money(15)) // + .buildAndAdd().getEntry(); + + assertThat(fixture.account.getTransactions().size(), is(1)); + + new LedgerMutationContext(fixture.client).removeEntry(entry); + + assertTrue(fixture.account.getTransactions().isEmpty()); + assertTrue(fixture.portfolio.getTransactions().isEmpty()); + } + + private LedgerBackedAccountTransaction ledgerBackedAccountProjection(Account account, LedgerProjectionRole role, + String semanticInstanceKey) + { + return account.getTransactions().stream() // + .filter(LedgerBackedAccountTransaction.class::isInstance) // + .map(LedgerBackedAccountTransaction.class::cast) // + .filter(transaction -> transaction.getLedgerProjectionRole() == role) // + .filter(transaction -> transaction.getLedgerProjectionDescriptor().getSemanticInstanceKey() + .filter(semanticInstanceKey::equals).isPresent()) // + .findFirst().orElseThrow(); + } + + private Set semanticKeys(List descriptors) + { + return descriptors.stream().map(descriptor -> descriptor.getSemanticInstanceKey().orElseThrow()) + .collect(Collectors.toSet()); + } + + private Set runtimeProjectionIds(List transactions) + { + return transactions.stream().map(LedgerBackedTransaction::getRuntimeProjectionId).collect(Collectors.toSet()); + } + + private static Money money(long amount) + { + return Money.of(CurrencyUnit.EUR, Values.Amount.factorize(amount)); + } + + private static Fixture fixture() + { + var client = new Client(); + var account = new Account(); + var secondAccount = new Account(); + var portfolio = new Portfolio(); + var security = new Security("Coupon AG", CurrencyUnit.EUR); + + account.setName("Cash"); + account.setCurrencyCode(CurrencyUnit.EUR); + secondAccount.setName("Second Cash"); + secondAccount.setCurrencyCode(CurrencyUnit.EUR); + portfolio.setName("Portfolio"); + + client.addAccount(account); + client.addAccount(secondAccount); + client.addPortfolio(portfolio); + client.addSecurity(security); + + return new Fixture(client, account, secondAccount, portfolio, security); + } + + private record Fixture(Client client, Account account, Account secondAccount, Portfolio portfolio, + Security security) + { + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java index 284fe89adc..b32af0f13f 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java @@ -97,9 +97,19 @@ private void corporateAction(LedgerEntry entry, List k == CorporateActionKind.CASH_DISTRIBUTION || k == CorporateActionKind.COUPON_PAYMENT) + .isPresent()) + cashOrientedCorporateAction(entry, descriptors, diagnostics); + } + private void spinOff(LedgerEntry entry, List descriptors, List diagnostics) + { repeatedPortfolio(entry, LedgerProjectionRole.OLD_SECURITY_LEG, primary().and(corporateLeg(CorporateActionLeg.SOURCE_SECURITY)) .and(localKey(LedgerProjectionRole.OLD_SECURITY_LEG)) @@ -128,6 +138,16 @@ private void corporateAction(LedgerEntry entry, List descriptors, + List diagnostics) + { + repeatedAccount(entry, LedgerProjectionRole.ACCOUNT, + primary().and(postingType(LedgerPostingType.CASH)).and(localKey(LedgerProjectionRole.ACCOUNT)), + primary().and(postingType(LedgerPostingType.CASH)) + .and(semantic(LedgerPostingSemanticRole.CASH)), + true, diagnostics).forEach(descriptors::add); + } + private java.util.Optional account(LedgerEntry entry, LedgerProjectionRole role, Predicate selector, List diagnostics) { @@ -338,6 +358,11 @@ private Predicate semantic(LedgerPostingSemanticRole role) return posting -> posting.getSemanticRole() == role; } + private Predicate postingType(LedgerPostingType type) + { + return posting -> posting.getType() == type; + } + private Predicate direction(LedgerPostingDirection direction) { return posting -> posting.getDirection() == direction; diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedAccountTransaction.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedAccountTransaction.java index fdb692bd40..a203bcc42b 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedAccountTransaction.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedAccountTransaction.java @@ -11,6 +11,8 @@ import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerEntryMetadataPatchHelper; import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; import name.abuchen.portfolio.money.CurrencyConverter; import name.abuchen.portfolio.money.Money; import name.abuchen.portfolio.money.MoneyCollectors; @@ -62,7 +64,7 @@ public String getUUID() public Type getType() { if (entry.getType().isLedgerNativeTargeted()) - return LedgerProjectionSupport.targetedAccountType(descriptor.getRole()); + return LedgerProjectionSupport.targetedAccountType(descriptor); return switch (entry.getType()) { @@ -128,7 +130,13 @@ public Money getMonetaryAmount() @Override public Security getSecurity() { - return primaryPosting.getSecurity(); + if (primaryPosting.getSecurity() != null) + return primaryPosting.getSecurity(); + + if (entry.getType() == LedgerEntryType.CORPORATE_ACTION && descriptor.getRole() == LedgerProjectionRole.ACCOUNT) + return LedgerProjectionSupport.securityContext(entry).orElse(null); + + return null; } @Override diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionSupport.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionSupport.java index bae6b42dd3..4bb1cd0f46 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionSupport.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionSupport.java @@ -10,11 +10,14 @@ import name.abuchen.portfolio.model.AccountTransaction; import name.abuchen.portfolio.model.LedgerDiagnosticCode; import name.abuchen.portfolio.model.PortfolioTransaction; +import name.abuchen.portfolio.model.Security; import name.abuchen.portfolio.model.Transaction.Unit; import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerParameter; import name.abuchen.portfolio.model.ledger.LedgerPosting; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionKind; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; import name.abuchen.portfolio.money.Money; @@ -110,16 +113,43 @@ static Stream units(DerivedProjectionDescriptor descriptor) return descriptor.getUnitPostings().stream().map(LedgerProjectionSupport::unit); } - static AccountTransaction.Type targetedAccountType(LedgerProjectionRole role) + static AccountTransaction.Type targetedAccountType(DerivedProjectionDescriptor descriptor) { - return switch (role) + return switch (descriptor.getRole()) { case CASH_COMPENSATION -> AccountTransaction.Type.DEPOSIT; + case ACCOUNT -> corporateActionAccountType(descriptor); default -> throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PROJ_072 - .message("Unsupported targeted account role " + role)); //$NON-NLS-1$ + .message("Unsupported targeted account role " + descriptor.getRole())); //$NON-NLS-1$ }; } + private static AccountTransaction.Type corporateActionAccountType(DerivedProjectionDescriptor descriptor) + { + var kind = CorporateActionKind.fromEntry(descriptor.getEntry()).orElse(null); + + if (kind == null) + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PROJ_072 + .message("Unsupported targeted account kind " + kind)); //$NON-NLS-1$ + + return switch (kind) + { + case CASH_DISTRIBUTION -> AccountTransaction.Type.DIVIDENDS; + case COUPON_PAYMENT -> AccountTransaction.Type.INTEREST; + default -> throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PROJ_072 + .message("Unsupported targeted account kind " + kind)); //$NON-NLS-1$ + }; + } + + static Optional securityContext(LedgerEntry entry) + { + return entry.getPostings().stream() // + .filter(posting -> posting.getCorporateActionLeg() == CorporateActionLeg.SECURITY_CONTEXT) // + .filter(posting -> posting.getSecurity() != null) // + .map(LedgerPosting::getSecurity) // + .findFirst(); + } + static PortfolioTransaction.Type targetedPortfolioType(LedgerProjectionRole role) { return switch (role) From 88720309e8c678460239b54ff96de5157144253a Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Sun, 5 Jul 2026 17:53:43 +0200 Subject: [PATCH 59/68] Project Ledger security-in corporate actions Adds corporate action descriptor derivation and materialization coverage for stock dividends, bonus issues, rights distributions, and PIK interest, including repeated inbound security semantic keys and optional cash compensation projections. This exposes supported security-in corporate actions through Ledger-backed portfolio and account projections while preserving no-UUID selection by role, localKey, and groupKey. UI, importers, reports, tax/FIFO/performance, automatic basis calculation, correction/reversal, XML/protobuf schema, and new legacy transaction types are intentionally unchanged. --- ...rporateActionSecurityInProjectionTest.java | 311 ++++++++++++++++++ .../LedgerCorporateActionBuilder.java | 29 ++ .../DerivedProjectionDescriptorService.java | 58 +++- 3 files changed, 393 insertions(+), 5 deletions(-) create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerCorporateActionSecurityInProjectionTest.java diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerCorporateActionSecurityInProjectionTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerCorporateActionSecurityInProjectionTest.java new file mode 100644 index 0000000000..22dbc8329f --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerCorporateActionSecurityInProjectionTest.java @@ -0,0 +1,311 @@ +package name.abuchen.portfolio.model.ledger.projection; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import org.junit.Test; + +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.Unit; +import name.abuchen.portfolio.model.ledger.LedgerMutationContext; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionBasisStatus; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionKind; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.model.ledger.nativeentry.LedgerNativeEntryAssembler; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.Values; + +@SuppressWarnings("nls") +public class LedgerCorporateActionSecurityInProjectionTest +{ + private static final LocalDateTime DATE = LocalDateTime.of(2026, 1, 2, 0, 0); + + @Test + public void testStockDividendMaterializesSingleInboundProjection() + { + var fixture = fixture(); + var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.STOCK_DIVIDEND) // + .date(DATE) // + .note("stock dividend") // + .securityContext("context-1", "main", fixture.portfolio, fixture.sourceSecurity) // + .securityIn("target-1", fixture.portfolio, fixture.targetSecurity, shares(5)) // + .basis(CorporateActionBasisStatus.UNKNOWN) // + .buildAndAdd().getEntry(); + + LedgerProjectionService.materialize(fixture.client); + + var projection = ledgerBackedPortfolioProjection(fixture.portfolio, LedgerProjectionRole.NEW_SECURITY_LEG, + "target-1"); + + assertThat(fixture.portfolio.getTransactions().size(), is(1)); + assertThat(projection.getType(), is(PortfolioTransaction.Type.DELIVERY_INBOUND)); + assertThat(projection.getDateTime(), is(DATE)); + assertThat(projection.getNote(), is("stock dividend")); + assertSame(fixture.targetSecurity, projection.getSecurity()); + assertThat(projection.getShares(), is(shares(5))); + assertThat(LedgerProjectionSupport.descriptors(entry).size(), is(1)); + assertFalse(LedgerProjectionSupport.descriptors(entry).stream() + .anyMatch(descriptor -> descriptor.getPrimaryPosting() + .getCorporateActionLeg() == CorporateActionLeg.SECURITY_CONTEXT)); + } + + @Test + public void testStockDividendMaterializesRepeatedInboundProjections() + { + var fixture = fixture(); + var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.STOCK_DIVIDEND) // + .date(DATE) // + .securityContext("context-1", "main", fixture.portfolio, fixture.sourceSecurity) // + .securityIn("target-1", fixture.portfolio, fixture.targetSecurity, shares(5)) // + .securityIn("target-2", fixture.secondPortfolio, fixture.rightSecurity, shares(3)) // + .buildAndAdd().getEntry(); + + var descriptors = LedgerProjectionSupport.descriptors(entry); + var securityDescriptors = descriptors.stream() + .filter(descriptor -> descriptor.getRole() == LedgerProjectionRole.NEW_SECURITY_LEG).toList(); + + assertThat(securityDescriptors.size(), is(2)); + assertThat(semanticKeys(securityDescriptors), is(Set.of("target-1", "target-2"))); + assertThrows(IllegalArgumentException.class, + () -> new LedgerProjectionFactory().createProjection(entry, LedgerProjectionRole.NEW_SECURITY_LEG)); + assertThat(new LedgerProjectionFactory() + .createProjection(entry, LedgerProjectionRole.NEW_SECURITY_LEG, "target-2").getUUID(), + is(entry.getUUID() + ":NEW_SECURITY_LEG:target-2")); + + LedgerProjectionService.materialize(fixture.client); + LedgerProjectionService.materialize(fixture.client); + + var first = ledgerBackedPortfolioProjection(fixture.portfolio, LedgerProjectionRole.NEW_SECURITY_LEG, + "target-1"); + var second = ledgerBackedPortfolioProjection(fixture.secondPortfolio, LedgerProjectionRole.NEW_SECURITY_LEG, + "target-2"); + + assertThat(first.getShares(), is(shares(5))); + assertThat(second.getShares(), is(shares(3))); + assertSame(fixture.targetSecurity, first.getSecurity()); + assertSame(fixture.rightSecurity, second.getSecurity()); + assertThat(fixture.portfolio.getTransactions().size(), is(1)); + assertThat(fixture.secondPortfolio.getTransactions().size(), is(1)); + assertThat(runtimeProjectionIds(List.of(first, second)), + is(securityDescriptors.stream().map(DerivedProjectionDescriptor::getRuntimeProjectionId) + .collect(Collectors.toSet()))); + } + + @Test + public void testBonusIssueMaterializesInboundProjection() + { + var fixture = fixture(); + + LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.BONUS_ISSUE) // + .date(DATE) // + .securityContext("context-1", "main", fixture.portfolio, fixture.sourceSecurity) // + .securityIn("target-1", fixture.portfolio, fixture.targetSecurity, shares(4)) // + .buildAndAdd(); + + LedgerProjectionService.materialize(fixture.client); + + var projection = ledgerBackedPortfolioProjection(fixture.portfolio, LedgerProjectionRole.NEW_SECURITY_LEG, + "target-1"); + + assertThat(projection.getType(), is(PortfolioTransaction.Type.DELIVERY_INBOUND)); + assertSame(fixture.targetSecurity, projection.getSecurity()); + assertThat(projection.getShares(), is(shares(4))); + } + + @Test + public void testRightsDistributionMaterializesInboundProjection() + { + var fixture = fixture(); + + LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.RIGHTS_DISTRIBUTION) // + .date(DATE) // + .securityContext("context-1", "main", fixture.portfolio, fixture.sourceSecurity) // + .rightIn("right-1", fixture.portfolio, fixture.rightSecurity, shares(9)) // + .buildAndAdd(); + + LedgerProjectionService.materialize(fixture.client); + + var projection = ledgerBackedPortfolioProjection(fixture.portfolio, LedgerProjectionRole.NEW_SECURITY_LEG, + "right-1"); + + assertThat(projection.getType(), is(PortfolioTransaction.Type.DELIVERY_INBOUND)); + assertSame(fixture.rightSecurity, projection.getSecurity()); + assertThat(projection.getShares(), is(shares(9))); + } + + @Test + public void testPikInterestMaterializesInboundProjectionWithGroupedInterestDetail() + { + var fixture = fixture(); + var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.PIK_INTEREST) // + .date(DATE) // + .securityContext("context-1", "pik-1", fixture.portfolio, fixture.sourceSecurity) // + .securityIn("target-1", "pik-1", fixture.portfolio, fixture.targetSecurity, shares(6)) // + .accruedInterest("interest-1", "pik-1", fixture.account, money(12)) // + .buildAndAdd().getEntry(); + + LedgerProjectionService.materialize(fixture.client); + + var projection = ledgerBackedPortfolioProjection(fixture.portfolio, LedgerProjectionRole.NEW_SECURITY_LEG, + "target-1"); + + assertThat(projection.getType(), is(PortfolioTransaction.Type.DELIVERY_INBOUND)); + assertThat(projection.getShares(), is(shares(6))); + assertSame(fixture.targetSecurity, projection.getSecurity()); + assertThat(LedgerProjectionSupport.descriptors(entry).size(), is(1)); + assertFalse(LedgerProjectionSupport.descriptors(entry).stream().map(DerivedProjectionDescriptor::getPrimaryPosting) + .anyMatch(posting -> posting.getType() == LedgerPostingType.ACCRUED_INTEREST)); + assertFalse(LedgerProjectionSupport.descriptors(entry).stream().map(DerivedProjectionDescriptor::getPrimaryPosting) + .anyMatch(posting -> posting.getCorporateActionLeg() == CorporateActionLeg.SECURITY_CONTEXT)); + } + + @Test + public void testSecurityInActionMaterializesOptionalCashCompensationProjection() + { + var fixture = fixture(); + + LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.STOCK_DIVIDEND) // + .date(DATE) // + .securityContext("context-1", "main", fixture.portfolio, fixture.sourceSecurity) // + .securityIn("target-1", fixture.portfolio, fixture.targetSecurity, shares(5)) // + .cash("cash-1", fixture.account, money(7)) // + .fee("fee-1", fixture.account, money(2), "cash-1") // + .tax("tax-1", fixture.account, money(1), "cash-1") // + .buildAndAdd(); + + LedgerProjectionService.materialize(fixture.client); + + var securityProjection = ledgerBackedPortfolioProjection(fixture.portfolio, + LedgerProjectionRole.NEW_SECURITY_LEG, "target-1"); + var cashProjection = ledgerBackedAccountProjection(fixture.account, LedgerProjectionRole.CASH_COMPENSATION, + "cash-1"); + + assertThat(securityProjection.getType(), is(PortfolioTransaction.Type.DELIVERY_INBOUND)); + assertThat(cashProjection.getType(), is(AccountTransaction.Type.DEPOSIT)); + assertThat(cashProjection.getAmount(), is(money(7).getAmount())); + assertThat(cashProjection.getUnit(Unit.Type.FEE).orElseThrow().getAmount().getAmount(), is(money(2).getAmount())); + assertThat(cashProjection.getUnit(Unit.Type.TAX).orElseThrow().getAmount().getAmount(), is(money(1).getAmount())); + } + + @Test + public void testDeletingSecurityInActionRemovesProjections() + { + var fixture = fixture(); + var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.STOCK_DIVIDEND) // + .date(DATE) // + .securityContext("context-1", "main", fixture.portfolio, fixture.sourceSecurity) // + .securityIn("target-1", fixture.portfolio, fixture.targetSecurity, shares(5)) // + .cash("cash-1", fixture.account, money(7)) // + .buildAndAdd().getEntry(); + + LedgerProjectionService.materialize(fixture.client); + LedgerProjectionService.materialize(fixture.client); + + assertThat(fixture.portfolio.getTransactions().size(), is(1)); + assertThat(fixture.account.getTransactions().size(), is(1)); + + new LedgerMutationContext(fixture.client).removeEntry(entry); + + assertTrue(fixture.portfolio.getTransactions().isEmpty()); + assertTrue(fixture.account.getTransactions().isEmpty()); + } + + private LedgerBackedPortfolioTransaction ledgerBackedPortfolioProjection(Portfolio portfolio, + LedgerProjectionRole role, String semanticInstanceKey) + { + return portfolio.getTransactions().stream() // + .filter(LedgerBackedPortfolioTransaction.class::isInstance) // + .map(LedgerBackedPortfolioTransaction.class::cast) // + .filter(transaction -> transaction.getLedgerProjectionRole() == role) // + .filter(transaction -> transaction.getLedgerProjectionDescriptor().getSemanticInstanceKey() + .filter(semanticInstanceKey::equals).isPresent()) // + .findFirst().orElseThrow(); + } + + private LedgerBackedAccountTransaction ledgerBackedAccountProjection(Account account, LedgerProjectionRole role, + String semanticInstanceKey) + { + return account.getTransactions().stream() // + .filter(LedgerBackedAccountTransaction.class::isInstance) // + .map(LedgerBackedAccountTransaction.class::cast) // + .filter(transaction -> transaction.getLedgerProjectionRole() == role) // + .filter(transaction -> transaction.getLedgerProjectionDescriptor().getSemanticInstanceKey() + .filter(semanticInstanceKey::equals).isPresent()) // + .findFirst().orElseThrow(); + } + + private Set semanticKeys(List descriptors) + { + return descriptors.stream().map(descriptor -> descriptor.getSemanticInstanceKey().orElseThrow()) + .collect(Collectors.toSet()); + } + + private Set runtimeProjectionIds(List transactions) + { + return transactions.stream().map(LedgerBackedTransaction::getRuntimeProjectionId).collect(Collectors.toSet()); + } + + private static long shares(long shares) + { + return Values.Share.factorize(shares); + } + + private static Money money(long amount) + { + return Money.of(CurrencyUnit.EUR, Values.Amount.factorize(amount)); + } + + private static Fixture fixture() + { + var client = new Client(); + var account = new Account(); + var portfolio = new Portfolio(); + var secondPortfolio = new Portfolio(); + var sourceSecurity = new Security("Source AG", CurrencyUnit.EUR); + var targetSecurity = new Security("Target AG", CurrencyUnit.EUR); + var rightSecurity = new Security("Rights", CurrencyUnit.EUR); + + account.setName("Cash"); + account.setCurrencyCode(CurrencyUnit.EUR); + portfolio.setName("Portfolio"); + secondPortfolio.setName("Second Portfolio"); + + client.addAccount(account); + client.addPortfolio(portfolio); + client.addPortfolio(secondPortfolio); + client.addSecurity(sourceSecurity); + client.addSecurity(targetSecurity); + client.addSecurity(rightSecurity); + + return new Fixture(client, account, portfolio, secondPortfolio, sourceSecurity, targetSecurity, rightSecurity); + } + + private record Fixture(Client client, Account account, Portfolio portfolio, Portfolio secondPortfolio, + Security sourceSecurity, Security targetSecurity, Security rightSecurity) + { + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionBuilder.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionBuilder.java index 8cf1f2092a..807cba5a11 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionBuilder.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionBuilder.java @@ -145,6 +145,18 @@ public LedgerCorporateActionBuilder securityOut(String localKey, String groupKey return this; } + public LedgerCorporateActionBuilder rightIn(String localKey, Portfolio portfolio, Security security, long shares) + { + return rightIn(localKey, localKey, portfolio, security, shares); + } + + public LedgerCorporateActionBuilder rightIn(String localKey, String groupKey, Portfolio portfolio, Security security, + long shares) + { + postingWriters.add(entry -> entry.addPosting(rightPosting(localKey, groupKey, portfolio, security, shares))); + return this; + } + public LedgerCorporateActionBuilder cash(String localKey, Account account, Money amount) { return cash(localKey, localKey, account, amount); @@ -329,6 +341,23 @@ else if (role == LedgerLegRole.TARGET_SECURITY_LEG) return posting; } + private LedgerPosting rightPosting(String localKey, String groupKey, Portfolio portfolio, Security security, + long shares) + { + var posting = primaryPosting(LedgerPostingType.RIGHT, LedgerPostingSemanticRole.RIGHT, + CorporateActionLeg.RIGHT_SECURITY, LedgerPostingDirection.INBOUND, localKey, groupKey); + + posting.setPortfolio(portfolio); + posting.setSecurity(security); + posting.setShares(shares); + posting.setAmount(0L); + posting.addParameter(LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_LEG, + CorporateActionLeg.RIGHT_SECURITY.getCode())); + posting.addParameter(LedgerParameter.ofSecurity(LedgerParameterType.RIGHT_SECURITY, security)); + + return posting; + } + private void addDefaultRatio(LedgerPosting posting) { posting.addParameter(LedgerParameter.ofDecimal(LedgerParameterType.RATIO_NUMERATOR, BigDecimal.ONE)); diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java index b32af0f13f..1a5d4f838e 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java @@ -103,11 +103,26 @@ private void corporateAction(LedgerEntry entry, List k == CorporateActionKind.CASH_DISTRIBUTION || k == CorporateActionKind.COUPON_PAYMENT) .isPresent()) cashOrientedCorporateAction(entry, descriptors, diagnostics); } + private boolean isSecurityInCorporateAction(CorporateActionKind kind) + { + return switch (kind) + { + case STOCK_DIVIDEND, BONUS_ISSUE, RIGHTS_DISTRIBUTION, PIK_INTEREST -> true; + default -> false; + }; + } + private void spinOff(LedgerEntry entry, List descriptors, List diagnostics) { repeatedPortfolio(entry, LedgerProjectionRole.OLD_SECURITY_LEG, @@ -130,11 +145,18 @@ private void spinOff(LedgerEntry entry, List descri .and(localKey(LedgerProjectionRole.DELIVERY_INBOUND).negate()), true, diagnostics).forEach(descriptors::add); repeatedAccount(entry, LedgerProjectionRole.CASH_COMPENSATION, - primary().and(corporateLeg(CorporateActionLeg.CASH_COMPENSATION)) - .and(localKey(LedgerProjectionRole.CASH_COMPENSATION)) - .or(legacyCorporateLeg(LedgerPostingType.CASH_COMPENSATION, - CorporateActionLeg.CASH_COMPENSATION)), - primary().and(corporateLeg(CorporateActionLeg.CASH_COMPENSATION)), true, diagnostics) + cashCompensationPreferredSelector(), cashCompensationRepeatedSelector(), true, diagnostics) + .forEach(descriptors::add); + } + + private void securityInCorporateAction(LedgerEntry entry, List descriptors, + List diagnostics) + { + repeatedPortfolio(entry, LedgerProjectionRole.NEW_SECURITY_LEG, + securityInPreferredSelector(), securityInRepeatedSelector(), false, diagnostics) + .forEach(descriptors::add); + repeatedAccount(entry, LedgerProjectionRole.CASH_COMPENSATION, + cashCompensationPreferredSelector(), cashCompensationRepeatedSelector(), true, diagnostics) .forEach(descriptors::add); } @@ -148,6 +170,32 @@ private void cashOrientedCorporateAction(LedgerEntry entry, List securityInPreferredSelector() + { + return primary().and(corporateLeg(CorporateActionLeg.TARGET_SECURITY) + .or(corporateLeg(CorporateActionLeg.RIGHT_SECURITY))) + .and(localKey(LedgerProjectionRole.NEW_SECURITY_LEG)); + } + + private Predicate securityInRepeatedSelector() + { + return primary().and(corporateLeg(CorporateActionLeg.TARGET_SECURITY) + .or(corporateLeg(CorporateActionLeg.RIGHT_SECURITY))); + } + + private Predicate cashCompensationPreferredSelector() + { + return primary().and(corporateLeg(CorporateActionLeg.CASH_COMPENSATION)) + .and(localKey(LedgerProjectionRole.CASH_COMPENSATION)) + .or(legacyCorporateLeg(LedgerPostingType.CASH_COMPENSATION, + CorporateActionLeg.CASH_COMPENSATION)); + } + + private Predicate cashCompensationRepeatedSelector() + { + return primary().and(corporateLeg(CorporateActionLeg.CASH_COMPENSATION)); + } + private java.util.Optional account(LedgerEntry entry, LedgerProjectionRole role, Predicate selector, List diagnostics) { From 995ae8f56ce728e1efa322b5b843c36645106132 Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Sun, 5 Jul 2026 18:07:32 +0200 Subject: [PATCH 60/68] Project Ledger redemption corporate actions Adds descriptor derivation and materialization coverage for maturity, partial redemption, call, and put corporate actions, including outbound portfolio projections and redemption cash account projections. This makes fixed-income redemption Ledger entries visible through existing legacy projection types while preserving semantic selection by role, localKey, and groupKey. Conversion and exchange projections, UI, importers, reports, tax/FIFO/performance, automatic basis calculation, correction/reversal, XML/protobuf schema, and new legacy transaction types are intentionally unchanged. --- ...rporateActionRedemptionProjectionTest.java | 380 ++++++++++++++++++ .../DerivedProjectionDescriptorService.java | 52 ++- .../projection/LedgerProjectionSupport.java | 1 + 3 files changed, 428 insertions(+), 5 deletions(-) create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerCorporateActionRedemptionProjectionTest.java diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerCorporateActionRedemptionProjectionTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerCorporateActionRedemptionProjectionTest.java new file mode 100644 index 0000000000..ca31dd406c --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerCorporateActionRedemptionProjectionTest.java @@ -0,0 +1,380 @@ +package name.abuchen.portfolio.model.ledger.projection; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import org.junit.Test; + +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.Unit; +import name.abuchen.portfolio.model.ledger.LedgerMutationContext; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionBasisStatus; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionKind; +import name.abuchen.portfolio.model.ledger.configuration.LedgerLegRole; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.model.ledger.nativeentry.LedgerCorporateActionEditSupport; +import name.abuchen.portfolio.model.ledger.nativeentry.LedgerNativeEntryAssembler; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.Values; + +@SuppressWarnings("nls") +public class LedgerCorporateActionRedemptionProjectionTest +{ + private static final LocalDateTime DATE = LocalDateTime.of(2026, 1, 2, 0, 0); + + @Test + public void testMaturityMaterializesSecurityOutAndCashProjection() + { + var fixture = fixture(); + var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.MATURITY) // + .date(DATE) // + .note("bond maturity") // + .securityContext("context-1", "redemption-1", fixture.portfolio, fixture.bond) // + .securityOut("source-1", "redemption-1", fixture.portfolio, fixture.bond, shares(10)) // + .cash("cash-1", "redemption-1", fixture.account, money(100)) // + .principal("principal-1", "redemption-1", fixture.account, money(100)) // + .basis(CorporateActionBasisStatus.UNKNOWN) // + .buildAndAdd().getEntry(); + + LedgerProjectionService.materialize(fixture.client); + + var portfolioProjection = ledgerBackedPortfolioProjection(fixture.portfolio, + LedgerProjectionRole.DELIVERY_OUTBOUND, "source-1"); + var accountProjection = ledgerBackedAccountProjection(fixture.account, LedgerProjectionRole.ACCOUNT, "cash-1"); + + assertThat(portfolioProjection.getType(), is(PortfolioTransaction.Type.DELIVERY_OUTBOUND)); + assertThat(portfolioProjection.getDateTime(), is(DATE)); + assertThat(portfolioProjection.getNote(), is("bond maturity")); + assertSame(fixture.bond, portfolioProjection.getSecurity()); + assertThat(portfolioProjection.getShares(), is(shares(10))); + + assertThat(accountProjection.getType(), is(AccountTransaction.Type.DEPOSIT)); + assertThat(accountProjection.getAmount(), is(money(100).getAmount())); + assertSame(fixture.bond, accountProjection.getSecurity()); + + assertThat(LedgerCorporateActionEditSupport.postingBySemanticKey(entry, + LedgerLegRole.PRINCIPAL_REDEMPTION_LEG, "principal-1", "redemption-1").getAmount(), + is(money(100).getAmount())); + assertThat(LedgerProjectionSupport.descriptors(entry).size(), is(2)); + assertFalse(hasPrimaryDescriptor(entry, CorporateActionLeg.SECURITY_CONTEXT)); + assertFalse(hasPrimaryDescriptor(entry, CorporateActionLeg.PRINCIPAL)); + } + + @Test + public void testPartialRedemptionMaterializesSecurityOutAndCashProjection() + { + var fixture = fixture(); + + LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.PARTIAL_REDEMPTION) // + .date(DATE) // + .securityContext("context-1", "redemption-1", fixture.portfolio, fixture.bond) // + .securityOut("source-1", "redemption-1", fixture.portfolio, fixture.bond, shares(4)) // + .cash("cash-1", "redemption-1", fixture.account, money(40)) // + .principal("principal-1", "redemption-1", fixture.account, money(40)) // + .basis(CorporateActionBasisStatus.UNKNOWN) // + .buildAndAdd(); + + LedgerProjectionService.materialize(fixture.client); + + var portfolioProjection = ledgerBackedPortfolioProjection(fixture.portfolio, + LedgerProjectionRole.DELIVERY_OUTBOUND, "source-1"); + var accountProjection = ledgerBackedAccountProjection(fixture.account, LedgerProjectionRole.ACCOUNT, "cash-1"); + + assertThat(portfolioProjection.getType(), is(PortfolioTransaction.Type.DELIVERY_OUTBOUND)); + assertThat(portfolioProjection.getShares(), is(shares(4))); + assertThat(accountProjection.getType(), is(AccountTransaction.Type.DEPOSIT)); + assertThat(accountProjection.getAmount(), is(money(40).getAmount())); + } + + @Test + public void testCallMaterializesSecurityOutAndCashProjection() + { + var fixture = fixture(); + + LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.CALL) // + .date(DATE) // + .securityContext("context-1", "call-1", fixture.portfolio, fixture.bond) // + .securityOut("source-1", "call-1", fixture.portfolio, fixture.bond, shares(8)) // + .cash("cash-1", "call-1", fixture.account, money(80)) // + .principal("principal-1", "call-1", fixture.account, money(80)) // + .buildAndAdd(); + + LedgerProjectionService.materialize(fixture.client); + + assertThat(ledgerBackedPortfolioProjection(fixture.portfolio, LedgerProjectionRole.DELIVERY_OUTBOUND, + "source-1").getShares(), is(shares(8))); + assertThat(ledgerBackedAccountProjection(fixture.account, LedgerProjectionRole.ACCOUNT, "cash-1").getAmount(), + is(money(80).getAmount())); + } + + @Test + public void testPutMaterializesSecurityOutAndCashProjection() + { + var fixture = fixture(); + + LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.PUT) // + .date(DATE) // + .securityContext("context-1", "put-1", fixture.portfolio, fixture.bond) // + .securityOut("source-1", "put-1", fixture.portfolio, fixture.bond, shares(3)) // + .cash("cash-1", "put-1", fixture.account, money(30)) // + .principal("principal-1", "put-1", fixture.account, money(30)) // + .buildAndAdd(); + + LedgerProjectionService.materialize(fixture.client); + + assertThat(ledgerBackedPortfolioProjection(fixture.portfolio, LedgerProjectionRole.DELIVERY_OUTBOUND, + "source-1").getType(), is(PortfolioTransaction.Type.DELIVERY_OUTBOUND)); + assertThat(ledgerBackedAccountProjection(fixture.account, LedgerProjectionRole.ACCOUNT, "cash-1").getType(), + is(AccountTransaction.Type.DEPOSIT)); + } + + @Test + public void testMaturityMaterializesRepeatedSecurityOutAndCashProjections() + { + var fixture = fixture(); + var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.MATURITY) // + .date(DATE) // + .securityContext("context-1", "main", fixture.portfolio, fixture.bond) // + .securityOut("source-1", "redemption-1", fixture.portfolio, fixture.bond, shares(5)) // + .securityOut("source-2", "redemption-2", fixture.secondPortfolio, fixture.secondBond, + shares(7)) // + .cash("cash-1", "redemption-1", fixture.account, money(50)) // + .cash("cash-2", "redemption-2", fixture.secondAccount, money(70)) // + .principal("principal-1", "redemption-1", fixture.account, money(50)) // + .principal("principal-2", "redemption-2", fixture.secondAccount, money(70)) // + .buildAndAdd().getEntry(); + + var descriptors = LedgerProjectionSupport.descriptors(entry); + var portfolioDescriptors = descriptors.stream() + .filter(descriptor -> descriptor.getRole() == LedgerProjectionRole.DELIVERY_OUTBOUND) + .toList(); + var accountDescriptors = descriptors.stream() + .filter(descriptor -> descriptor.getRole() == LedgerProjectionRole.ACCOUNT).toList(); + + assertThat(portfolioDescriptors.size(), is(2)); + assertThat(accountDescriptors.size(), is(2)); + assertThat(semanticKeys(portfolioDescriptors), is(Set.of("source-1", "source-2"))); + assertThat(semanticKeys(accountDescriptors), is(Set.of("cash-1", "cash-2"))); + assertThrows(IllegalArgumentException.class, + () -> new LedgerProjectionFactory().createProjection(entry, LedgerProjectionRole.DELIVERY_OUTBOUND)); + assertThrows(IllegalArgumentException.class, + () -> new LedgerProjectionFactory().createProjection(entry, LedgerProjectionRole.ACCOUNT)); + assertThat(new LedgerProjectionFactory().createProjection(entry, LedgerProjectionRole.DELIVERY_OUTBOUND, + "source-2").getUUID(), is(entry.getUUID() + ":DELIVERY_OUTBOUND:source-2")); + assertThat(new LedgerProjectionFactory().createProjection(entry, LedgerProjectionRole.ACCOUNT, + "cash-2").getUUID(), is(entry.getUUID() + ":ACCOUNT:cash-2")); + + LedgerProjectionService.materialize(fixture.client); + LedgerProjectionService.materialize(fixture.client); + + var firstPortfolio = ledgerBackedPortfolioProjection(fixture.portfolio, + LedgerProjectionRole.DELIVERY_OUTBOUND, "source-1"); + var secondPortfolio = ledgerBackedPortfolioProjection(fixture.secondPortfolio, + LedgerProjectionRole.DELIVERY_OUTBOUND, "source-2"); + var firstAccount = ledgerBackedAccountProjection(fixture.account, LedgerProjectionRole.ACCOUNT, "cash-1"); + var secondAccount = ledgerBackedAccountProjection(fixture.secondAccount, LedgerProjectionRole.ACCOUNT, + "cash-2"); + + assertThat(firstPortfolio.getShares(), is(shares(5))); + assertThat(secondPortfolio.getShares(), is(shares(7))); + assertThat(firstAccount.getAmount(), is(money(50).getAmount())); + assertThat(secondAccount.getAmount(), is(money(70).getAmount())); + assertThat(fixture.portfolio.getTransactions().size(), is(1)); + assertThat(fixture.secondPortfolio.getTransactions().size(), is(1)); + assertThat(fixture.account.getTransactions().size(), is(1)); + assertThat(fixture.secondAccount.getTransactions().size(), is(1)); + assertThat(runtimeProjectionIds(List.of(firstPortfolio, secondPortfolio)), + is(portfolioDescriptors.stream().map(DerivedProjectionDescriptor::getRuntimeProjectionId) + .collect(Collectors.toSet()))); + assertThat(runtimeProjectionIds(List.of(firstAccount, secondAccount)), + is(accountDescriptors.stream().map(DerivedProjectionDescriptor::getRuntimeProjectionId) + .collect(Collectors.toSet()))); + } + + @Test + public void testMaturityKeepsOptionalAccruedInterestAsNonProjectingDetail() + { + var fixture = fixture(); + var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.MATURITY) // + .date(DATE) // + .securityContext("context-1", "redemption-1", fixture.portfolio, fixture.bond) // + .securityOut("source-1", "redemption-1", fixture.portfolio, fixture.bond, shares(10)) // + .cash("cash-1", "redemption-1", fixture.account, money(100)) // + .principal("principal-1", "redemption-1", fixture.account, money(100)) // + .accruedInterest("interest-1", "redemption-1", fixture.account, money(5)) // + .fee("fee-1", fixture.account, money(2), "redemption-1") // + .tax("tax-1", fixture.account, money(1), "redemption-1") // + .buildAndAdd().getEntry(); + + LedgerProjectionService.materialize(fixture.client); + + var accountProjection = ledgerBackedAccountProjection(fixture.account, LedgerProjectionRole.ACCOUNT, "cash-1"); + + assertThat(accountProjection.getUnit(Unit.Type.FEE).orElseThrow().getAmount().getAmount(), + is(money(2).getAmount())); + assertThat(accountProjection.getUnit(Unit.Type.TAX).orElseThrow().getAmount().getAmount(), + is(money(1).getAmount())); + assertThat(LedgerProjectionSupport.descriptors(entry).size(), is(2)); + assertFalse(hasPrimaryDescriptor(entry, CorporateActionLeg.ACCRUED_INTEREST)); + assertThat(LedgerCorporateActionEditSupport.postingBySemanticKey(entry, LedgerLegRole.ACCRUED_INTEREST_LEG, + "interest-1", "redemption-1").getAmount(), is(money(5).getAmount())); + } + + @Test + public void testMaturityDoesNotAttachMismatchedFeeTaxOrAccruedInterest() + { + var fixture = fixture(); + var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.MATURITY) // + .date(DATE) // + .securityContext("context-1", "redemption-1", fixture.portfolio, fixture.bond) // + .securityOut("source-1", "redemption-1", fixture.portfolio, fixture.bond, shares(10)) // + .cash("cash-1", "redemption-1", fixture.account, money(100)) // + .principal("principal-1", "redemption-1", fixture.account, money(100)) // + .accruedInterest("interest-1", "other", fixture.account, money(5)) // + .fee("fee-1", fixture.account, money(2), "other") // + .tax("tax-1", fixture.account, money(1), "other") // + .buildAndAdd().getEntry(); + + LedgerProjectionService.materialize(fixture.client); + + var accountProjection = ledgerBackedAccountProjection(fixture.account, LedgerProjectionRole.ACCOUNT, "cash-1"); + + assertTrue(accountProjection.getUnit(Unit.Type.FEE).isEmpty()); + assertTrue(accountProjection.getUnit(Unit.Type.TAX).isEmpty()); + assertThat(LedgerProjectionSupport.descriptor(entry, LedgerProjectionRole.ACCOUNT, "cash-1") + .getUnitPostings().size(), is(0)); + assertFalse(hasPrimaryDescriptor(entry, CorporateActionLeg.ACCRUED_INTEREST)); + } + + @Test + public void testDeletingMaturityRemovesRedemptionProjections() + { + var fixture = fixture(); + var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.MATURITY) // + .date(DATE) // + .securityContext("context-1", "redemption-1", fixture.portfolio, fixture.bond) // + .securityOut("source-1", "redemption-1", fixture.portfolio, fixture.bond, shares(10)) // + .cash("cash-1", "redemption-1", fixture.account, money(100)) // + .principal("principal-1", "redemption-1", fixture.account, money(100)) // + .buildAndAdd().getEntry(); + + LedgerProjectionService.materialize(fixture.client); + LedgerProjectionService.materialize(fixture.client); + + assertThat(fixture.portfolio.getTransactions().size(), is(1)); + assertThat(fixture.account.getTransactions().size(), is(1)); + + new LedgerMutationContext(fixture.client).removeEntry(entry); + + assertTrue(fixture.portfolio.getTransactions().isEmpty()); + assertTrue(fixture.account.getTransactions().isEmpty()); + } + + private boolean hasPrimaryDescriptor(name.abuchen.portfolio.model.ledger.LedgerEntry entry, CorporateActionLeg leg) + { + return LedgerProjectionSupport.descriptors(entry).stream().map(DerivedProjectionDescriptor::getPrimaryPosting) + .anyMatch(posting -> posting.getCorporateActionLeg() == leg); + } + + private LedgerBackedPortfolioTransaction ledgerBackedPortfolioProjection(Portfolio portfolio, + LedgerProjectionRole role, String semanticInstanceKey) + { + return portfolio.getTransactions().stream() // + .filter(LedgerBackedPortfolioTransaction.class::isInstance) // + .map(LedgerBackedPortfolioTransaction.class::cast) // + .filter(transaction -> transaction.getLedgerProjectionRole() == role) // + .filter(transaction -> transaction.getLedgerProjectionDescriptor().getSemanticInstanceKey() + .filter(semanticInstanceKey::equals).isPresent()) // + .findFirst().orElseThrow(); + } + + private LedgerBackedAccountTransaction ledgerBackedAccountProjection(Account account, LedgerProjectionRole role, + String semanticInstanceKey) + { + return account.getTransactions().stream() // + .filter(LedgerBackedAccountTransaction.class::isInstance) // + .map(LedgerBackedAccountTransaction.class::cast) // + .filter(transaction -> transaction.getLedgerProjectionRole() == role) // + .filter(transaction -> transaction.getLedgerProjectionDescriptor().getSemanticInstanceKey() + .filter(semanticInstanceKey::equals).isPresent()) // + .findFirst().orElseThrow(); + } + + private Set semanticKeys(List descriptors) + { + return descriptors.stream().map(descriptor -> descriptor.getSemanticInstanceKey().orElseThrow()) + .collect(Collectors.toSet()); + } + + private Set runtimeProjectionIds(List transactions) + { + return transactions.stream().map(LedgerBackedTransaction::getRuntimeProjectionId).collect(Collectors.toSet()); + } + + private static long shares(long shares) + { + return Values.Share.factorize(shares); + } + + private static Money money(long amount) + { + return Money.of(CurrencyUnit.EUR, Values.Amount.factorize(amount)); + } + + private static Fixture fixture() + { + var client = new Client(); + var account = new Account(); + var secondAccount = new Account(); + var portfolio = new Portfolio(); + var secondPortfolio = new Portfolio(); + var bond = new Security("Bond AG", CurrencyUnit.EUR); + var secondBond = new Security("Second Bond AG", CurrencyUnit.EUR); + + account.setName("Cash"); + account.setCurrencyCode(CurrencyUnit.EUR); + secondAccount.setName("Second Cash"); + secondAccount.setCurrencyCode(CurrencyUnit.EUR); + portfolio.setName("Portfolio"); + secondPortfolio.setName("Second Portfolio"); + + client.addAccount(account); + client.addAccount(secondAccount); + client.addPortfolio(portfolio); + client.addPortfolio(secondPortfolio); + client.addSecurity(bond); + client.addSecurity(secondBond); + + return new Fixture(client, account, secondAccount, portfolio, secondPortfolio, bond, secondBond); + } + + private record Fixture(Client client, Account account, Account secondAccount, Portfolio portfolio, + Portfolio secondPortfolio, Security bond, Security secondBond) + { + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java index 1a5d4f838e..258db7e4da 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java @@ -109,6 +109,12 @@ private void corporateAction(LedgerEntry entry, List k == CorporateActionKind.CASH_DISTRIBUTION || k == CorporateActionKind.COUPON_PAYMENT) .isPresent()) cashOrientedCorporateAction(entry, descriptors, diagnostics); @@ -123,6 +129,15 @@ private boolean isSecurityInCorporateAction(CorporateActionKind kind) }; } + private boolean isFixedIncomeRedemptionCorporateAction(CorporateActionKind kind) + { + return switch (kind) + { + case MATURITY, PARTIAL_REDEMPTION, CALL, PUT -> true; + default -> false; + }; + } + private void spinOff(LedgerEntry entry, List descriptors, List diagnostics) { repeatedPortfolio(entry, LedgerProjectionRole.OLD_SECURITY_LEG, @@ -163,11 +178,17 @@ private void securityInCorporateAction(LedgerEntry entry, List descriptors, List diagnostics) { - repeatedAccount(entry, LedgerProjectionRole.ACCOUNT, - primary().and(postingType(LedgerPostingType.CASH)).and(localKey(LedgerProjectionRole.ACCOUNT)), - primary().and(postingType(LedgerPostingType.CASH)) - .and(semantic(LedgerPostingSemanticRole.CASH)), - true, diagnostics).forEach(descriptors::add); + repeatedAccount(entry, LedgerProjectionRole.ACCOUNT, cashPreferredSelector(), cashRepeatedSelector(), true, + diagnostics).forEach(descriptors::add); + } + + private void fixedIncomeRedemptionCorporateAction(LedgerEntry entry, List descriptors, + List diagnostics) + { + repeatedPortfolio(entry, LedgerProjectionRole.DELIVERY_OUTBOUND, sourceSecurityPreferredSelector(), + sourceSecurityRepeatedSelector(), true, diagnostics).forEach(descriptors::add); + repeatedAccount(entry, LedgerProjectionRole.ACCOUNT, cashPreferredSelector(), cashRepeatedSelector(), true, + diagnostics).forEach(descriptors::add); } private Predicate securityInPreferredSelector() @@ -196,6 +217,27 @@ private Predicate cashCompensationRepeatedSelector() return primary().and(corporateLeg(CorporateActionLeg.CASH_COMPENSATION)); } + private Predicate sourceSecurityPreferredSelector() + { + return primary().and(corporateLeg(CorporateActionLeg.SOURCE_SECURITY)) + .and(localKey(LedgerProjectionRole.DELIVERY_OUTBOUND)); + } + + private Predicate sourceSecurityRepeatedSelector() + { + return primary().and(corporateLeg(CorporateActionLeg.SOURCE_SECURITY)); + } + + private Predicate cashPreferredSelector() + { + return primary().and(postingType(LedgerPostingType.CASH)).and(localKey(LedgerProjectionRole.ACCOUNT)); + } + + private Predicate cashRepeatedSelector() + { + return primary().and(postingType(LedgerPostingType.CASH)).and(semantic(LedgerPostingSemanticRole.CASH)); + } + private java.util.Optional account(LedgerEntry entry, LedgerProjectionRole role, Predicate selector, List diagnostics) { diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionSupport.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionSupport.java index 4bb1cd0f46..15a299a988 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionSupport.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionSupport.java @@ -136,6 +136,7 @@ private static AccountTransaction.Type corporateActionAccountType(DerivedProject { case CASH_DISTRIBUTION -> AccountTransaction.Type.DIVIDENDS; case COUPON_PAYMENT -> AccountTransaction.Type.INTEREST; + case MATURITY, PARTIAL_REDEMPTION, CALL, PUT -> AccountTransaction.Type.DEPOSIT; default -> throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PROJ_072 .message("Unsupported targeted account kind " + kind)); //$NON-NLS-1$ }; From 5b04ae171a578151b7b33014267694ecf82074b0 Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Sun, 5 Jul 2026 18:23:57 +0200 Subject: [PATCH 61/68] Project Ledger conversion exchange corporate actions Adds descriptor derivation and materialization coverage for conversion and exchange corporate actions, including source security-out, target security-in, and optional cash compensation projections. This makes conversion and exchange Ledger entries visible through existing Ledger-backed portfolio and account projection types while preserving semantic selection by role, localKey, and groupKey. Defaulted interest, restructuring, default projections, UI, importers, reports, tax/FIFO/performance, automatic basis calculation, correction/reversal, XML/protobuf schema, and new legacy transaction types are intentionally unchanged. --- ...ctionConversionExchangeProjectionTest.java | 365 ++++++++++++++++++ .../DerivedProjectionDescriptorService.java | 26 ++ 2 files changed, 391 insertions(+) create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerCorporateActionConversionExchangeProjectionTest.java diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerCorporateActionConversionExchangeProjectionTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerCorporateActionConversionExchangeProjectionTest.java new file mode 100644 index 0000000000..49e42135ff --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerCorporateActionConversionExchangeProjectionTest.java @@ -0,0 +1,365 @@ +package name.abuchen.portfolio.model.ledger.projection; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertFalse; +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.LocalDateTime; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import org.junit.Test; + +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.Unit; +import name.abuchen.portfolio.model.ledger.LedgerMutationContext; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionBasisMethod; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionBasisStatus; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionKind; +import name.abuchen.portfolio.model.ledger.configuration.LedgerLegRole; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.model.ledger.nativeentry.LedgerCorporateActionEditSupport; +import name.abuchen.portfolio.model.ledger.nativeentry.LedgerNativeEntryAssembler; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.Values; + +@SuppressWarnings("nls") +public class LedgerCorporateActionConversionExchangeProjectionTest +{ + private static final LocalDateTime DATE = LocalDateTime.of(2026, 1, 2, 0, 0); + + @Test + public void testConversionMaterializesSourceAndTargetProjections() + { + var fixture = fixture(); + var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.CONVERSION) // + .date(DATE) // + .note("bond conversion") // + .securityContext("context-1", "main", fixture.portfolio, fixture.sourceSecurity) // + .securityOut("source-1", "main", fixture.portfolio, fixture.sourceSecurity, shares(10)) // + .securityIn("target-1", "main", fixture.portfolio, fixture.targetSecurity, shares(5)) // + .basis(CorporateActionBasisStatus.PROVIDED) // + .basisMethod(CorporateActionBasisMethod.PERCENTAGE_ALLOCATION) // + .basisPercentageAllocation(LedgerLegRole.TARGET_SECURITY_LEG, "target-1", "main", + BigDecimal.valueOf(100)) // + .buildAndAdd().getEntry(); + + LedgerProjectionService.materialize(fixture.client); + + var sourceProjection = ledgerBackedPortfolioProjection(fixture.portfolio, + LedgerProjectionRole.DELIVERY_OUTBOUND, "source-1"); + var targetProjection = ledgerBackedPortfolioProjection(fixture.portfolio, + LedgerProjectionRole.NEW_SECURITY_LEG, "target-1"); + + assertThat(sourceProjection.getType(), is(PortfolioTransaction.Type.DELIVERY_OUTBOUND)); + assertThat(sourceProjection.getDateTime(), is(DATE)); + assertThat(sourceProjection.getNote(), is("bond conversion")); + assertSame(fixture.sourceSecurity, sourceProjection.getSecurity()); + assertThat(sourceProjection.getShares(), is(shares(10))); + + assertThat(targetProjection.getType(), is(PortfolioTransaction.Type.DELIVERY_INBOUND)); + assertSame(fixture.targetSecurity, targetProjection.getSecurity()); + assertThat(targetProjection.getShares(), is(shares(5))); + + assertThat(LedgerProjectionSupport.descriptors(entry).size(), is(2)); + assertFalse(hasPrimaryDescriptor(entry, CorporateActionLeg.SECURITY_CONTEXT)); + } + + @Test + public void testExchangeMaterializesTargetAndOptionalCashProjections() + { + var fixture = fixture(); + var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.EXCHANGE) // + .date(DATE) // + .securityContext("context-1", "main", fixture.portfolio, fixture.sourceSecurity) // + .securityOut("source-1", "main", fixture.portfolio, fixture.sourceSecurity, shares(10)) // + .securityIn("target-1", "main", fixture.portfolio, fixture.targetSecurity, shares(6)) // + .securityIn("target-2", "main", fixture.secondPortfolio, fixture.secondTargetSecurity, + shares(4)) // + .cash("cash-1", "cash-1", fixture.account, money(9)) // + .basis(CorporateActionBasisStatus.PROVIDED) // + .basisMethod(CorporateActionBasisMethod.PERCENTAGE_ALLOCATION) // + .basisPercentageAllocation(LedgerLegRole.TARGET_SECURITY_LEG, "target-1", "main", + BigDecimal.valueOf(60)) // + .basisPercentageAllocation(LedgerLegRole.TARGET_SECURITY_LEG, "target-2", "main", + BigDecimal.valueOf(40)) // + .buildAndAdd().getEntry(); + + LedgerProjectionService.materialize(fixture.client); + + assertThat(ledgerBackedPortfolioProjection(fixture.portfolio, LedgerProjectionRole.DELIVERY_OUTBOUND, + "source-1").getShares(), is(shares(10))); + assertThat(ledgerBackedPortfolioProjection(fixture.portfolio, LedgerProjectionRole.NEW_SECURITY_LEG, + "target-1").getShares(), is(shares(6))); + assertThat(ledgerBackedPortfolioProjection(fixture.secondPortfolio, LedgerProjectionRole.NEW_SECURITY_LEG, + "target-2").getShares(), is(shares(4))); + + var cashProjection = ledgerBackedAccountProjection(fixture.account, LedgerProjectionRole.CASH_COMPENSATION, + "cash-1"); + assertThat(cashProjection.getType(), is(AccountTransaction.Type.DEPOSIT)); + assertThat(cashProjection.getAmount(), is(money(9).getAmount())); + + assertThrows(IllegalArgumentException.class, + () -> new LedgerProjectionFactory().createProjection(entry, LedgerProjectionRole.NEW_SECURITY_LEG)); + assertThat(new LedgerProjectionFactory() + .createProjection(entry, LedgerProjectionRole.NEW_SECURITY_LEG, "target-2").getUUID(), + is(entry.getUUID() + ":NEW_SECURITY_LEG:target-2")); + } + + @Test + public void testExchangeMaterializesRepeatedSourceTargetAndCashProjections() + { + var fixture = fixture(); + var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.EXCHANGE) // + .date(DATE) // + .securityContext("context-1", "main", fixture.portfolio, fixture.sourceSecurity) // + .securityOut("source-1", "source-1", fixture.portfolio, fixture.sourceSecurity, shares(10)) // + .securityOut("source-2", "source-2", fixture.secondPortfolio, fixture.secondSourceSecurity, + shares(7)) // + .securityIn("target-1", "target-1", fixture.portfolio, fixture.targetSecurity, shares(6)) // + .securityIn("target-2", "target-2", fixture.secondPortfolio, fixture.secondTargetSecurity, + shares(4)) // + .cash("cash-1", "cash-1", fixture.account, money(9)) // + .cash("cash-2", "cash-2", fixture.secondAccount, money(11)) // + .buildAndAdd().getEntry(); + + var descriptors = LedgerProjectionSupport.descriptors(entry); + var sourceDescriptors = descriptors.stream() + .filter(descriptor -> descriptor.getRole() == LedgerProjectionRole.DELIVERY_OUTBOUND) + .toList(); + var targetDescriptors = descriptors.stream() + .filter(descriptor -> descriptor.getRole() == LedgerProjectionRole.NEW_SECURITY_LEG).toList(); + var cashDescriptors = descriptors.stream() + .filter(descriptor -> descriptor.getRole() == LedgerProjectionRole.CASH_COMPENSATION).toList(); + + assertThat(sourceDescriptors.size(), is(2)); + assertThat(targetDescriptors.size(), is(2)); + assertThat(cashDescriptors.size(), is(2)); + assertThat(semanticKeys(sourceDescriptors), is(Set.of("source-1", "source-2"))); + assertThat(semanticKeys(targetDescriptors), is(Set.of("target-1", "target-2"))); + assertThat(semanticKeys(cashDescriptors), is(Set.of("cash-1", "cash-2"))); + + assertThrows(IllegalArgumentException.class, + () -> new LedgerProjectionFactory().createProjection(entry, LedgerProjectionRole.DELIVERY_OUTBOUND)); + assertThrows(IllegalArgumentException.class, + () -> new LedgerProjectionFactory().createProjection(entry, LedgerProjectionRole.NEW_SECURITY_LEG)); + assertThrows(IllegalArgumentException.class, + () -> new LedgerProjectionFactory().createProjection(entry, LedgerProjectionRole.CASH_COMPENSATION)); + + LedgerProjectionService.materialize(fixture.client); + LedgerProjectionService.materialize(fixture.client); + + var firstSource = ledgerBackedPortfolioProjection(fixture.portfolio, + LedgerProjectionRole.DELIVERY_OUTBOUND, "source-1"); + var secondSource = ledgerBackedPortfolioProjection(fixture.secondPortfolio, + LedgerProjectionRole.DELIVERY_OUTBOUND, "source-2"); + var firstTarget = ledgerBackedPortfolioProjection(fixture.portfolio, LedgerProjectionRole.NEW_SECURITY_LEG, + "target-1"); + var secondTarget = ledgerBackedPortfolioProjection(fixture.secondPortfolio, LedgerProjectionRole.NEW_SECURITY_LEG, + "target-2"); + var firstCash = ledgerBackedAccountProjection(fixture.account, LedgerProjectionRole.CASH_COMPENSATION, + "cash-1"); + var secondCash = ledgerBackedAccountProjection(fixture.secondAccount, LedgerProjectionRole.CASH_COMPENSATION, + "cash-2"); + + assertThat(firstSource.getShares(), is(shares(10))); + assertThat(secondSource.getShares(), is(shares(7))); + assertThat(firstTarget.getShares(), is(shares(6))); + assertThat(secondTarget.getShares(), is(shares(4))); + assertThat(firstCash.getAmount(), is(money(9).getAmount())); + assertThat(secondCash.getAmount(), is(money(11).getAmount())); + assertThat(fixture.portfolio.getTransactions().size(), is(2)); + assertThat(fixture.secondPortfolio.getTransactions().size(), is(2)); + assertThat(fixture.account.getTransactions().size(), is(1)); + assertThat(fixture.secondAccount.getTransactions().size(), is(1)); + assertThat(runtimeProjectionIds(List.of(firstSource, secondSource)), + is(sourceDescriptors.stream().map(DerivedProjectionDescriptor::getRuntimeProjectionId) + .collect(Collectors.toSet()))); + assertThat(runtimeProjectionIds(List.of(firstTarget, secondTarget)), + is(targetDescriptors.stream().map(DerivedProjectionDescriptor::getRuntimeProjectionId) + .collect(Collectors.toSet()))); + assertThat(runtimeProjectionIds(List.of(firstCash, secondCash)), + is(cashDescriptors.stream().map(DerivedProjectionDescriptor::getRuntimeProjectionId) + .collect(Collectors.toSet()))); + } + + @Test + public void testConversionKeepsOptionalAccruedInterestAsNonProjectingDetail() + { + var fixture = fixture(); + var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.CONVERSION) // + .date(DATE) // + .securityContext("context-1", "main", fixture.portfolio, fixture.sourceSecurity) // + .securityOut("source-1", "main", fixture.portfolio, fixture.sourceSecurity, shares(10)) // + .securityIn("target-1", "main", fixture.portfolio, fixture.targetSecurity, shares(5)) // + .cash("cash-1", "cash-1", fixture.account, money(9)) // + .accruedInterest("interest-1", "cash-1", fixture.account, money(5)) // + .buildAndAdd().getEntry(); + + LedgerProjectionService.materialize(fixture.client); + + assertThat(ledgerBackedAccountProjection(fixture.account, LedgerProjectionRole.CASH_COMPENSATION, "cash-1") + .getAmount(), is(money(9).getAmount())); + assertThat(LedgerProjectionSupport.descriptors(entry).size(), is(3)); + assertFalse(hasPrimaryDescriptor(entry, CorporateActionLeg.ACCRUED_INTEREST)); + assertThat(LedgerCorporateActionEditSupport.postingBySemanticKey(entry, LedgerLegRole.ACCRUED_INTEREST_LEG, + "interest-1", "cash-1").getAmount(), is(money(5).getAmount())); + } + + @Test + public void testConversionAttachesFeeTaxByMatchingGroup() + { + var fixture = fixture(); + var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.CONVERSION) // + .date(DATE) // + .securityContext("context-1", "main", fixture.portfolio, fixture.sourceSecurity) // + .securityOut("source-1", "main", fixture.portfolio, fixture.sourceSecurity, shares(10)) // + .securityIn("target-1", "main", fixture.portfolio, fixture.targetSecurity, shares(5)) // + .cash("cash-1", "cash-1", fixture.account, money(9)) // + .fee("fee-1", fixture.account, money(2), "cash-1") // + .tax("tax-1", fixture.account, money(1), "cash-1") // + .buildAndAdd().getEntry(); + + LedgerProjectionService.materialize(fixture.client); + + var cashProjection = ledgerBackedAccountProjection(fixture.account, LedgerProjectionRole.CASH_COMPENSATION, + "cash-1"); + + assertThat(cashProjection.getUnit(Unit.Type.FEE).orElseThrow().getAmount().getAmount(), is(money(2).getAmount())); + assertThat(cashProjection.getUnit(Unit.Type.TAX).orElseThrow().getAmount().getAmount(), is(money(1).getAmount())); + assertThat(LedgerProjectionSupport.descriptor(entry, LedgerProjectionRole.CASH_COMPENSATION, "cash-1") + .getUnitPostings().size(), is(2)); + } + + @Test + public void testDeletingConversionRemovesProjectedTransactions() + { + var fixture = fixture(); + var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.CONVERSION) // + .date(DATE) // + .securityContext("context-1", "main", fixture.portfolio, fixture.sourceSecurity) // + .securityOut("source-1", "main", fixture.portfolio, fixture.sourceSecurity, shares(10)) // + .securityIn("target-1", "main", fixture.portfolio, fixture.targetSecurity, shares(5)) // + .cash("cash-1", "cash-1", fixture.account, money(9)) // + .buildAndAdd().getEntry(); + + LedgerProjectionService.materialize(fixture.client); + LedgerProjectionService.materialize(fixture.client); + + assertThat(fixture.portfolio.getTransactions().size(), is(2)); + assertThat(fixture.account.getTransactions().size(), is(1)); + + new LedgerMutationContext(fixture.client).removeEntry(entry); + + assertTrue(fixture.portfolio.getTransactions().isEmpty()); + assertTrue(fixture.account.getTransactions().isEmpty()); + } + + private boolean hasPrimaryDescriptor(name.abuchen.portfolio.model.ledger.LedgerEntry entry, CorporateActionLeg leg) + { + return LedgerProjectionSupport.descriptors(entry).stream().map(DerivedProjectionDescriptor::getPrimaryPosting) + .anyMatch(posting -> posting.getCorporateActionLeg() == leg); + } + + private LedgerBackedPortfolioTransaction ledgerBackedPortfolioProjection(Portfolio portfolio, + LedgerProjectionRole role, String semanticInstanceKey) + { + return portfolio.getTransactions().stream() // + .filter(LedgerBackedPortfolioTransaction.class::isInstance) // + .map(LedgerBackedPortfolioTransaction.class::cast) // + .filter(transaction -> transaction.getLedgerProjectionRole() == role) // + .filter(transaction -> transaction.getLedgerProjectionDescriptor().getSemanticInstanceKey() + .filter(semanticInstanceKey::equals).isPresent()) // + .findFirst().orElseThrow(); + } + + private LedgerBackedAccountTransaction ledgerBackedAccountProjection(Account account, LedgerProjectionRole role, + String semanticInstanceKey) + { + return account.getTransactions().stream() // + .filter(LedgerBackedAccountTransaction.class::isInstance) // + .map(LedgerBackedAccountTransaction.class::cast) // + .filter(transaction -> transaction.getLedgerProjectionRole() == role) // + .filter(transaction -> transaction.getLedgerProjectionDescriptor().getSemanticInstanceKey() + .filter(semanticInstanceKey::equals).isPresent()) // + .findFirst().orElseThrow(); + } + + private Set semanticKeys(List descriptors) + { + return descriptors.stream().map(descriptor -> descriptor.getSemanticInstanceKey().orElseThrow()) + .collect(Collectors.toSet()); + } + + private Set runtimeProjectionIds(List transactions) + { + return transactions.stream().map(LedgerBackedTransaction::getRuntimeProjectionId).collect(Collectors.toSet()); + } + + private static long shares(long shares) + { + return Values.Share.factorize(shares); + } + + private static Money money(long amount) + { + return Money.of(CurrencyUnit.EUR, Values.Amount.factorize(amount)); + } + + private static Fixture fixture() + { + var client = new Client(); + var account = new Account(); + var secondAccount = new Account(); + var portfolio = new Portfolio(); + var secondPortfolio = new Portfolio(); + var sourceSecurity = new Security("Source AG", CurrencyUnit.EUR); + var secondSourceSecurity = new Security("Second Source AG", CurrencyUnit.EUR); + var targetSecurity = new Security("Target AG", CurrencyUnit.EUR); + var secondTargetSecurity = new Security("Second Target AG", CurrencyUnit.EUR); + + account.setName("Cash"); + account.setCurrencyCode(CurrencyUnit.EUR); + secondAccount.setName("Second Cash"); + secondAccount.setCurrencyCode(CurrencyUnit.EUR); + portfolio.setName("Portfolio"); + secondPortfolio.setName("Second Portfolio"); + + client.addAccount(account); + client.addAccount(secondAccount); + client.addPortfolio(portfolio); + client.addPortfolio(secondPortfolio); + client.addSecurity(sourceSecurity); + client.addSecurity(secondSourceSecurity); + client.addSecurity(targetSecurity); + client.addSecurity(secondTargetSecurity); + + return new Fixture(client, account, secondAccount, portfolio, secondPortfolio, sourceSecurity, + secondSourceSecurity, targetSecurity, secondTargetSecurity); + } + + private record Fixture(Client client, Account account, Account secondAccount, Portfolio portfolio, + Portfolio secondPortfolio, Security sourceSecurity, Security secondSourceSecurity, + Security targetSecurity, Security secondTargetSecurity) + { + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java index 258db7e4da..b530d78abd 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java @@ -115,6 +115,12 @@ private void corporateAction(LedgerEntry entry, List k == CorporateActionKind.CASH_DISTRIBUTION || k == CorporateActionKind.COUPON_PAYMENT) .isPresent()) cashOrientedCorporateAction(entry, descriptors, diagnostics); @@ -129,6 +135,15 @@ private boolean isSecurityInCorporateAction(CorporateActionKind kind) }; } + private boolean isSecurityReorganizationCorporateAction(CorporateActionKind kind) + { + return switch (kind) + { + case CONVERSION, EXCHANGE -> true; + default -> false; + }; + } + private boolean isFixedIncomeRedemptionCorporateAction(CorporateActionKind kind) { return switch (kind) @@ -191,6 +206,17 @@ private void fixedIncomeRedemptionCorporateAction(LedgerEntry entry, List descriptors, List diagnostics) + { + repeatedPortfolio(entry, LedgerProjectionRole.DELIVERY_OUTBOUND, sourceSecurityPreferredSelector(), + sourceSecurityRepeatedSelector(), true, diagnostics).forEach(descriptors::add); + repeatedPortfolio(entry, LedgerProjectionRole.NEW_SECURITY_LEG, securityInPreferredSelector(), + securityInRepeatedSelector(), true, diagnostics).forEach(descriptors::add); + repeatedAccount(entry, LedgerProjectionRole.CASH_COMPENSATION, cashCompensationPreferredSelector(), + cashCompensationRepeatedSelector(), true, diagnostics).forEach(descriptors::add); + } + private Predicate securityInPreferredSelector() { return primary().and(corporateLeg(CorporateActionLeg.TARGET_SECURITY) From 4de61e2030937b925a765d762990469995d12f1f Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Sun, 5 Jul 2026 18:40:57 +0200 Subject: [PATCH 62/68] Project Ledger open-movement corporate actions Adds generic projection descriptor support for open-movement corporate actions so defaulted interest, restructuring, and default entries project their actual cash, security-in, security-out, and standalone fee/tax movements. This closes the remaining Ledger projection gap for broad corporate-action profiles while preserving semantic selection by role, localKey, and groupKey and reusing existing legacy projection types. UI, importers, reports, tax/FIFO/performance, automatic basis calculation, correction/reversal, XML/protobuf schema, and new legacy transaction types are intentionally unchanged. --- ...ateActionMultiMovementPersistenceTest.java | 8 +- ...orateActionOpenMovementProjectionTest.java | 336 ++++++++++++++++++ .../DerivedProjectionDescriptorService.java | 45 +++ .../projection/LedgerProjectionSupport.java | 11 +- 4 files changed, 397 insertions(+), 3 deletions(-) create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerCorporateActionOpenMovementProjectionTest.java diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java index 7168badd72..782608d70f 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java @@ -636,7 +636,13 @@ private void assertDefaultedInterestPrimaryMovement(LedgerEntry entry) .filter(posting -> posting.getType() == LedgerPostingType.CASH) .filter(posting -> "cash-1".equals(posting.getLocalKey())) //$NON-NLS-1$ .count(), is(1L)); - assertTrue(LedgerDescriptorTestSupport.descriptors(entry).isEmpty()); + var descriptors = LedgerDescriptorTestSupport.descriptors(entry); + assertThat(descriptors.size(), is(1)); + assertThat(descriptors.get(0).getRole(), is(LedgerProjectionRole.ACCOUNT)); + assertThat(descriptors.get(0).getSemanticInstanceKey().orElseThrow(), is("cash-1")); + assertThat(descriptors.get(0).getPrimaryPosting().getType(), is(LedgerPostingType.CASH)); + assertFalse(descriptors.stream().map(DerivedProjectionDescriptor::getPrimaryPosting) + .anyMatch(posting -> posting.getCorporateActionLeg() == CorporateActionLeg.SECURITY_CONTEXT)); } private void assertCashDistribution(LedgerEntry entry) diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerCorporateActionOpenMovementProjectionTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerCorporateActionOpenMovementProjectionTest.java new file mode 100644 index 0000000000..80efae1a41 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerCorporateActionOpenMovementProjectionTest.java @@ -0,0 +1,336 @@ +package name.abuchen.portfolio.model.ledger.projection; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import org.junit.Test; + +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.Unit; +import name.abuchen.portfolio.model.ledger.LedgerMutationContext; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionKind; +import name.abuchen.portfolio.model.ledger.configuration.LedgerLegRole; +import name.abuchen.portfolio.model.ledger.nativeentry.LedgerCorporateActionEditSupport; +import name.abuchen.portfolio.model.ledger.nativeentry.LedgerNativeEntryAssembler; +import name.abuchen.portfolio.model.ledger.nativeentry.LedgerNativeEntryAssemblyException; +import name.abuchen.portfolio.model.ledger.nativeentry.LedgerNativeEntryAssemblyIssue; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.Values; + +@SuppressWarnings("nls") +public class LedgerCorporateActionOpenMovementProjectionTest +{ + private static final LocalDateTime DATE = LocalDateTime.of(2026, 1, 2, 0, 0); + + @Test + public void testDefaultedInterestMaterializesCashMovement() + { + var fixture = fixture(); + var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.DEFAULTED_INTEREST) // + .date(DATE) // + .securityContext("context-1", "cash-1", fixture.portfolio, fixture.sourceSecurity) // + .cash("cash-1", "cash-1", fixture.account, money(12)) // + .fee("fee-1", fixture.account, money(2), "cash-1") // + .tax("tax-1", fixture.account, money(1), "cash-1") // + .buildAndAdd().getEntry(); + + LedgerProjectionService.materialize(fixture.client); + + var projection = ledgerBackedAccountProjection(fixture.account, LedgerProjectionRole.ACCOUNT, "cash-1"); + + assertThat(projection.getType(), is(AccountTransaction.Type.INTEREST)); + assertThat(projection.getAmount(), is(money(12).getAmount())); + assertSame(fixture.sourceSecurity, projection.getSecurity()); + assertThat(projection.getUnit(Unit.Type.FEE).orElseThrow().getAmount().getAmount(), is(money(2).getAmount())); + assertThat(projection.getUnit(Unit.Type.TAX).orElseThrow().getAmount().getAmount(), is(money(1).getAmount())); + assertThat(LedgerProjectionSupport.descriptors(entry).size(), is(1)); + assertFalse(hasPrimaryDescriptor(entry, CorporateActionLeg.SECURITY_CONTEXT)); + } + + @Test + public void testDefaultedInterestMaterializesClaimSecurityMovement() + { + var fixture = fixture(); + + LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.DEFAULTED_INTEREST) // + .date(DATE) // + .securityContext("context-1", "claim-1", fixture.portfolio, fixture.sourceSecurity) // + .securityIn("claim-1", "claim-1", fixture.portfolio, fixture.claimSecurity, shares(3)) // + .buildAndAdd(); + + LedgerProjectionService.materialize(fixture.client); + + var projection = ledgerBackedPortfolioProjection(fixture.portfolio, LedgerProjectionRole.NEW_SECURITY_LEG, + "claim-1"); + + assertThat(projection.getType(), is(PortfolioTransaction.Type.DELIVERY_INBOUND)); + assertSame(fixture.claimSecurity, projection.getSecurity()); + assertThat(projection.getShares(), is(shares(3))); + } + + @Test + public void testDefaultedInterestMaterializesStandaloneFeeMovement() + { + var fixture = fixture(); + + LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.DEFAULTED_INTEREST) // + .date(DATE) // + .securityContext("context-1", "main", fixture.portfolio, fixture.sourceSecurity) // + .fee("fee-1", fixture.account, money(2), null) // + .buildAndAdd(); + + LedgerProjectionService.materialize(fixture.client); + + var projection = ledgerBackedAccountProjection(fixture.account, LedgerProjectionRole.ACCOUNT, "fee-1"); + + assertThat(projection.getType(), is(AccountTransaction.Type.FEES)); + assertThat(projection.getAmount(), is(money(2).getAmount())); + assertTrue(projection.getUnits().findAny().isEmpty()); + } + + @Test + public void testRestructuringMaterializesMixedMovements() + { + var fixture = fixture(); + var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.RESTRUCTURING) // + .date(DATE) // + .securityContext("context-1", "main", fixture.portfolio, fixture.sourceSecurity) // + .securityOut("source-1", "main", fixture.portfolio, fixture.sourceSecurity, shares(10)) // + .securityIn("target-1", "main", fixture.portfolio, fixture.targetSecurity, shares(5)) // + .cash("cash-1", "main", fixture.account, money(9)) // + .principal("principal-1", "main", fixture.account, money(9)) // + .accruedInterest("interest-1", "main", fixture.account, money(1)) // + .buildAndAdd().getEntry(); + + LedgerProjectionService.materialize(fixture.client); + + assertThat(ledgerBackedPortfolioProjection(fixture.portfolio, LedgerProjectionRole.DELIVERY_OUTBOUND, + "source-1").getType(), is(PortfolioTransaction.Type.DELIVERY_OUTBOUND)); + assertThat(ledgerBackedPortfolioProjection(fixture.portfolio, LedgerProjectionRole.NEW_SECURITY_LEG, + "target-1").getType(), is(PortfolioTransaction.Type.DELIVERY_INBOUND)); + assertThat(ledgerBackedAccountProjection(fixture.account, LedgerProjectionRole.ACCOUNT, "cash-1").getType(), + is(AccountTransaction.Type.DEPOSIT)); + assertThat(LedgerProjectionSupport.descriptors(entry).size(), is(3)); + assertFalse(hasPrimaryDescriptor(entry, CorporateActionLeg.PRINCIPAL)); + assertFalse(hasPrimaryDescriptor(entry, CorporateActionLeg.ACCRUED_INTEREST)); + assertThat(LedgerCorporateActionEditSupport.postingBySemanticKey(entry, + LedgerLegRole.PRINCIPAL_REDEMPTION_LEG, "principal-1", "main").getAmount(), + is(money(9).getAmount())); + } + + @Test + public void testDefaultMaterializesBookedMovementAndRejectsContextOnly() + { + var fixture = fixture(); + + LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.DEFAULT) // + .date(DATE) // + .securityContext("context-1", "main", fixture.portfolio, fixture.sourceSecurity) // + .cash("cash-1", "cash-1", fixture.account, money(5)) // + .buildAndAdd(); + + LedgerProjectionService.materialize(fixture.client); + + assertThat(ledgerBackedAccountProjection(fixture.account, LedgerProjectionRole.ACCOUNT, "cash-1").getType(), + is(AccountTransaction.Type.DEPOSIT)); + + var exception = assertThrows(LedgerNativeEntryAssemblyException.class, + () -> LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.DEFAULT) // + .date(DATE) // + .securityContext("context-only", "main", fixture.portfolio, + fixture.sourceSecurity) // + .buildDetached()); + + assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.NATIVE_DEFINITION_VALIDATION_FAILED)); + } + + @Test + public void testDefaultMaterializesRepeatedCashAndSecurityMovements() + { + var fixture = fixture(); + var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.DEFAULT) // + .date(DATE) // + .securityContext("context-1", "main", fixture.portfolio, fixture.sourceSecurity) // + .securityIn("claim-1", "claim-1", fixture.portfolio, fixture.claimSecurity, shares(3)) // + .securityIn("claim-2", "claim-2", fixture.secondPortfolio, fixture.secondClaimSecurity, + shares(4)) // + .cash("cash-1", "cash-1", fixture.account, money(5)) // + .cash("cash-2", "cash-2", fixture.secondAccount, money(7)) // + .buildAndAdd().getEntry(); + + var descriptors = LedgerProjectionSupport.descriptors(entry); + var securityDescriptors = descriptors.stream() + .filter(descriptor -> descriptor.getRole() == LedgerProjectionRole.NEW_SECURITY_LEG).toList(); + var accountDescriptors = descriptors.stream() + .filter(descriptor -> descriptor.getRole() == LedgerProjectionRole.ACCOUNT).toList(); + + assertThat(securityDescriptors.size(), is(2)); + assertThat(accountDescriptors.size(), is(2)); + assertThat(semanticKeys(securityDescriptors), is(Set.of("claim-1", "claim-2"))); + assertThat(semanticKeys(accountDescriptors), is(Set.of("cash-1", "cash-2"))); + assertThrows(IllegalArgumentException.class, + () -> new LedgerProjectionFactory().createProjection(entry, LedgerProjectionRole.NEW_SECURITY_LEG)); + assertThrows(IllegalArgumentException.class, + () -> new LedgerProjectionFactory().createProjection(entry, LedgerProjectionRole.ACCOUNT)); + + LedgerProjectionService.materialize(fixture.client); + LedgerProjectionService.materialize(fixture.client); + + var firstSecurity = ledgerBackedPortfolioProjection(fixture.portfolio, LedgerProjectionRole.NEW_SECURITY_LEG, + "claim-1"); + var secondSecurity = ledgerBackedPortfolioProjection(fixture.secondPortfolio, + LedgerProjectionRole.NEW_SECURITY_LEG, "claim-2"); + var firstAccount = ledgerBackedAccountProjection(fixture.account, LedgerProjectionRole.ACCOUNT, "cash-1"); + var secondAccount = ledgerBackedAccountProjection(fixture.secondAccount, LedgerProjectionRole.ACCOUNT, + "cash-2"); + + assertThat(firstSecurity.getShares(), is(shares(3))); + assertThat(secondSecurity.getShares(), is(shares(4))); + assertThat(firstAccount.getAmount(), is(money(5).getAmount())); + assertThat(secondAccount.getAmount(), is(money(7).getAmount())); + assertThat(runtimeProjectionIds(List.of(firstSecurity, secondSecurity)), + is(securityDescriptors.stream().map(DerivedProjectionDescriptor::getRuntimeProjectionId) + .collect(Collectors.toSet()))); + assertThat(runtimeProjectionIds(List.of(firstAccount, secondAccount)), + is(accountDescriptors.stream().map(DerivedProjectionDescriptor::getRuntimeProjectionId) + .collect(Collectors.toSet()))); + } + + @Test + public void testDeletingOpenMovementActionRemovesProjections() + { + var fixture = fixture(); + var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.RESTRUCTURING) // + .date(DATE) // + .securityContext("context-1", "main", fixture.portfolio, fixture.sourceSecurity) // + .securityOut("source-1", "main", fixture.portfolio, fixture.sourceSecurity, shares(10)) // + .securityIn("target-1", "main", fixture.portfolio, fixture.targetSecurity, shares(5)) // + .cash("cash-1", "main", fixture.account, money(9)) // + .buildAndAdd().getEntry(); + + LedgerProjectionService.materialize(fixture.client); + LedgerProjectionService.materialize(fixture.client); + + assertThat(fixture.portfolio.getTransactions().size(), is(2)); + assertThat(fixture.account.getTransactions().size(), is(1)); + + new LedgerMutationContext(fixture.client).removeEntry(entry); + + assertTrue(fixture.portfolio.getTransactions().isEmpty()); + assertTrue(fixture.account.getTransactions().isEmpty()); + } + + private boolean hasPrimaryDescriptor(name.abuchen.portfolio.model.ledger.LedgerEntry entry, CorporateActionLeg leg) + { + return LedgerProjectionSupport.descriptors(entry).stream().map(DerivedProjectionDescriptor::getPrimaryPosting) + .anyMatch(posting -> posting.getCorporateActionLeg() == leg); + } + + private LedgerBackedPortfolioTransaction ledgerBackedPortfolioProjection(Portfolio portfolio, + LedgerProjectionRole role, String semanticInstanceKey) + { + return portfolio.getTransactions().stream() // + .filter(LedgerBackedPortfolioTransaction.class::isInstance) // + .map(LedgerBackedPortfolioTransaction.class::cast) // + .filter(transaction -> transaction.getLedgerProjectionRole() == role) // + .filter(transaction -> transaction.getLedgerProjectionDescriptor().getSemanticInstanceKey() + .filter(semanticInstanceKey::equals).isPresent()) // + .findFirst().orElseThrow(); + } + + private LedgerBackedAccountTransaction ledgerBackedAccountProjection(Account account, LedgerProjectionRole role, + String semanticInstanceKey) + { + return account.getTransactions().stream() // + .filter(LedgerBackedAccountTransaction.class::isInstance) // + .map(LedgerBackedAccountTransaction.class::cast) // + .filter(transaction -> transaction.getLedgerProjectionRole() == role) // + .filter(transaction -> transaction.getLedgerProjectionDescriptor().getSemanticInstanceKey() + .filter(semanticInstanceKey::equals).isPresent()) // + .findFirst().orElseThrow(); + } + + private Set semanticKeys(List descriptors) + { + return descriptors.stream().map(descriptor -> descriptor.getSemanticInstanceKey().orElseThrow()) + .collect(Collectors.toSet()); + } + + private Set runtimeProjectionIds(List transactions) + { + return transactions.stream().map(LedgerBackedTransaction::getRuntimeProjectionId).collect(Collectors.toSet()); + } + + private static long shares(long shares) + { + return Values.Share.factorize(shares); + } + + private static Money money(long amount) + { + return Money.of(CurrencyUnit.EUR, Values.Amount.factorize(amount)); + } + + private static Fixture fixture() + { + var client = new Client(); + var account = new Account(); + var secondAccount = new Account(); + var portfolio = new Portfolio(); + var secondPortfolio = new Portfolio(); + var sourceSecurity = new Security("Source AG", CurrencyUnit.EUR); + var targetSecurity = new Security("Target AG", CurrencyUnit.EUR); + var claimSecurity = new Security("Claim AG", CurrencyUnit.EUR); + var secondClaimSecurity = new Security("Second Claim AG", CurrencyUnit.EUR); + + account.setName("Cash"); + account.setCurrencyCode(CurrencyUnit.EUR); + secondAccount.setName("Second Cash"); + secondAccount.setCurrencyCode(CurrencyUnit.EUR); + portfolio.setName("Portfolio"); + secondPortfolio.setName("Second Portfolio"); + + client.addAccount(account); + client.addAccount(secondAccount); + client.addPortfolio(portfolio); + client.addPortfolio(secondPortfolio); + client.addSecurity(sourceSecurity); + client.addSecurity(targetSecurity); + client.addSecurity(claimSecurity); + client.addSecurity(secondClaimSecurity); + + return new Fixture(client, account, secondAccount, portfolio, secondPortfolio, sourceSecurity, targetSecurity, + claimSecurity, secondClaimSecurity); + } + + private record Fixture(Client client, Account account, Account secondAccount, Portfolio portfolio, + Portfolio secondPortfolio, Security sourceSecurity, Security targetSecurity, Security claimSecurity, + Security secondClaimSecurity) + { + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java index b530d78abd..c9fea3799a 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java @@ -121,6 +121,12 @@ private void corporateAction(LedgerEntry entry, List k == CorporateActionKind.CASH_DISTRIBUTION || k == CorporateActionKind.COUPON_PAYMENT) .isPresent()) cashOrientedCorporateAction(entry, descriptors, diagnostics); @@ -144,6 +150,15 @@ private boolean isSecurityReorganizationCorporateAction(CorporateActionKind kind }; } + private boolean isOpenMovementCorporateAction(CorporateActionKind kind) + { + return switch (kind) + { + case DEFAULTED_INTEREST, RESTRUCTURING, DEFAULT -> true; + default -> false; + }; + } + private boolean isFixedIncomeRedemptionCorporateAction(CorporateActionKind kind) { return switch (kind) @@ -217,6 +232,17 @@ private void securityReorganizationCorporateAction(LedgerEntry entry, cashCompensationRepeatedSelector(), true, diagnostics).forEach(descriptors::add); } + private void openMovementCorporateAction(LedgerEntry entry, List descriptors, + List diagnostics) + { + repeatedPortfolio(entry, LedgerProjectionRole.DELIVERY_OUTBOUND, sourceSecurityPreferredSelector(), + sourceSecurityRepeatedSelector(), true, diagnostics).forEach(descriptors::add); + repeatedPortfolio(entry, LedgerProjectionRole.NEW_SECURITY_LEG, securityInPreferredSelector(), + securityInRepeatedSelector(), true, diagnostics).forEach(descriptors::add); + repeatedAccount(entry, LedgerProjectionRole.ACCOUNT, openAccountPreferredSelector(), + openAccountRepeatedSelector(), true, diagnostics).forEach(descriptors::add); + } + private Predicate securityInPreferredSelector() { return primary().and(corporateLeg(CorporateActionLeg.TARGET_SECURITY) @@ -264,6 +290,22 @@ private Predicate cashRepeatedSelector() return primary().and(postingType(LedgerPostingType.CASH)).and(semantic(LedgerPostingSemanticRole.CASH)); } + private Predicate openAccountPreferredSelector() + { + return cashPreferredSelector().or(standaloneFeeTaxSelector().and(localKey(LedgerProjectionRole.ACCOUNT))); + } + + private Predicate openAccountRepeatedSelector() + { + return cashRepeatedSelector().or(standaloneFeeTaxSelector()); + } + + private Predicate standaloneFeeTaxSelector() + { + return posting -> (posting.getType() == LedgerPostingType.FEE || posting.getType() == LedgerPostingType.TAX) + && isBlank(posting.getGroupKey()); + } + private java.util.Optional account(LedgerEntry entry, LedgerProjectionRole role, Predicate selector, List diagnostics) { @@ -440,6 +482,9 @@ private List matches(LedgerEntry entry, Predicate private List unitPostings(LedgerEntry entry, LedgerPosting primary) { + if (unitPosting(primary)) + return List.of(); + return entry.getPostings().stream() // .filter(posting -> posting != primary) // .filter(this::unitPosting) // diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionSupport.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionSupport.java index 15a299a988..5dda42d34c 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionSupport.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionSupport.java @@ -19,6 +19,7 @@ import name.abuchen.portfolio.model.ledger.configuration.CorporateActionKind; import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; import name.abuchen.portfolio.money.Money; /** @@ -126,6 +127,12 @@ static AccountTransaction.Type targetedAccountType(DerivedProjectionDescriptor d private static AccountTransaction.Type corporateActionAccountType(DerivedProjectionDescriptor descriptor) { + if (descriptor.getPrimaryPosting().getType() == LedgerPostingType.FEE) + return AccountTransaction.Type.FEES; + + if (descriptor.getPrimaryPosting().getType() == LedgerPostingType.TAX) + return AccountTransaction.Type.TAXES; + var kind = CorporateActionKind.fromEntry(descriptor.getEntry()).orElse(null); if (kind == null) @@ -135,8 +142,8 @@ private static AccountTransaction.Type corporateActionAccountType(DerivedProject return switch (kind) { case CASH_DISTRIBUTION -> AccountTransaction.Type.DIVIDENDS; - case COUPON_PAYMENT -> AccountTransaction.Type.INTEREST; - case MATURITY, PARTIAL_REDEMPTION, CALL, PUT -> AccountTransaction.Type.DEPOSIT; + case COUPON_PAYMENT, DEFAULTED_INTEREST -> AccountTransaction.Type.INTEREST; + case MATURITY, PARTIAL_REDEMPTION, CALL, PUT, RESTRUCTURING, DEFAULT -> AccountTransaction.Type.DEPOSIT; default -> throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PROJ_072 .message("Unsupported targeted account kind " + kind)); //$NON-NLS-1$ }; From f0d35e4dbd7624530c1bfd81a2f945b3b6c70b8d Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Sun, 5 Jul 2026 19:23:44 +0200 Subject: [PATCH 63/68] Add Ledger corporate action fluent end-to-end tests Adds an isolated fluent end-to-end test suite covering every registered Ledger corporate-action kind through create, validate, materialize, semantic edit, XML/protobuf roundtrip, and delete. This gives contributors readable usage examples and verifies the final service/API/projection readiness path without depending on UI/import/reporting layers. Existing tests, UI/importers/reports/tax/FIFO/performance, correction/reversal, legacy transaction types, and XML/protobuf schema are intentionally unchanged. --- ...dgerCorporateActionFluentEndToEndTest.java | 689 ++++++++++++++++++ .../LedgerCorporateActionBuilder.java | 1 + 2 files changed, 690 insertions(+) create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionFluentEndToEndTest.java diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionFluentEndToEndTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionFluentEndToEndTest.java new file mode 100644 index 0000000000..d59d2c38b9 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionFluentEndToEndTest.java @@ -0,0 +1,689 @@ +package name.abuchen.portfolio.model.ledger.nativeentry; + +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.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.Objects; + +import org.junit.Test; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.ClientFactory; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.ProtobufTestUtilities; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerMutationContext; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionBasisMethod; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionBasisStatus; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionKind; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; +import name.abuchen.portfolio.model.ledger.configuration.LedgerLegRole; +import name.abuchen.portfolio.model.ledger.configuration.LedgerNativeEntryDefinitionValidator; +import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; +import name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedAccountTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerBackedPortfolioTransaction; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionService; +import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionSupport; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.Values; + +@SuppressWarnings("nls") +public class LedgerCorporateActionFluentEndToEndTest +{ + private static final LocalDateTime DATE = LocalDateTime.of(2026, 1, 2, 0, 0); + private static final Instant UPDATED_AT = Instant.parse("2026-01-02T00:00:00Z"); + + @Test + public void testCashDistributionFluentCreateEditSaveLoadDelete() throws Exception + { + var fixture = fixture(); + var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.CASH_DISTRIBUTION) // + .date(DATE) // + .securityContext("context-1", "cash-1", fixture.portfolio, fixture.source) // + .cash("cash-1", "cash-1", fixture.account, money(10)) // + .cash("cash-2", "cash-2", fixture.secondAccount, money(20)) // + .fee("fee-1", fixture.account, money(2), "cash-1") // + .tax("tax-1", fixture.account, money(1), "cash-1") // + .buildAndAdd().getEntry(); + + exerciseEndToEnd(fixture, entry, LedgerLegRole.CASH_LEG, "cash-2", "cash-2", + posting -> setAmount(posting, 42), + reloaded -> { + assertAmount(reloaded, LedgerLegRole.CASH_LEG, "cash-2", "cash-2", 42); + assertProjection(reloaded, LedgerProjectionRole.ACCOUNT, "cash-1"); + assertProjection(reloaded, LedgerProjectionRole.ACCOUNT, "cash-2"); + }); + } + + @Test + public void testStockDividendFluentCreateEditSaveLoadDelete() throws Exception + { + var fixture = fixture(); + var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.STOCK_DIVIDEND) // + .date(DATE) // + .securityContext("context-1", "main", fixture.portfolio, fixture.source) // + .securityIn("target-1", "main", fixture.portfolio, fixture.target, shares(5)) // + .securityIn("target-2", "main", fixture.secondPortfolio, fixture.secondTarget, shares(3)) // + .cash("cash-1", "cash-1", fixture.account, money(1)) // + .basis(CorporateActionBasisStatus.UNKNOWN) // + .buildAndAdd().getEntry(); + + exerciseEndToEnd(fixture, entry, LedgerLegRole.TARGET_SECURITY_LEG, "target-2", "main", + posting -> posting.setShares(shares(9)), + reloaded -> { + assertShares(reloaded, LedgerLegRole.TARGET_SECURITY_LEG, "target-2", "main", 9); + assertProjection(reloaded, LedgerProjectionRole.NEW_SECURITY_LEG, "target-1"); + assertProjection(reloaded, LedgerProjectionRole.NEW_SECURITY_LEG, "target-2"); + assertProjection(reloaded, LedgerProjectionRole.CASH_COMPENSATION, "cash-1"); + }); + } + + @Test + public void testSpinOffFluentCreateEditSaveLoadDelete() throws Exception + { + var fixture = fixture(); + var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.SPIN_OFF) // + .date(DATE) // + .effectiveDate(DATE.toLocalDate()) // + .securityContext("context-1", "main", fixture.portfolio, fixture.source) // + .securityOut("source-1", "main", fixture.portfolio, fixture.source, shares(10)) // + .securityIn("target-1", "main", fixture.portfolio, fixture.target, shares(4)) // + .securityIn("target-2", "main", fixture.secondPortfolio, fixture.secondTarget, shares(2)) // + .cash("cash-1", "cash-1", fixture.account, money(3)) // + .basis(CorporateActionBasisStatus.PROVIDED) // + .basisMethod(CorporateActionBasisMethod.PERCENTAGE_ALLOCATION) // + .basisPercentageAllocation(LedgerLegRole.SECURITY_CONTEXT_LEG, "context-1", "main", + new BigDecimal("70")) // + .basisPercentageAllocation(LedgerLegRole.TARGET_SECURITY_LEG, "target-1", "main", + new BigDecimal("20")) // + .basisPercentageAllocation(LedgerLegRole.TARGET_SECURITY_LEG, "target-2", "main", + new BigDecimal("10")) // + .buildAndAdd().getEntry(); + + exerciseEndToEnd(fixture, entry, LedgerLegRole.TARGET_SECURITY_LEG, "target-2", "main", + posting -> posting.setShares(shares(8)), + reloaded -> { + assertShares(reloaded, LedgerLegRole.TARGET_SECURITY_LEG, "target-2", "main", 8); + assertBasisAllocationCount(reloaded, 3); + assertProjection(reloaded, LedgerProjectionRole.OLD_SECURITY_LEG, "source-1"); + assertProjection(reloaded, LedgerProjectionRole.NEW_SECURITY_LEG, "target-1"); + assertProjection(reloaded, LedgerProjectionRole.NEW_SECURITY_LEG, "target-2"); + assertProjection(reloaded, LedgerProjectionRole.CASH_COMPENSATION, "cash-1"); + }); + } + + @Test + public void testBonusIssueFluentCreateEditSaveLoadDelete() throws Exception + { + var fixture = fixture(); + var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.BONUS_ISSUE) // + .date(DATE) // + .securityContext("context-1", "main", fixture.portfolio, fixture.source) // + .securityIn("target-1", "main", fixture.portfolio, fixture.target, shares(6)) // + .cash("cash-1", "cash-1", fixture.account, money(2)) // + .fee("fee-1", fixture.account, money(1), "cash-1") // + .tax("tax-1", fixture.account, money(1), "cash-1") // + .buildAndAdd().getEntry(); + + exerciseEndToEnd(fixture, entry, LedgerLegRole.TARGET_SECURITY_LEG, "target-1", "main", + posting -> posting.setShares(shares(7)), + reloaded -> { + assertShares(reloaded, LedgerLegRole.TARGET_SECURITY_LEG, "target-1", "main", 7); + assertProjection(reloaded, LedgerProjectionRole.NEW_SECURITY_LEG, "target-1"); + assertProjection(reloaded, LedgerProjectionRole.CASH_COMPENSATION, "cash-1"); + }); + } + + @Test + public void testRightsDistributionFluentCreateEditSaveLoadDelete() throws Exception + { + var fixture = fixture(); + var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.RIGHTS_DISTRIBUTION) // + .date(DATE) // + .securityContext("context-1", "main", fixture.portfolio, fixture.source) // + .rightIn("right-1", "main", fixture.portfolio, fixture.right, shares(11)) // + .cash("cash-1", "cash-1", fixture.account, money(2)) // + .buildAndAdd().getEntry(); + + exerciseEndToEnd(fixture, entry, LedgerLegRole.DISTRIBUTED_RIGHT_LEG, "right-1", "main", + posting -> posting.setShares(shares(13)), + reloaded -> { + assertShares(reloaded, LedgerLegRole.DISTRIBUTED_RIGHT_LEG, "right-1", "main", 13); + assertProjection(reloaded, LedgerProjectionRole.NEW_SECURITY_LEG, "right-1"); + assertProjection(reloaded, LedgerProjectionRole.CASH_COMPENSATION, "cash-1"); + }); + } + + @Test + public void testCouponPaymentFluentCreateEditSaveLoadDelete() throws Exception + { + var fixture = fixture(); + var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.COUPON_PAYMENT) // + .date(DATE) // + .securityContext("context-1", "coupon-1", fixture.portfolio, fixture.source) // + .cash("cash-1", "coupon-1", fixture.account, money(12)) // + .accruedInterest("interest-1", "coupon-1", fixture.account, money(12)) // + .fee("fee-1", fixture.account, money(2), "coupon-1") // + .tax("tax-1", fixture.account, money(1), "coupon-1") // + .buildAndAdd().getEntry(); + + exerciseEndToEnd(fixture, entry, LedgerLegRole.CASH_LEG, "cash-1", "coupon-1", + posting -> setAmount(posting, 14), + reloaded -> { + assertAmount(reloaded, LedgerLegRole.CASH_LEG, "cash-1", "coupon-1", 14); + assertAmount(reloaded, LedgerLegRole.ACCRUED_INTEREST_LEG, "interest-1", "coupon-1", 12); + assertProjection(reloaded, LedgerProjectionRole.ACCOUNT, "cash-1"); + }); + } + + @Test + public void testPikInterestFluentCreateEditSaveLoadDelete() throws Exception + { + var fixture = fixture(); + var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.PIK_INTEREST) // + .date(DATE) // + .securityContext("context-1", "pik-1", fixture.portfolio, fixture.source) // + .securityIn("target-1", "pik-1", fixture.portfolio, fixture.target, shares(5)) // + .accruedInterest("interest-1", "pik-1", fixture.account, money(5)) // + .cash("cash-1", "cash-1", fixture.account, money(1)) // + .basis(CorporateActionBasisStatus.UNKNOWN) // + .buildAndAdd().getEntry(); + + exerciseEndToEnd(fixture, entry, LedgerLegRole.TARGET_SECURITY_LEG, "target-1", "pik-1", + posting -> posting.setShares(shares(6)), + reloaded -> { + assertShares(reloaded, LedgerLegRole.TARGET_SECURITY_LEG, "target-1", "pik-1", 6); + assertAmount(reloaded, LedgerLegRole.ACCRUED_INTEREST_LEG, "interest-1", "pik-1", 5); + assertProjection(reloaded, LedgerProjectionRole.NEW_SECURITY_LEG, "target-1"); + assertProjection(reloaded, LedgerProjectionRole.CASH_COMPENSATION, "cash-1"); + }); + } + + @Test + public void testDefaultedInterestFluentCreateEditSaveLoadDelete() throws Exception + { + var fixture = fixture(); + var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.DEFAULTED_INTEREST) // + .date(DATE) // + .securityContext("context-1", "claim-1", fixture.portfolio, fixture.source) // + .securityIn("claim-1", "claim-1", fixture.portfolio, fixture.claim, shares(3)) // + .cash("cash-1", "cash-1", fixture.account, money(4)) // + .buildAndAdd().getEntry(); + + exerciseEndToEnd(fixture, entry, LedgerLegRole.TARGET_SECURITY_LEG, "claim-1", "claim-1", + posting -> posting.setShares(shares(5)), + reloaded -> { + assertShares(reloaded, LedgerLegRole.TARGET_SECURITY_LEG, "claim-1", "claim-1", 5); + assertProjection(reloaded, LedgerProjectionRole.NEW_SECURITY_LEG, "claim-1"); + assertProjection(reloaded, LedgerProjectionRole.ACCOUNT, "cash-1"); + }); + } + + @Test + public void testMaturityFluentCreateEditSaveLoadDelete() throws Exception + { + var fixture = fixture(); + var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.MATURITY) // + .date(DATE) // + .securityContext("context-1", "redemption-1", fixture.portfolio, fixture.bond) // + .securityOut("source-1", "redemption-1", fixture.portfolio, fixture.bond, shares(10)) // + .cash("cash-1", "redemption-1", fixture.account, money(100)) // + .principal("principal-1", "redemption-1", fixture.account, money(100)) // + .accruedInterest("interest-1", "redemption-1", fixture.account, money(5)) // + .buildAndAdd().getEntry(); + + exerciseEndToEnd(fixture, entry, LedgerLegRole.CASH_LEG, "cash-1", "redemption-1", + posting -> setAmount(posting, 105), + reloaded -> { + assertAmount(reloaded, LedgerLegRole.CASH_LEG, "cash-1", "redemption-1", 105); + assertAmount(reloaded, LedgerLegRole.PRINCIPAL_REDEMPTION_LEG, "principal-1", + "redemption-1", 100); + assertProjection(reloaded, LedgerProjectionRole.DELIVERY_OUTBOUND, "source-1"); + assertProjection(reloaded, LedgerProjectionRole.ACCOUNT, "cash-1"); + }); + } + + @Test + public void testPartialRedemptionFluentCreateEditSaveLoadDelete() throws Exception + { + var fixture = fixture(); + var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.PARTIAL_REDEMPTION) // + .date(DATE) // + .securityContext("context-1", "redemption-1", fixture.portfolio, fixture.bond) // + .securityOut("source-1", "redemption-1", fixture.portfolio, fixture.bond, shares(4)) // + .cash("cash-1", "redemption-1", fixture.account, money(40)) // + .principal("principal-1", "redemption-1", fixture.account, money(40)) // + .basis(CorporateActionBasisStatus.UNKNOWN) // + .buildAndAdd().getEntry(); + + exerciseEndToEnd(fixture, entry, LedgerLegRole.PRINCIPAL_REDEMPTION_LEG, "principal-1", "redemption-1", + posting -> setAmount(posting, 45), + reloaded -> { + assertAmount(reloaded, LedgerLegRole.PRINCIPAL_REDEMPTION_LEG, "principal-1", + "redemption-1", 45); + assertProjection(reloaded, LedgerProjectionRole.DELIVERY_OUTBOUND, "source-1"); + assertProjection(reloaded, LedgerProjectionRole.ACCOUNT, "cash-1"); + }); + } + + @Test + public void testCallFluentCreateEditSaveLoadDelete() throws Exception + { + var fixture = fixture(); + var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.CALL) // + .date(DATE) // + .securityContext("context-1", "redemption-1", fixture.portfolio, fixture.bond) // + .securityOut("source-1", "redemption-1", fixture.portfolio, fixture.bond, shares(7)) // + .cash("cash-1", "redemption-1", fixture.account, money(70)) // + .principal("principal-1", "redemption-1", fixture.account, money(70)) // + .fee("fee-1", fixture.account, money(1), "redemption-1") // + .buildAndAdd().getEntry(); + + exerciseEndToEnd(fixture, entry, LedgerLegRole.CASH_LEG, "cash-1", "redemption-1", + posting -> setAmount(posting, 71), + reloaded -> { + assertAmount(reloaded, LedgerLegRole.CASH_LEG, "cash-1", "redemption-1", 71); + assertProjection(reloaded, LedgerProjectionRole.DELIVERY_OUTBOUND, "source-1"); + assertProjection(reloaded, LedgerProjectionRole.ACCOUNT, "cash-1"); + }); + } + + @Test + public void testPutFluentCreateEditSaveLoadDelete() throws Exception + { + var fixture = fixture(); + var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.PUT) // + .date(DATE) // + .securityContext("context-1", "redemption-1", fixture.portfolio, fixture.bond) // + .securityOut("source-1", "redemption-1", fixture.portfolio, fixture.bond, shares(8)) // + .cash("cash-1", "redemption-1", fixture.account, money(80)) // + .principal("principal-1", "redemption-1", fixture.account, money(80)) // + .tax("tax-1", fixture.account, money(1), "redemption-1") // + .buildAndAdd().getEntry(); + + exerciseEndToEnd(fixture, entry, LedgerLegRole.SOURCE_SECURITY_LEG, "source-1", "redemption-1", + posting -> posting.setShares(shares(6)), + reloaded -> { + assertShares(reloaded, LedgerLegRole.SOURCE_SECURITY_LEG, "source-1", "redemption-1", 6); + assertProjection(reloaded, LedgerProjectionRole.DELIVERY_OUTBOUND, "source-1"); + assertProjection(reloaded, LedgerProjectionRole.ACCOUNT, "cash-1"); + }); + } + + @Test + public void testConversionFluentCreateEditSaveLoadDelete() throws Exception + { + var fixture = fixture(); + var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.CONVERSION) // + .date(DATE) // + .securityContext("context-1", "main", fixture.portfolio, fixture.bond) // + .securityOut("source-1", "main", fixture.portfolio, fixture.bond, shares(10)) // + .securityIn("target-1", "main", fixture.portfolio, fixture.target, shares(5)) // + .cash("cash-1", "cash-1", fixture.account, money(2)) // + .basis(CorporateActionBasisStatus.PROVIDED) // + .basisMethod(CorporateActionBasisMethod.PERCENTAGE_ALLOCATION) // + .basisPercentageAllocation(LedgerLegRole.TARGET_SECURITY_LEG, "target-1", "main", + new BigDecimal("100")) // + .buildAndAdd().getEntry(); + + exerciseEndToEnd(fixture, entry, LedgerLegRole.TARGET_SECURITY_LEG, "target-1", "main", + posting -> posting.setShares(shares(6)), + reloaded -> { + assertShares(reloaded, LedgerLegRole.TARGET_SECURITY_LEG, "target-1", "main", 6); + assertBasisAllocationCount(reloaded, 1); + assertProjection(reloaded, LedgerProjectionRole.DELIVERY_OUTBOUND, "source-1"); + assertProjection(reloaded, LedgerProjectionRole.NEW_SECURITY_LEG, "target-1"); + assertProjection(reloaded, LedgerProjectionRole.CASH_COMPENSATION, "cash-1"); + }); + } + + @Test + public void testExchangeFluentCreateEditSaveLoadDelete() throws Exception + { + var fixture = fixture(); + var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.EXCHANGE) // + .date(DATE) // + .securityContext("context-1", "main", fixture.portfolio, fixture.bond) // + .securityOut("source-1", "main", fixture.portfolio, fixture.bond, shares(10)) // + .securityIn("target-1", "main", fixture.portfolio, fixture.target, shares(6)) // + .securityIn("target-2", "main", fixture.secondPortfolio, fixture.secondTarget, shares(4)) // + .cash("cash-1", "cash-1", fixture.account, money(3)) // + .basis(CorporateActionBasisStatus.PROVIDED) // + .basisMethod(CorporateActionBasisMethod.PERCENTAGE_ALLOCATION) // + .basisPercentageAllocation(LedgerLegRole.TARGET_SECURITY_LEG, "target-1", "main", + new BigDecimal("60")) // + .basisPercentageAllocation(LedgerLegRole.TARGET_SECURITY_LEG, "target-2", "main", + new BigDecimal("40")) // + .buildAndAdd().getEntry(); + + exerciseEndToEnd(fixture, entry, LedgerLegRole.TARGET_SECURITY_LEG, "target-2", "main", + posting -> posting.setShares(shares(9)), + reloaded -> { + assertShares(reloaded, LedgerLegRole.TARGET_SECURITY_LEG, "target-2", "main", 9); + assertBasisAllocationCount(reloaded, 2); + assertProjection(reloaded, LedgerProjectionRole.DELIVERY_OUTBOUND, "source-1"); + assertProjection(reloaded, LedgerProjectionRole.NEW_SECURITY_LEG, "target-1"); + assertProjection(reloaded, LedgerProjectionRole.NEW_SECURITY_LEG, "target-2"); + assertProjection(reloaded, LedgerProjectionRole.CASH_COMPENSATION, "cash-1"); + }); + } + + @Test + public void testRestructuringFluentCreateEditSaveLoadDelete() throws Exception + { + var fixture = fixture(); + var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.RESTRUCTURING) // + .date(DATE) // + .securityContext("context-1", "main", fixture.portfolio, fixture.source) // + .securityOut("source-1", "main", fixture.portfolio, fixture.source, shares(10)) // + .securityIn("target-1", "main", fixture.portfolio, fixture.target, shares(5)) // + .cash("cash-1", "main", fixture.account, money(9)) // + .principal("principal-1", "main", fixture.account, money(9)) // + .accruedInterest("interest-1", "main", fixture.account, money(1)) // + .basis(CorporateActionBasisStatus.UNKNOWN) // + .buildAndAdd().getEntry(); + + exerciseEndToEnd(fixture, entry, LedgerLegRole.CASH_LEG, "cash-1", "main", + posting -> setAmount(posting, 10), + reloaded -> { + assertAmount(reloaded, LedgerLegRole.CASH_LEG, "cash-1", "main", 10); + assertAmount(reloaded, LedgerLegRole.PRINCIPAL_REDEMPTION_LEG, "principal-1", "main", 9); + assertAmount(reloaded, LedgerLegRole.ACCRUED_INTEREST_LEG, "interest-1", "main", 1); + assertProjection(reloaded, LedgerProjectionRole.DELIVERY_OUTBOUND, "source-1"); + assertProjection(reloaded, LedgerProjectionRole.NEW_SECURITY_LEG, "target-1"); + assertProjection(reloaded, LedgerProjectionRole.ACCOUNT, "cash-1"); + }); + } + + @Test + public void testDefaultFluentCreateEditSaveLoadDelete() throws Exception + { + var fixture = fixture(); + var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.DEFAULT) // + .date(DATE) // + .securityContext("context-1", "claim-1", fixture.portfolio, fixture.source) // + .securityIn("claim-1", "claim-1", fixture.portfolio, fixture.claim, shares(3)) // + .cash("cash-1", "cash-1", fixture.account, money(5)) // + .buildAndAdd().getEntry(); + + exerciseEndToEnd(fixture, entry, LedgerLegRole.CASH_LEG, "cash-1", "cash-1", + posting -> setAmount(posting, 6), + reloaded -> { + assertAmount(reloaded, LedgerLegRole.CASH_LEG, "cash-1", "cash-1", 6); + assertProjection(reloaded, LedgerProjectionRole.NEW_SECURITY_LEG, "claim-1"); + assertProjection(reloaded, LedgerProjectionRole.ACCOUNT, "cash-1"); + }); + } + + private void exerciseEndToEnd(Fixture fixture, LedgerEntry entry, LedgerLegRole editRole, String editLocalKey, + String editGroupKey, PostingMutation edit, EntryAssertion assertion) throws Exception + { + assertValid(fixture.client); + assertNativeDefinitionValid(entry); + + LedgerProjectionService.materialize(fixture.client); + LedgerProjectionService.materialize(fixture.client); + assertProjectingMovementsAreMaterialized(entry); + assertNonProjectingFacts(entry); + + LedgerCorporateActionEditSupport.mutatePosting(fixture.client, entry, editRole, editLocalKey, editGroupKey, + edit::mutate); + var liveEntry = entryByUUID(fixture.client, entry.getUUID()); + + assertion.assertEntry(liveEntry); + assertValid(fixture.client); + assertNativeDefinitionValid(liveEntry); + assertProjectingMovementsAreMaterialized(liveEntry); + assertNonProjectingFacts(liveEntry); + + var xml = saveXml(fixture.client); + assertNoUuidSelectorFields(xml); + + var loadedFromXml = loadXml(xml); + LedgerProjectionService.materialize(loadedFromXml); + var xmlEntry = onlyLedgerEntry(loadedFromXml); + assertion.assertEntry(xmlEntry); + assertValid(loadedFromXml); + assertNativeDefinitionValid(xmlEntry); + assertProjectingMovementsAreMaterialized(xmlEntry); + assertNonProjectingFacts(xmlEntry); + + var loadedFromProtobuf = ProtobufTestUtilities.load(ProtobufTestUtilities.save(fixture.client)); + LedgerProjectionService.materialize(loadedFromProtobuf); + var protobufEntry = onlyLedgerEntry(loadedFromProtobuf); + assertion.assertEntry(protobufEntry); + assertValid(loadedFromProtobuf); + assertNativeDefinitionValid(protobufEntry); + assertProjectingMovementsAreMaterialized(protobufEntry); + assertNonProjectingFacts(protobufEntry); + + var securityCount = fixture.client.getSecurities().size(); + new LedgerMutationContext(fixture.client).removeEntry(liveEntry); + + assertThat(fixture.client.getLedger().getEntries().size(), is(0)); + assertThat(ledgerBackedProjectionCount(fixture.client), is(0L)); + assertThat(fixture.client.getSecurities().size(), is(securityCount)); + } + + private void assertProjectingMovementsAreMaterialized(LedgerEntry entry) + { + var descriptors = LedgerProjectionSupport.descriptors(entry); + + assertFalse(descriptors.isEmpty()); + assertTrue(descriptors.stream().allMatch(descriptor -> descriptor.getSemanticInstanceKey().isPresent())); + for (var descriptor : descriptors) + assertProjection(entry, descriptor.getRole(), descriptor.getSemanticInstanceKey().orElseThrow()); + } + + private void assertNonProjectingFacts(LedgerEntry entry) + { + assertFalse(LedgerProjectionSupport.descriptors(entry).stream() + .map(DerivedProjectionDescriptor::getPrimaryPosting) + .anyMatch(posting -> posting.getCorporateActionLeg() == CorporateActionLeg.SECURITY_CONTEXT)); + assertFalse(LedgerProjectionSupport.descriptors(entry).stream() + .map(DerivedProjectionDescriptor::getPrimaryPosting) + .anyMatch(posting -> posting.getType().name().contains("BASIS"))); + } + + private static void assertProjection(LedgerEntry entry, LedgerProjectionRole role, String semanticInstanceKey) + { + assertThat(LedgerProjectionSupport.descriptor(entry, role, semanticInstanceKey).getRole(), is(role)); + } + + private static void assertAmount(LedgerEntry entry, LedgerLegRole role, String localKey, String groupKey, + long amount) + { + assertThat(posting(entry, role, localKey, groupKey).getAmount(), is(money(amount).getAmount())); + } + + private static void assertShares(LedgerEntry entry, LedgerLegRole role, String localKey, String groupKey, + long shares) + { + assertThat(posting(entry, role, localKey, groupKey).getShares(), is(shares(shares))); + } + + private static void assertBasisAllocationCount(LedgerEntry entry, int expected) + { + assertThat(entry.getParameters().stream() + .filter(parameter -> parameter.getType() == LedgerParameterType.CORPORATE_ACTION_BASIS_ALLOCATION) + .count(), is((long) expected)); + } + + private static LedgerPosting posting(LedgerEntry entry, LedgerLegRole role, String localKey, String groupKey) + { + return LedgerCorporateActionEditSupport.postingBySemanticKey(entry, role, localKey, groupKey); + } + + private static void setAmount(LedgerPosting posting, long amount) + { + posting.setAmount(money(amount).getAmount()); + posting.setCurrency(CurrencyUnit.EUR); + } + + private static void assertValid(Client client) + { + var result = LedgerStructuralValidator.validate(client.getLedger()); + assertTrue(result.toString(), result.isOK()); + } + + private static void assertNativeDefinitionValid(LedgerEntry entry) + { + var result = LedgerNativeEntryDefinitionValidator.validate(entry); + assertTrue(result.format(), result.isOK()); + } + + private static LedgerEntry onlyLedgerEntry(Client client) + { + assertThat(client.getLedger().getEntries().size(), is(1)); + return client.getLedger().getEntries().get(0); + } + + private static LedgerEntry entryByUUID(Client client, String uuid) + { + return client.getLedger().getEntries().stream().filter(entry -> Objects.equals(entry.getUUID(), uuid)) + .findFirst().orElseThrow(); + } + + private static long ledgerBackedProjectionCount(Client client) + { + return client.getAccounts().stream().flatMap(account -> account.getTransactions().stream()) + .filter(LedgerBackedAccountTransaction.class::isInstance).count() + + client.getPortfolios().stream().flatMap(portfolio -> portfolio.getTransactions().stream()) + .filter(LedgerBackedPortfolioTransaction.class::isInstance).count(); + } + + private static void assertNoUuidSelectorFields(String xml) + { + assertFalse(xml.contains("ProjectionMembership")); + assertFalse(xml.contains("primaryPostingUUID")); + assertFalse(xml.contains("postingGroupUUID")); + } + + private static String saveXml(Client client) throws IOException + { + var file = File.createTempFile("ledger-corporate-action-fluent-e2e", ".xml"); + + try + { + ClientFactory.save(client, file); + return Files.readString(file.toPath(), StandardCharsets.UTF_8); + } + finally + { + Files.deleteIfExists(file.toPath()); + } + } + + private static Client loadXml(String xml) throws IOException + { + return ClientFactory.load(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); + } + + private static long shares(long shares) + { + return Values.Share.factorize(shares); + } + + private static Money money(long amount) + { + return Money.of(CurrencyUnit.EUR, Values.Amount.factorize(amount)); + } + + private static Fixture fixture() + { + var client = new Client(); + var account = new Account(); + var secondAccount = new Account(); + var portfolio = new Portfolio(); + var secondPortfolio = new Portfolio(); + var source = new Security("Source AG", CurrencyUnit.EUR); + var target = new Security("Target AG", CurrencyUnit.EUR); + var secondTarget = new Security("Second Target AG", CurrencyUnit.EUR); + var right = new Security("Right AG", CurrencyUnit.EUR); + var bond = new Security("Bond AG", CurrencyUnit.EUR); + var claim = new Security("Claim AG", CurrencyUnit.EUR); + + account.setName("Cash Account"); + account.setCurrencyCode(CurrencyUnit.EUR); + secondAccount.setName("Second Cash Account"); + secondAccount.setCurrencyCode(CurrencyUnit.EUR); + portfolio.setName("Portfolio"); + portfolio.setReferenceAccount(account); + secondPortfolio.setName("Second Portfolio"); + secondPortfolio.setReferenceAccount(secondAccount); + + account.setUpdatedAt(UPDATED_AT); + secondAccount.setUpdatedAt(UPDATED_AT); + portfolio.setUpdatedAt(UPDATED_AT); + secondPortfolio.setUpdatedAt(UPDATED_AT); + source.setUpdatedAt(UPDATED_AT); + target.setUpdatedAt(UPDATED_AT); + secondTarget.setUpdatedAt(UPDATED_AT); + right.setUpdatedAt(UPDATED_AT); + bond.setUpdatedAt(UPDATED_AT); + claim.setUpdatedAt(UPDATED_AT); + + client.addAccount(account); + client.addAccount(secondAccount); + client.addPortfolio(portfolio); + client.addPortfolio(secondPortfolio); + client.addSecurity(source); + client.addSecurity(target); + client.addSecurity(secondTarget); + client.addSecurity(right); + client.addSecurity(bond); + client.addSecurity(claim); + + return new Fixture(client, account, secondAccount, portfolio, secondPortfolio, source, target, secondTarget, + right, bond, claim); + } + + private interface PostingMutation + { + void mutate(LedgerPosting posting); + } + + private interface EntryAssertion + { + void assertEntry(LedgerEntry entry); + } + + private record Fixture(Client client, Account account, Account secondAccount, Portfolio portfolio, + Portfolio secondPortfolio, Security source, Security target, Security secondTarget, Security right, + Security bond, Security claim) + { + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionBuilder.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionBuilder.java index 807cba5a11..14484f64e4 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionBuilder.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionBuilder.java @@ -351,6 +351,7 @@ private LedgerPosting rightPosting(String localKey, String groupKey, Portfolio p posting.setSecurity(security); posting.setShares(shares); posting.setAmount(0L); + posting.setCurrency(client.getBaseCurrency()); posting.addParameter(LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_LEG, CorporateActionLeg.RIGHT_SECURITY.getCode())); posting.addParameter(LedgerParameter.ofSecurity(LedgerParameterType.RIGHT_SECURITY, security)); From 0da68f969eb6dc9676156ad9b597389a9bb171e0 Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Sun, 5 Jul 2026 21:29:47 +0200 Subject: [PATCH 64/68] Add Ledger corporate action leg query handles Adds a contributor-facing Corporate Action view, query, and leg handle API over semantic role, localKey, and groupKey addresses. This makes native corporate-action entries easier to query and edit by business attributes while preserving the existing no-UUID semantic truth model. Persistence, projections, legacy transaction types, UI, importers, reports, tax, FIFO, performance, automatic basis calculation, and correction/reversal behavior are intentionally unchanged. --- ...dgerCorporateActionFluentEndToEndTest.java | 141 ++++++++++++++--- .../LedgerCorporateActionViewTest.java | 147 ++++++++++++++++++ .../LedgerCorporateActionBuilder.java | 7 + .../LedgerCorporateActionEditSupport.java | 57 ++----- .../LedgerCorporateActionLegHandle.java | 86 ++++++++++ .../LedgerCorporateActionLegQuery.java | 139 +++++++++++++++++ .../LedgerCorporateActionLegRoles.java | 95 +++++++++++ .../LedgerCorporateActionView.java | 93 +++++++++++ 8 files changed, 704 insertions(+), 61 deletions(-) create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionViewTest.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionLegHandle.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionLegQuery.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionLegRoles.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionView.java diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionFluentEndToEndTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionFluentEndToEndTest.java index d59d2c38b9..61d13eafd5 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionFluentEndToEndTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionFluentEndToEndTest.java @@ -64,7 +64,10 @@ public void testCashDistributionFluentCreateEditSaveLoadDelete() throws Exceptio .tax("tax-1", fixture.account, money(1), "cash-1") // .buildAndAdd().getEntry(); - exerciseEndToEnd(fixture, entry, LedgerLegRole.CASH_LEG, "cash-2", "cash-2", + exerciseEndToEnd(fixture, entry, + (client, candidate) -> LedgerCorporateActionView.of(candidate).cash() + .withAccount(account(client, "Second Cash Account")).single(), + LedgerLegRole.CASH_LEG, "cash-2", "cash-2", posting -> setAmount(posting, 42), reloaded -> { assertAmount(reloaded, LedgerLegRole.CASH_LEG, "cash-2", "cash-2", 42); @@ -87,7 +90,11 @@ public void testStockDividendFluentCreateEditSaveLoadDelete() throws Exception .basis(CorporateActionBasisStatus.UNKNOWN) // .buildAndAdd().getEntry(); - exerciseEndToEnd(fixture, entry, LedgerLegRole.TARGET_SECURITY_LEG, "target-2", "main", + exerciseEndToEnd(fixture, entry, + (client, candidate) -> LedgerCorporateActionView.of(candidate).securityIn() + .withPortfolio(portfolio(client, "Second Portfolio")) + .withSecurity(security(client, "Second Target AG")).single(), + LedgerLegRole.TARGET_SECURITY_LEG, "target-2", "main", posting -> posting.setShares(shares(9)), reloaded -> { assertShares(reloaded, LedgerLegRole.TARGET_SECURITY_LEG, "target-2", "main", 9); @@ -120,7 +127,11 @@ public void testSpinOffFluentCreateEditSaveLoadDelete() throws Exception new BigDecimal("10")) // .buildAndAdd().getEntry(); - exerciseEndToEnd(fixture, entry, LedgerLegRole.TARGET_SECURITY_LEG, "target-2", "main", + exerciseEndToEnd(fixture, entry, + (client, candidate) -> LedgerCorporateActionView.of(candidate).securityIn() + .withPortfolio(portfolio(client, "Second Portfolio")) + .withSecurity(security(client, "Second Target AG")).single(), + LedgerLegRole.TARGET_SECURITY_LEG, "target-2", "main", posting -> posting.setShares(shares(8)), reloaded -> { assertShares(reloaded, LedgerLegRole.TARGET_SECURITY_LEG, "target-2", "main", 8); @@ -146,7 +157,11 @@ public void testBonusIssueFluentCreateEditSaveLoadDelete() throws Exception .tax("tax-1", fixture.account, money(1), "cash-1") // .buildAndAdd().getEntry(); - exerciseEndToEnd(fixture, entry, LedgerLegRole.TARGET_SECURITY_LEG, "target-1", "main", + exerciseEndToEnd(fixture, entry, + (client, candidate) -> LedgerCorporateActionView.of(candidate).securityIn() + .withPortfolio(portfolio(client, "Portfolio")) + .withSecurity(security(client, "Target AG")).single(), + LedgerLegRole.TARGET_SECURITY_LEG, "target-1", "main", posting -> posting.setShares(shares(7)), reloaded -> { assertShares(reloaded, LedgerLegRole.TARGET_SECURITY_LEG, "target-1", "main", 7); @@ -167,7 +182,11 @@ public void testRightsDistributionFluentCreateEditSaveLoadDelete() throws Except .cash("cash-1", "cash-1", fixture.account, money(2)) // .buildAndAdd().getEntry(); - exerciseEndToEnd(fixture, entry, LedgerLegRole.DISTRIBUTED_RIGHT_LEG, "right-1", "main", + exerciseEndToEnd(fixture, entry, + (client, candidate) -> LedgerCorporateActionView.of(candidate).rightsIn() + .withPortfolio(portfolio(client, "Portfolio")) + .withSecurity(security(client, "Right AG")).single(), + LedgerLegRole.DISTRIBUTED_RIGHT_LEG, "right-1", "main", posting -> posting.setShares(shares(13)), reloaded -> { assertShares(reloaded, LedgerLegRole.DISTRIBUTED_RIGHT_LEG, "right-1", "main", 13); @@ -190,7 +209,11 @@ public void testCouponPaymentFluentCreateEditSaveLoadDelete() throws Exception .tax("tax-1", fixture.account, money(1), "coupon-1") // .buildAndAdd().getEntry(); - exerciseEndToEnd(fixture, entry, LedgerLegRole.CASH_LEG, "cash-1", "coupon-1", + exerciseEndToEnd(fixture, entry, + (client, candidate) -> LedgerCorporateActionView.of(candidate).cash() + .withAccount(account(client, "Cash Account")).withGroupKey("coupon-1") + .single(), + LedgerLegRole.CASH_LEG, "cash-1", "coupon-1", posting -> setAmount(posting, 14), reloaded -> { assertAmount(reloaded, LedgerLegRole.CASH_LEG, "cash-1", "coupon-1", 14); @@ -213,7 +236,11 @@ public void testPikInterestFluentCreateEditSaveLoadDelete() throws Exception .basis(CorporateActionBasisStatus.UNKNOWN) // .buildAndAdd().getEntry(); - exerciseEndToEnd(fixture, entry, LedgerLegRole.TARGET_SECURITY_LEG, "target-1", "pik-1", + exerciseEndToEnd(fixture, entry, + (client, candidate) -> LedgerCorporateActionView.of(candidate).securityIn() + .withPortfolio(portfolio(client, "Portfolio")) + .withSecurity(security(client, "Target AG")).single(), + LedgerLegRole.TARGET_SECURITY_LEG, "target-1", "pik-1", posting -> posting.setShares(shares(6)), reloaded -> { assertShares(reloaded, LedgerLegRole.TARGET_SECURITY_LEG, "target-1", "pik-1", 6); @@ -235,7 +262,11 @@ public void testDefaultedInterestFluentCreateEditSaveLoadDelete() throws Excepti .cash("cash-1", "cash-1", fixture.account, money(4)) // .buildAndAdd().getEntry(); - exerciseEndToEnd(fixture, entry, LedgerLegRole.TARGET_SECURITY_LEG, "claim-1", "claim-1", + exerciseEndToEnd(fixture, entry, + (client, candidate) -> LedgerCorporateActionView.of(candidate).securityIn() + .withPortfolio(portfolio(client, "Portfolio")) + .withSecurity(security(client, "Claim AG")).single(), + LedgerLegRole.TARGET_SECURITY_LEG, "claim-1", "claim-1", posting -> posting.setShares(shares(5)), reloaded -> { assertShares(reloaded, LedgerLegRole.TARGET_SECURITY_LEG, "claim-1", "claim-1", 5); @@ -258,7 +289,11 @@ public void testMaturityFluentCreateEditSaveLoadDelete() throws Exception .accruedInterest("interest-1", "redemption-1", fixture.account, money(5)) // .buildAndAdd().getEntry(); - exerciseEndToEnd(fixture, entry, LedgerLegRole.CASH_LEG, "cash-1", "redemption-1", + exerciseEndToEnd(fixture, entry, + (client, candidate) -> LedgerCorporateActionView.of(candidate).cash() + .withAccount(account(client, "Cash Account")).withGroupKey("redemption-1") + .single(), + LedgerLegRole.CASH_LEG, "cash-1", "redemption-1", posting -> setAmount(posting, 105), reloaded -> { assertAmount(reloaded, LedgerLegRole.CASH_LEG, "cash-1", "redemption-1", 105); @@ -283,7 +318,11 @@ public void testPartialRedemptionFluentCreateEditSaveLoadDelete() throws Excepti .basis(CorporateActionBasisStatus.UNKNOWN) // .buildAndAdd().getEntry(); - exerciseEndToEnd(fixture, entry, LedgerLegRole.PRINCIPAL_REDEMPTION_LEG, "principal-1", "redemption-1", + exerciseEndToEnd(fixture, entry, + (client, candidate) -> LedgerCorporateActionView.of(candidate).principal() + .withAccount(account(client, "Cash Account")).withGroupKey("redemption-1") + .single(), + LedgerLegRole.PRINCIPAL_REDEMPTION_LEG, "principal-1", "redemption-1", posting -> setAmount(posting, 45), reloaded -> { assertAmount(reloaded, LedgerLegRole.PRINCIPAL_REDEMPTION_LEG, "principal-1", @@ -307,7 +346,11 @@ public void testCallFluentCreateEditSaveLoadDelete() throws Exception .fee("fee-1", fixture.account, money(1), "redemption-1") // .buildAndAdd().getEntry(); - exerciseEndToEnd(fixture, entry, LedgerLegRole.CASH_LEG, "cash-1", "redemption-1", + exerciseEndToEnd(fixture, entry, + (client, candidate) -> LedgerCorporateActionView.of(candidate).cash() + .withAccount(account(client, "Cash Account")).withGroupKey("redemption-1") + .single(), + LedgerLegRole.CASH_LEG, "cash-1", "redemption-1", posting -> setAmount(posting, 71), reloaded -> { assertAmount(reloaded, LedgerLegRole.CASH_LEG, "cash-1", "redemption-1", 71); @@ -330,7 +373,11 @@ public void testPutFluentCreateEditSaveLoadDelete() throws Exception .tax("tax-1", fixture.account, money(1), "redemption-1") // .buildAndAdd().getEntry(); - exerciseEndToEnd(fixture, entry, LedgerLegRole.SOURCE_SECURITY_LEG, "source-1", "redemption-1", + exerciseEndToEnd(fixture, entry, + (client, candidate) -> LedgerCorporateActionView.of(candidate).securityOut() + .withPortfolio(portfolio(client, "Portfolio")) + .withSecurity(security(client, "Bond AG")).single(), + LedgerLegRole.SOURCE_SECURITY_LEG, "source-1", "redemption-1", posting -> posting.setShares(shares(6)), reloaded -> { assertShares(reloaded, LedgerLegRole.SOURCE_SECURITY_LEG, "source-1", "redemption-1", 6); @@ -356,7 +403,11 @@ public void testConversionFluentCreateEditSaveLoadDelete() throws Exception new BigDecimal("100")) // .buildAndAdd().getEntry(); - exerciseEndToEnd(fixture, entry, LedgerLegRole.TARGET_SECURITY_LEG, "target-1", "main", + exerciseEndToEnd(fixture, entry, + (client, candidate) -> LedgerCorporateActionView.of(candidate).securityIn() + .withPortfolio(portfolio(client, "Portfolio")) + .withSecurity(security(client, "Target AG")).single(), + LedgerLegRole.TARGET_SECURITY_LEG, "target-1", "main", posting -> posting.setShares(shares(6)), reloaded -> { assertShares(reloaded, LedgerLegRole.TARGET_SECURITY_LEG, "target-1", "main", 6); @@ -387,7 +438,11 @@ public void testExchangeFluentCreateEditSaveLoadDelete() throws Exception new BigDecimal("40")) // .buildAndAdd().getEntry(); - exerciseEndToEnd(fixture, entry, LedgerLegRole.TARGET_SECURITY_LEG, "target-2", "main", + exerciseEndToEnd(fixture, entry, + (client, candidate) -> LedgerCorporateActionView.of(candidate).securityIn() + .withPortfolio(portfolio(client, "Second Portfolio")) + .withSecurity(security(client, "Second Target AG")).single(), + LedgerLegRole.TARGET_SECURITY_LEG, "target-2", "main", posting -> posting.setShares(shares(9)), reloaded -> { assertShares(reloaded, LedgerLegRole.TARGET_SECURITY_LEG, "target-2", "main", 9); @@ -415,7 +470,10 @@ public void testRestructuringFluentCreateEditSaveLoadDelete() throws Exception .basis(CorporateActionBasisStatus.UNKNOWN) // .buildAndAdd().getEntry(); - exerciseEndToEnd(fixture, entry, LedgerLegRole.CASH_LEG, "cash-1", "main", + exerciseEndToEnd(fixture, entry, + (client, candidate) -> LedgerCorporateActionView.of(candidate).cash() + .withAccount(account(client, "Cash Account")).withGroupKey("main").single(), + LedgerLegRole.CASH_LEG, "cash-1", "main", posting -> setAmount(posting, 10), reloaded -> { assertAmount(reloaded, LedgerLegRole.CASH_LEG, "cash-1", "main", 10); @@ -439,7 +497,10 @@ public void testDefaultFluentCreateEditSaveLoadDelete() throws Exception .cash("cash-1", "cash-1", fixture.account, money(5)) // .buildAndAdd().getEntry(); - exerciseEndToEnd(fixture, entry, LedgerLegRole.CASH_LEG, "cash-1", "cash-1", + exerciseEndToEnd(fixture, entry, + (client, candidate) -> LedgerCorporateActionView.of(candidate).cash() + .withAccount(account(client, "Cash Account")).single(), + LedgerLegRole.CASH_LEG, "cash-1", "cash-1", posting -> setAmount(posting, 6), reloaded -> { assertAmount(reloaded, LedgerLegRole.CASH_LEG, "cash-1", "cash-1", 6); @@ -448,8 +509,9 @@ public void testDefaultFluentCreateEditSaveLoadDelete() throws Exception }); } - private void exerciseEndToEnd(Fixture fixture, LedgerEntry entry, LedgerLegRole editRole, String editLocalKey, - String editGroupKey, PostingMutation edit, EntryAssertion assertion) throws Exception + private void exerciseEndToEnd(Fixture fixture, LedgerEntry entry, HandleLookup handleLookup, + LedgerLegRole expectedRole, String expectedLocalKey, String expectedGroupKey, PostingMutation edit, + EntryAssertion assertion) throws Exception { assertValid(fixture.client); assertNativeDefinitionValid(entry); @@ -459,8 +521,8 @@ private void exerciseEndToEnd(Fixture fixture, LedgerEntry entry, LedgerLegRole assertProjectingMovementsAreMaterialized(entry); assertNonProjectingFacts(entry); - LedgerCorporateActionEditSupport.mutatePosting(fixture.client, entry, editRole, editLocalKey, editGroupKey, - edit::mutate); + var handle = assertHandle(fixture.client, entry, handleLookup, expectedRole, expectedLocalKey, expectedGroupKey); + LedgerCorporateActionEditSupport.mutatePosting(fixture.client, handle, edit::mutate); var liveEntry = entryByUUID(fixture.client, entry.getUUID()); assertion.assertEntry(liveEntry); @@ -475,6 +537,7 @@ private void exerciseEndToEnd(Fixture fixture, LedgerEntry entry, LedgerLegRole var loadedFromXml = loadXml(xml); LedgerProjectionService.materialize(loadedFromXml); var xmlEntry = onlyLedgerEntry(loadedFromXml); + assertHandle(loadedFromXml, xmlEntry, handleLookup, expectedRole, expectedLocalKey, expectedGroupKey); assertion.assertEntry(xmlEntry); assertValid(loadedFromXml); assertNativeDefinitionValid(xmlEntry); @@ -484,6 +547,7 @@ private void exerciseEndToEnd(Fixture fixture, LedgerEntry entry, LedgerLegRole var loadedFromProtobuf = ProtobufTestUtilities.load(ProtobufTestUtilities.save(fixture.client)); LedgerProjectionService.materialize(loadedFromProtobuf); var protobufEntry = onlyLedgerEntry(loadedFromProtobuf); + assertHandle(loadedFromProtobuf, protobufEntry, handleLookup, expectedRole, expectedLocalKey, expectedGroupKey); assertion.assertEntry(protobufEntry); assertValid(loadedFromProtobuf); assertNativeDefinitionValid(protobufEntry); @@ -498,6 +562,20 @@ private void exerciseEndToEnd(Fixture fixture, LedgerEntry entry, LedgerLegRole assertThat(fixture.client.getSecurities().size(), is(securityCount)); } + private static LedgerCorporateActionLegHandle assertHandle(Client client, LedgerEntry entry, + HandleLookup handleLookup, LedgerLegRole expectedRole, String expectedLocalKey, + String expectedGroupKey) + { + var handle = handleLookup.find(client, entry); + assertThat(handle.role(), is(expectedRole)); + assertThat(handle.localKey(), is(expectedLocalKey)); + assertThat(handle.groupKey(), is(expectedGroupKey)); + assertThat(handle.toSemanticKey().role(), is(expectedRole)); + assertThat(handle.toSemanticKey().localKey(), is(expectedLocalKey)); + assertThat(handle.toSemanticKey().groupKey(), is(expectedGroupKey)); + return handle; + } + private void assertProjectingMovementsAreMaterialized(LedgerEntry entry) { var descriptors = LedgerProjectionSupport.descriptors(entry); @@ -671,6 +749,29 @@ private static Fixture fixture() right, bond, claim); } + private static Account account(Client client, String name) + { + return client.getAccounts().stream().filter(account -> name.equals(account.getName())).findFirst() + .orElseThrow(); + } + + private static Portfolio portfolio(Client client, String name) + { + return client.getPortfolios().stream().filter(portfolio -> name.equals(portfolio.getName())).findFirst() + .orElseThrow(); + } + + private static Security security(Client client, String name) + { + return client.getSecurities().stream().filter(security -> name.equals(security.getName())).findFirst() + .orElseThrow(); + } + + private interface HandleLookup + { + LedgerCorporateActionLegHandle find(Client client, LedgerEntry entry); + } + private interface PostingMutation { void mutate(LedgerPosting posting); diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionViewTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionViewTest.java new file mode 100644 index 0000000000..74893f2ea2 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionViewTest.java @@ -0,0 +1,147 @@ +package name.abuchen.portfolio.model.ledger.nativeentry; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; + +import java.time.LocalDateTime; + +import org.junit.Test; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionKind; +import name.abuchen.portfolio.model.ledger.configuration.LedgerLegRole; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.Values; + +@SuppressWarnings("nls") +public class LedgerCorporateActionViewTest +{ + private static final LocalDateTime DATE = LocalDateTime.of(2026, 1, 2, 0, 0); + + @Test + public void testSingleFailsOnNoMatch() + { + var fixture = fixture(); + var entry = stockDividend(fixture); + + assertThrows(IllegalArgumentException.class, + () -> LedgerCorporateActionView.of(entry).cash().withAccount(fixture.secondAccount).single()); + } + + @Test + public void testSingleFailsOnMultipleMatches() + { + var fixture = fixture(); + var entry = stockDividend(fixture); + + assertThrows(IllegalArgumentException.class, () -> LedgerCorporateActionView.of(entry).securityIn().single()); + } + + @Test + public void testPortfolioSecurityFiltersDisambiguateRepeatedTargetLegs() + { + var fixture = fixture(); + var entry = stockDividend(fixture); + + var handle = LedgerCorporateActionView.of(entry).securityIn().withPortfolio(fixture.secondPortfolio) + .withSecurity(fixture.secondTarget).single(); + + assertThat(handle.role(), is(LedgerLegRole.TARGET_SECURITY_LEG)); + assertThat(handle.localKey(), is("target-2")); + assertThat(handle.groupKey(), is("main")); + } + + @Test + public void testAccountFilterDisambiguatesRepeatedCashLegs() + { + var fixture = fixture(); + var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.CASH_DISTRIBUTION) // + .date(DATE) // + .securityContext("context-1", "cash-1", fixture.portfolio, fixture.source) // + .cash("cash-1", "cash-1", fixture.account, money(10)) // + .cash("cash-2", "cash-2", fixture.secondAccount, money(20)) // + .buildAndAdd().getEntry(); + + var handle = LedgerCorporateActionView.of(entry).cash().withAccount(fixture.secondAccount).single(); + + assertThat(handle.role(), is(LedgerLegRole.CASH_LEG)); + assertThat(handle.localKey(), is("cash-2")); + assertThat(handle.groupKey(), is("cash-2")); + } + + @Test + public void testHandleExposesSemanticKeyWithoutUuidSelector() + { + var fixture = fixture(); + var entry = stockDividend(fixture); + + var handle = LedgerCorporateActionView.of(entry).securityIn().withPortfolio(fixture.portfolio) + .withSecurity(fixture.target).single(); + + assertThat(handle.toSemanticKey().role(), is(LedgerLegRole.TARGET_SECURITY_LEG)); + assertThat(handle.toSemanticKey().localKey(), is("target-1")); + assertThat(handle.toSemanticKey().groupKey(), is("main")); + assertFalse(handle.toSemanticKey().localKey().equals(handle.posting().getUUID())); + } + + private static name.abuchen.portfolio.model.ledger.LedgerEntry stockDividend(Fixture fixture) + { + return LedgerNativeEntryAssembler.corporateAction(fixture.client) // + .kind(CorporateActionKind.STOCK_DIVIDEND) // + .date(DATE) // + .securityContext("context-1", "main", fixture.portfolio, fixture.source) // + .securityIn("target-1", "main", fixture.portfolio, fixture.target, Values.Share.factorize(5)) // + .securityIn("target-2", "main", fixture.secondPortfolio, fixture.secondTarget, + Values.Share.factorize(3)) // + .cash("cash-1", "cash-1", fixture.account, money(1)) // + .buildAndAdd().getEntry(); + } + + private static Money money(long amount) + { + return Money.of(CurrencyUnit.EUR, Values.Amount.factorize(amount)); + } + + private static Fixture fixture() + { + var client = new Client(); + var account = new Account(); + var secondAccount = new Account(); + var portfolio = new Portfolio(); + var secondPortfolio = new Portfolio(); + var source = new Security("Source AG", CurrencyUnit.EUR); + var target = new Security("Target AG", CurrencyUnit.EUR); + var secondTarget = new Security("Second Target AG", CurrencyUnit.EUR); + + account.setName("Cash Account"); + account.setCurrencyCode(CurrencyUnit.EUR); + secondAccount.setName("Second Cash Account"); + secondAccount.setCurrencyCode(CurrencyUnit.EUR); + portfolio.setName("Portfolio"); + portfolio.setReferenceAccount(account); + secondPortfolio.setName("Second Portfolio"); + secondPortfolio.setReferenceAccount(secondAccount); + + client.addAccount(account); + client.addAccount(secondAccount); + client.addPortfolio(portfolio); + client.addPortfolio(secondPortfolio); + client.addSecurity(source); + client.addSecurity(target); + client.addSecurity(secondTarget); + + return new Fixture(client, account, secondAccount, portfolio, secondPortfolio, source, target, secondTarget); + } + + private record Fixture(Client client, Account account, Account secondAccount, Portfolio portfolio, + Portfolio secondPortfolio, Security source, Security target, Security secondTarget) + { + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionBuilder.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionBuilder.java index 14484f64e4..0dab750acf 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionBuilder.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionBuilder.java @@ -229,6 +229,13 @@ public LedgerCorporateActionBuilder basisPercentageAllocation(LedgerLegRole targ return this; } + public LedgerCorporateActionBuilder basisPercentageAllocation(LedgerCorporateActionLegHandle target, + BigDecimal percent) + { + var key = Objects.requireNonNull(target).toSemanticKey(); + return basisPercentageAllocation(key.role(), key.localKey(), key.groupKey(), percent); + } + public LedgerNativeEntryBuildResult buildDetached() { var entry = assemble(); diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionEditSupport.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionEditSupport.java index 68df5e3192..f6f6a1049c 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionEditSupport.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionEditSupport.java @@ -9,11 +9,9 @@ import name.abuchen.portfolio.model.ledger.LedgerEntry; import name.abuchen.portfolio.model.ledger.LedgerMutationContext; import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; import name.abuchen.portfolio.model.ledger.configuration.LedgerLegRole; import name.abuchen.portfolio.model.ledger.configuration.LedgerNativeEntryDefinitionValidator; -import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; /** * Semantic edit helpers for native Corporate Action Ledger entries. @@ -69,6 +67,21 @@ public static void mutatePosting(Client client, LedgerEntry entry, LedgerLegRole }); } + public static void mutatePosting(Client client, LedgerCorporateActionLegHandle handle, + Consumer mutation) + { + Objects.requireNonNull(client); + Objects.requireNonNull(handle); + + var liveEntry = client.getLedger().getEntries().stream() + .filter(entry -> Objects.equals(entry.getUUID(), handle.entry().getUUID())).findFirst() + .orElseThrow(() -> new IllegalArgumentException( + "Corporate Action handle does not belong to this client")); //$NON-NLS-1$ + + var key = handle.toSemanticKey(); + mutatePosting(client, liveEntry, key.role(), key.localKey(), key.groupKey(), mutation); + } + private static List matchingPostings(LedgerEntry entry, LedgerLegRole role, String localKey, String groupKey) { @@ -80,47 +93,9 @@ private static List matchingPostings(LedgerEntry entry, LedgerLeg return List.of(); return entry.getPostings().stream() // - .filter(posting -> posting.getType() == postingType(role)) // - .filter(posting -> posting.getCorporateActionLeg() == corporateActionLeg(role)) // + .filter(posting -> LedgerCorporateActionLegRoles.matches(posting, role)) // .filter(posting -> localKey.equals(posting.getLocalKey())) // .filter(posting -> groupKey == null || groupKey.equals(posting.getGroupKey())) // .toList(); } - - private static LedgerPostingType postingType(LedgerLegRole role) - { - return switch (role) - { - case SOURCE_SECURITY_LEG, TARGET_SECURITY_LEG, SECURITY_CONTEXT_LEG, RECEIVED_SECURITY_LEG, - DISTRIBUTED_SECURITY_LEG -> LedgerPostingType.SECURITY; - case DISTRIBUTED_RIGHT_LEG -> LedgerPostingType.RIGHT; - case SOURCE_BOND_LEG -> LedgerPostingType.BOND; - case CASH_LEG -> LedgerPostingType.CASH; - case CASH_COMPENSATION_LEG -> LedgerPostingType.CASH_COMPENSATION; - case ACCRUED_INTEREST_LEG -> LedgerPostingType.ACCRUED_INTEREST; - case PRINCIPAL_REDEMPTION_LEG -> LedgerPostingType.PRINCIPAL_REDEMPTION; - case FEE_LEG -> LedgerPostingType.FEE; - case TAX_LEG -> LedgerPostingType.TAX; - case FOREX_CONTEXT_LEG -> LedgerPostingType.FOREX; - }; - } - - private static CorporateActionLeg corporateActionLeg(LedgerLegRole role) - { - return switch (role) - { - case SOURCE_SECURITY_LEG, SOURCE_BOND_LEG -> CorporateActionLeg.SOURCE_SECURITY; - case TARGET_SECURITY_LEG, RECEIVED_SECURITY_LEG -> CorporateActionLeg.TARGET_SECURITY; - case SECURITY_CONTEXT_LEG -> CorporateActionLeg.SECURITY_CONTEXT; - case DISTRIBUTED_SECURITY_LEG -> CorporateActionLeg.DISTRIBUTED_SECURITY; - case DISTRIBUTED_RIGHT_LEG -> CorporateActionLeg.RIGHT_SECURITY; - case CASH_LEG -> null; - case CASH_COMPENSATION_LEG -> CorporateActionLeg.CASH_COMPENSATION; - case ACCRUED_INTEREST_LEG -> CorporateActionLeg.ACCRUED_INTEREST; - case PRINCIPAL_REDEMPTION_LEG -> CorporateActionLeg.PRINCIPAL; - case FEE_LEG -> CorporateActionLeg.FEE; - case TAX_LEG -> CorporateActionLeg.TAX; - case FOREX_CONTEXT_LEG -> null; - }; - } } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionLegHandle.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionLegHandle.java new file mode 100644 index 0000000000..d8de05c78e --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionLegHandle.java @@ -0,0 +1,86 @@ +package name.abuchen.portfolio.model.ledger.nativeentry; + +import java.util.Objects; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.configuration.LedgerLegRole; + +/** + * In-memory handle for one native Corporate Action leg. + */ +public final class LedgerCorporateActionLegHandle +{ + private final LedgerEntry entry; + private final LedgerPosting posting; + private final LedgerLegRole role; + + LedgerCorporateActionLegHandle(LedgerEntry entry, LedgerPosting posting, LedgerLegRole role) + { + this.entry = Objects.requireNonNull(entry); + this.posting = Objects.requireNonNull(posting); + this.role = Objects.requireNonNull(role); + } + + public LedgerEntry entry() + { + return entry; + } + + public LedgerPosting posting() + { + return posting; + } + + public LedgerLegRole role() + { + return role; + } + + public String localKey() + { + return posting.getLocalKey(); + } + + public String groupKey() + { + return posting.getGroupKey(); + } + + public Portfolio portfolio() + { + return posting.getPortfolio(); + } + + public Security security() + { + return posting.getSecurity(); + } + + public Account account() + { + return posting.getAccount(); + } + + public boolean hasGroupKey() + { + return groupKey() != null && !groupKey().isBlank(); + } + + public SemanticKey toSemanticKey() + { + return new SemanticKey(role, localKey(), groupKey()); + } + + public record SemanticKey(LedgerLegRole role, String localKey, String groupKey) + { + public SemanticKey + { + Objects.requireNonNull(role); + Objects.requireNonNull(localKey); + } + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionLegQuery.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionLegQuery.java new file mode 100644 index 0000000000..6da5a67cf8 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionLegQuery.java @@ -0,0 +1,139 @@ +package name.abuchen.portfolio.model.ledger.nativeentry; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Predicate; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; +import name.abuchen.portfolio.model.ledger.configuration.LedgerLegRole; + +/** + * Business-attribute query over native Corporate Action postings. + */ +public final class LedgerCorporateActionLegQuery +{ + private final LedgerEntry entry; + private final LedgerLegRole role; + private final Predicate predicate; + + LedgerCorporateActionLegQuery(LedgerEntry entry) + { + this(entry, null, posting -> true); + } + + private LedgerCorporateActionLegQuery(LedgerEntry entry, LedgerLegRole role, Predicate predicate) + { + this.entry = Objects.requireNonNull(entry); + this.role = role; + this.predicate = Objects.requireNonNull(predicate); + } + + public LedgerCorporateActionLegQuery withRole(LedgerLegRole role) + { + Objects.requireNonNull(role); + return new LedgerCorporateActionLegQuery(entry, role, + predicate.and(posting -> LedgerCorporateActionLegRoles.matches(posting, role))); + } + + public LedgerCorporateActionLegQuery withLocalKey(String localKey) + { + Objects.requireNonNull(localKey); + return new LedgerCorporateActionLegQuery(entry, role, + predicate.and(posting -> localKey.equals(posting.getLocalKey()))); + } + + public LedgerCorporateActionLegQuery withGroupKey(String groupKey) + { + Objects.requireNonNull(groupKey); + return new LedgerCorporateActionLegQuery(entry, role, + predicate.and(posting -> groupKey.equals(posting.getGroupKey()))); + } + + public LedgerCorporateActionLegQuery withPortfolio(Portfolio portfolio) + { + Objects.requireNonNull(portfolio); + return new LedgerCorporateActionLegQuery(entry, role, + predicate.and(posting -> sameModelObject(portfolio.getUUID(), posting.getPortfolio()))); + } + + public LedgerCorporateActionLegQuery withSecurity(Security security) + { + Objects.requireNonNull(security); + return new LedgerCorporateActionLegQuery(entry, role, + predicate.and(posting -> sameModelObject(security.getUUID(), posting.getSecurity()))); + } + + public LedgerCorporateActionLegQuery withAccount(Account account) + { + Objects.requireNonNull(account); + return new LedgerCorporateActionLegQuery(entry, role, + predicate.and(posting -> sameModelObject(account.getUUID(), posting.getAccount()))); + } + + public LedgerCorporateActionLegQuery withCorporateActionLeg(CorporateActionLeg leg) + { + return new LedgerCorporateActionLegQuery(entry, role, + predicate.and(posting -> posting.getCorporateActionLeg() == leg)); + } + + public List list() + { + return entry.getPostings().stream().filter(predicate) + .map(posting -> new LedgerCorporateActionLegHandle(entry, posting, role(posting))).toList(); + } + + public LedgerCorporateActionLegHandle single() + { + var matches = list(); + + if (matches.size() != 1) + throw new IllegalArgumentException("Corporate Action leg query expected one match but found " //$NON-NLS-1$ + + matches.size()); + + return matches.get(0); + } + + public Optional optional() + { + var matches = list(); + + if (matches.size() > 1) + throw new IllegalArgumentException("Corporate Action leg query is ambiguous: " + matches.size() //$NON-NLS-1$ + + " matches"); //$NON-NLS-1$ + + return matches.stream().findFirst(); + } + + public boolean isEmpty() + { + return list().isEmpty(); + } + + private LedgerLegRole role(LedgerPosting posting) + { + if (role != null) + return role; + + return LedgerCorporateActionLegRoles.roleFor(posting) + .orElseThrow(() -> new IllegalArgumentException("Unsupported Corporate Action leg: " //$NON-NLS-1$ + + posting.getType())); + } + + private static boolean sameModelObject(String uuid, Object actual) + { + if (actual instanceof Account account) + return Objects.equals(uuid, account.getUUID()); + if (actual instanceof Portfolio portfolio) + return Objects.equals(uuid, portfolio.getUUID()); + if (actual instanceof Security security) + return Objects.equals(uuid, security.getUUID()); + + return false; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionLegRoles.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionLegRoles.java new file mode 100644 index 0000000000..be84a4e890 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionLegRoles.java @@ -0,0 +1,95 @@ +package name.abuchen.portfolio.model.ledger.nativeentry; + +import java.util.Optional; + +import name.abuchen.portfolio.model.ledger.LedgerPosting; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; +import name.abuchen.portfolio.model.ledger.configuration.LedgerLegRole; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; + +final class LedgerCorporateActionLegRoles +{ + private LedgerCorporateActionLegRoles() + { + } + + static boolean matches(LedgerPosting posting, LedgerLegRole role) + { + return posting.getType() == postingType(role) && posting.getCorporateActionLeg() == corporateActionLeg(role); + } + + static Optional roleFor(LedgerPosting posting) + { + return switch (posting.getType()) + { + case SECURITY -> switch (posting.getCorporateActionLeg()) + { + case SOURCE_SECURITY -> Optional.of(LedgerLegRole.SOURCE_SECURITY_LEG); + case TARGET_SECURITY -> Optional.of(LedgerLegRole.TARGET_SECURITY_LEG); + case SECURITY_CONTEXT -> Optional.of(LedgerLegRole.SECURITY_CONTEXT_LEG); + case DISTRIBUTED_SECURITY -> Optional.of(LedgerLegRole.DISTRIBUTED_SECURITY_LEG); + default -> Optional.empty(); + }; + case RIGHT -> posting.getCorporateActionLeg() == CorporateActionLeg.RIGHT_SECURITY + ? Optional.of(LedgerLegRole.DISTRIBUTED_RIGHT_LEG) + : Optional.empty(); + case BOND -> posting.getCorporateActionLeg() == CorporateActionLeg.SOURCE_SECURITY + ? Optional.of(LedgerLegRole.SOURCE_BOND_LEG) + : Optional.empty(); + case CASH -> posting.getCorporateActionLeg() == null ? Optional.of(LedgerLegRole.CASH_LEG) + : Optional.empty(); + case CASH_COMPENSATION -> posting.getCorporateActionLeg() == CorporateActionLeg.CASH_COMPENSATION + ? Optional.of(LedgerLegRole.CASH_COMPENSATION_LEG) + : Optional.empty(); + case ACCRUED_INTEREST -> posting.getCorporateActionLeg() == CorporateActionLeg.ACCRUED_INTEREST + ? Optional.of(LedgerLegRole.ACCRUED_INTEREST_LEG) + : Optional.empty(); + case PRINCIPAL_REDEMPTION -> posting.getCorporateActionLeg() == CorporateActionLeg.PRINCIPAL + ? Optional.of(LedgerLegRole.PRINCIPAL_REDEMPTION_LEG) + : Optional.empty(); + case FEE -> posting.getCorporateActionLeg() == CorporateActionLeg.FEE ? Optional.of(LedgerLegRole.FEE_LEG) + : Optional.empty(); + case TAX -> posting.getCorporateActionLeg() == CorporateActionLeg.TAX ? Optional.of(LedgerLegRole.TAX_LEG) + : Optional.empty(); + case FOREX -> Optional.of(LedgerLegRole.FOREX_CONTEXT_LEG); + default -> Optional.empty(); + }; + } + + static LedgerPostingType postingType(LedgerLegRole role) + { + return switch (role) + { + case SOURCE_SECURITY_LEG, TARGET_SECURITY_LEG, SECURITY_CONTEXT_LEG, RECEIVED_SECURITY_LEG, + DISTRIBUTED_SECURITY_LEG -> LedgerPostingType.SECURITY; + case DISTRIBUTED_RIGHT_LEG -> LedgerPostingType.RIGHT; + case SOURCE_BOND_LEG -> LedgerPostingType.BOND; + case CASH_LEG -> LedgerPostingType.CASH; + case CASH_COMPENSATION_LEG -> LedgerPostingType.CASH_COMPENSATION; + case ACCRUED_INTEREST_LEG -> LedgerPostingType.ACCRUED_INTEREST; + case PRINCIPAL_REDEMPTION_LEG -> LedgerPostingType.PRINCIPAL_REDEMPTION; + case FEE_LEG -> LedgerPostingType.FEE; + case TAX_LEG -> LedgerPostingType.TAX; + case FOREX_CONTEXT_LEG -> LedgerPostingType.FOREX; + }; + } + + static CorporateActionLeg corporateActionLeg(LedgerLegRole role) + { + return switch (role) + { + case SOURCE_SECURITY_LEG, SOURCE_BOND_LEG -> CorporateActionLeg.SOURCE_SECURITY; + case TARGET_SECURITY_LEG, RECEIVED_SECURITY_LEG -> CorporateActionLeg.TARGET_SECURITY; + case SECURITY_CONTEXT_LEG -> CorporateActionLeg.SECURITY_CONTEXT; + case DISTRIBUTED_SECURITY_LEG -> CorporateActionLeg.DISTRIBUTED_SECURITY; + case DISTRIBUTED_RIGHT_LEG -> CorporateActionLeg.RIGHT_SECURITY; + case CASH_LEG -> null; + case CASH_COMPENSATION_LEG -> CorporateActionLeg.CASH_COMPENSATION; + case ACCRUED_INTEREST_LEG -> CorporateActionLeg.ACCRUED_INTEREST; + case PRINCIPAL_REDEMPTION_LEG -> CorporateActionLeg.PRINCIPAL; + case FEE_LEG -> CorporateActionLeg.FEE; + case TAX_LEG -> CorporateActionLeg.TAX; + case FOREX_CONTEXT_LEG -> null; + }; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionView.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionView.java new file mode 100644 index 0000000000..3ed614302e --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionView.java @@ -0,0 +1,93 @@ +package name.abuchen.portfolio.model.ledger.nativeentry; + +import java.util.Objects; + +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerLegRole; + +/** + * Contributor-facing query facade over one native Corporate Action entry. + */ +public final class LedgerCorporateActionView +{ + private final LedgerEntry entry; + + private LedgerCorporateActionView(LedgerEntry entry) + { + this.entry = Objects.requireNonNull(entry); + + if (entry.getType() != LedgerEntryType.CORPORATE_ACTION) + throw new IllegalArgumentException("Ledger entry is not a Corporate Action: " + entry.getType()); //$NON-NLS-1$ + } + + public static LedgerCorporateActionView of(LedgerEntry entry) + { + return new LedgerCorporateActionView(entry); + } + + public LedgerEntry entry() + { + return entry; + } + + public LedgerCorporateActionLegQuery legs() + { + return new LedgerCorporateActionLegQuery(entry); + } + + public LedgerCorporateActionLegQuery legs(LedgerLegRole role) + { + return legs().withRole(role); + } + + public LedgerCorporateActionLegQuery securityContexts() + { + return legs(LedgerLegRole.SECURITY_CONTEXT_LEG); + } + + public LedgerCorporateActionLegQuery securityIn() + { + return legs(LedgerLegRole.TARGET_SECURITY_LEG); + } + + public LedgerCorporateActionLegQuery rightsIn() + { + return legs(LedgerLegRole.DISTRIBUTED_RIGHT_LEG); + } + + public LedgerCorporateActionLegQuery securityOut() + { + return legs(LedgerLegRole.SOURCE_SECURITY_LEG); + } + + public LedgerCorporateActionLegQuery cash() + { + return legs(LedgerLegRole.CASH_LEG); + } + + public LedgerCorporateActionLegQuery cashCompensation() + { + return legs(LedgerLegRole.CASH_COMPENSATION_LEG); + } + + public LedgerCorporateActionLegQuery fees() + { + return legs(LedgerLegRole.FEE_LEG); + } + + public LedgerCorporateActionLegQuery taxes() + { + return legs(LedgerLegRole.TAX_LEG); + } + + public LedgerCorporateActionLegQuery principal() + { + return legs(LedgerLegRole.PRINCIPAL_REDEMPTION_LEG); + } + + public LedgerCorporateActionLegQuery accruedInterest() + { + return legs(LedgerLegRole.ACCRUED_INTEREST_LEG); + } +} From bc42b9401c558f2bf080ecb4ef1f9d9d3ccb93a8 Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Sun, 5 Jul 2026 21:40:26 +0200 Subject: [PATCH 65/68] Prune redundant Ledger corporate action tests Removes earlier single-slice Ledger corporate action tests whose happy-path creation, edit, roundtrip, projection, and delete coverage is now covered by the fluent end-to-end and family projection suites. This keeps validator, registry, projection, persistence, diagnostic, and no-UUID coverage focused on unique behaviors while reducing duplicated maintenance load. Production code, registry definitions, persistence schemas, projections, legacy transaction types, UI/import/report/tax/FIFO/performance, and correction/reversal behavior are intentionally unchanged. --- ...ateActionMultiMovementPersistenceTest.java | 817 ------------------ .../LedgerCorporateActionFluentApiTest.java | 249 ------ 2 files changed, 1066 deletions(-) delete mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java delete mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionFluentApiTest.java diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java deleted file mode 100644 index 782608d70f..0000000000 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCorporateActionMultiMovementPersistenceTest.java +++ /dev/null @@ -1,817 +0,0 @@ -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.io.ByteArrayInputStream; -import java.io.File; -import java.io.IOException; -import java.math.BigDecimal; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.time.Instant; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; - -import org.junit.Test; - -import name.abuchen.portfolio.model.Account; -import name.abuchen.portfolio.model.Client; -import name.abuchen.portfolio.model.ClientFactory; -import name.abuchen.portfolio.model.Portfolio; -import name.abuchen.portfolio.model.ProtobufTestUtilities; -import name.abuchen.portfolio.model.Security; -import name.abuchen.portfolio.model.ledger.configuration.CorporateActionBasisMethod; -import name.abuchen.portfolio.model.ledger.configuration.CorporateActionBasisStatus; -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.LedgerLegRole; -import name.abuchen.portfolio.model.ledger.configuration.LedgerNativeEntryDefinitionValidator; -import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; -import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; -import name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor; -import name.abuchen.portfolio.model.proto.v1.PClient; -import name.abuchen.portfolio.money.CurrencyUnit; -import name.abuchen.portfolio.money.Money; -import name.abuchen.portfolio.money.Values; - -/** - * Model/persistence proof for repeated SPIN_OFF movement legs. - * This is low-level service coverage; UI support is intentionally outside this slice. - */ -@SuppressWarnings("nls") -public class LedgerCorporateActionMultiMovementPersistenceTest -{ - private static final byte[] PROTOBUF_SIGNATURE = new byte[] { 'P', 'P', 'P', 'B', 'V', '1' }; - private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 1, 2, 3, 4); - private static final Instant UPDATED_AT = Instant.parse("2026-01-02T03:04:05Z"); - - @Test - public void testXmlRoundtripPreservesRepeatedSpinOffMovementLegs() throws Exception - { - var fixture = spinOffFixture(); - - assertRepeatedSpinOff(fixture.entry()); - assertValid(fixture.client()); - assertNativeDefinitionValid(fixture.entry()); - - var xml = saveXml(fixture.client()); - - assertNoLedgerUuidTruth(xml); - - var loaded = loadXml(xml); - var loadedEntry = onlyLedgerEntry(loaded); - - assertRepeatedSpinOff(loadedEntry); - assertValid(loaded); - assertNativeDefinitionValid(loadedEntry); - } - - @Test - public void testProtobufRoundtripPreservesRepeatedSpinOffMovementLegs() throws Exception - { - var fixture = spinOffFixture(); - - assertRepeatedSpinOff(fixture.entry()); - assertValid(fixture.client()); - assertNativeDefinitionValid(fixture.entry()); - - var bytes = ProtobufTestUtilities.save(fixture.client()); - var proto = parseProto(bytes); - var protoEntry = proto.getLedger().getEntries(0); - - assertThat(proto.getLedger().getEntriesCount(), is(1)); - assertThat(protoEntry.getTypeCode(), is(LedgerEntryType.CORPORATE_ACTION.getCode())); - assertThat(protoEntry.getPostingsCount(), is(9)); - assertNoCorporateActionSpecificLegacyTransactionType(proto); - - var loaded = ProtobufTestUtilities.load(bytes); - var loadedEntry = onlyLedgerEntry(loaded); - - assertRepeatedSpinOff(loadedEntry); - assertValid(loaded); - assertNativeDefinitionValid(loadedEntry); - } - - @Test - public void testXmlAndProtobufRoundtripPreserveDefaultedInterestPrimaryMovement() throws Exception - { - var fixture = defaultedInterestFixture(); - - assertDefaultedInterestPrimaryMovement(fixture.entry()); - assertValid(fixture.client()); - assertNativeDefinitionValid(fixture.entry()); - - var loadedFromXml = loadXml(saveXml(fixture.client())); - var loadedXmlEntry = onlyLedgerEntry(loadedFromXml); - - assertDefaultedInterestPrimaryMovement(loadedXmlEntry); - assertValid(loadedFromXml); - assertNativeDefinitionValid(loadedXmlEntry); - - var loadedFromProto = ProtobufTestUtilities.load(ProtobufTestUtilities.save(fixture.client())); - var loadedProtoEntry = onlyLedgerEntry(loadedFromProto); - - assertDefaultedInterestPrimaryMovement(loadedProtoEntry); - assertValid(loadedFromProto); - assertNativeDefinitionValid(loadedProtoEntry); - } - - @Test - public void testXmlAndProtobufRoundtripPreserveCashDistribution() throws Exception - { - var fixture = cashDistributionFixture(); - - assertCashDistribution(fixture.entry()); - assertValid(fixture.client()); - assertNativeDefinitionValid(fixture.entry()); - - var xml = saveXml(fixture.client()); - - assertNoLedgerUuidTruth(xml); - - var loadedFromXml = loadXml(xml); - var loadedXmlEntry = onlyLedgerEntry(loadedFromXml); - - assertCashDistribution(loadedXmlEntry); - assertValid(loadedFromXml); - assertNativeDefinitionValid(loadedXmlEntry); - - var bytes = ProtobufTestUtilities.save(fixture.client()); - var proto = parseProto(bytes); - var protoEntry = proto.getLedger().getEntries(0); - - assertThat(protoEntry.getTypeCode(), is(LedgerEntryType.CORPORATE_ACTION.getCode())); - assertTrue(protoEntry.getParametersList().stream() - .anyMatch(parameter -> LedgerParameterType.CORPORATE_ACTION_KIND.getCode() - .equals(parameter.getTypeCode()) - && CorporateActionKind.CASH_DISTRIBUTION.getCode() - .equals(parameter.getStringValue()))); - assertNoCorporateActionSpecificLegacyTransactionType(proto); - - var loadedFromProto = ProtobufTestUtilities.load(bytes); - var loadedProtoEntry = onlyLedgerEntry(loadedFromProto); - - assertCashDistribution(loadedProtoEntry); - assertValid(loadedFromProto); - assertNativeDefinitionValid(loadedProtoEntry); - } - - @Test - public void testXmlAndProtobufRoundtripPreserveCouponPaymentInterestDetail() throws Exception - { - var fixture = couponPaymentFixture(); - - assertCouponPaymentInterestDetail(fixture.entry()); - assertValid(fixture.client()); - assertNativeDefinitionValid(fixture.entry()); - - var xml = saveXml(fixture.client()); - - assertNoLedgerUuidTruth(xml); - - var loadedFromXml = loadXml(xml); - var loadedXmlEntry = onlyLedgerEntry(loadedFromXml); - - assertCouponPaymentInterestDetail(loadedXmlEntry); - assertValid(loadedFromXml); - assertNativeDefinitionValid(loadedXmlEntry); - - var bytes = ProtobufTestUtilities.save(fixture.client()); - var proto = parseProto(bytes); - var protoEntry = proto.getLedger().getEntries(0); - - assertThat(protoEntry.getTypeCode(), is(LedgerEntryType.CORPORATE_ACTION.getCode())); - assertTrue(protoEntry.getParametersList().stream() - .anyMatch(parameter -> LedgerParameterType.CORPORATE_ACTION_KIND.getCode() - .equals(parameter.getTypeCode()) - && CorporateActionKind.COUPON_PAYMENT.getCode() - .equals(parameter.getStringValue()))); - assertNoCorporateActionSpecificLegacyTransactionType(proto); - - var loadedFromProto = ProtobufTestUtilities.load(bytes); - var loadedProtoEntry = onlyLedgerEntry(loadedFromProto); - - assertCouponPaymentInterestDetail(loadedProtoEntry); - assertValid(loadedFromProto); - assertNativeDefinitionValid(loadedProtoEntry); - } - - private Fixture spinOffFixture() - { - var client = new Client(); - var account = new Account(); - var portfolio = new Portfolio(); - var sourceSecurity = new Security("Source AG", CurrencyUnit.EUR); - var sourceSecurityB = new Security("Source B AG", CurrencyUnit.EUR); - var contextSecurity = new Security("Context AG", CurrencyUnit.EUR); - var targetSecurityA = new Security("Target A AG", CurrencyUnit.EUR); - var targetSecurityB = new Security("Target B AG", CurrencyUnit.EUR); - - account.setName("Cash Account"); - account.setCurrencyCode(CurrencyUnit.EUR); - portfolio.setName("Portfolio"); - portfolio.setReferenceAccount(account); - account.setUpdatedAt(UPDATED_AT); - portfolio.setUpdatedAt(UPDATED_AT); - sourceSecurity.setUpdatedAt(UPDATED_AT); - sourceSecurityB.setUpdatedAt(UPDATED_AT); - contextSecurity.setUpdatedAt(UPDATED_AT); - targetSecurityA.setUpdatedAt(UPDATED_AT); - targetSecurityB.setUpdatedAt(UPDATED_AT); - - client.addAccount(account); - client.addPortfolio(portfolio); - client.addSecurity(sourceSecurity); - client.addSecurity(sourceSecurityB); - client.addSecurity(contextSecurity); - client.addSecurity(targetSecurityA); - client.addSecurity(targetSecurityB); - - var entry = new LedgerEntry("spin-off-repeated"); - entry.setType(LedgerEntryType.CORPORATE_ACTION); - entry.setDateTime(DATE_TIME); - entry.setSource("SPIN_OFF repeated movement proof"); - entry.setNote("core service proof only"); - entry.addParameter(LedgerParameter.ofCode(LedgerParameterType.CORPORATE_ACTION_KIND, - CorporateActionKind.SPIN_OFF)); - entry.addParameter(LedgerParameter.ofLocalDate(LedgerParameterType.EFFECTIVE_DATE, DATE_TIME.toLocalDate())); - entry.addParameter(LedgerParameter.ofCode(LedgerParameterType.CORPORATE_ACTION_BASIS_STATUS, - CorporateActionBasisStatus.PROVIDED)); - entry.addParameter(LedgerParameter.ofCode(LedgerParameterType.CORPORATE_ACTION_BASIS_METHOD, - CorporateActionBasisMethod.PERCENTAGE_ALLOCATION)); - entry.addParameter(LedgerParameter.ofLocalDate(LedgerParameterType.CORPORATE_ACTION_BASIS_VALUATION_DATE, - DATE_TIME.toLocalDate())); - entry.addParameter(LedgerParameter.ofBoolean(LedgerParameterType.CORPORATE_ACTION_BASIS_MANUAL_OVERRIDE, - Boolean.TRUE)); - entry.addParameter(basisAllocation(LedgerLegRole.SECURITY_CONTEXT_LEG, "context-1", "main", "75")); - entry.addParameter(basisAllocation(LedgerLegRole.TARGET_SECURITY_LEG, "target-1", "main", "20")); - entry.addParameter(basisAllocation(LedgerLegRole.TARGET_SECURITY_LEG, "target-2", "main", "5")); - - entry.addPosting(spinOffSecurityPosting(portfolio, sourceSecurity, sourceSecurity, targetSecurityA, - LedgerPostingDirection.OUTBOUND, CorporateActionLeg.SOURCE_SECURITY, "main", "source-1", - 10, 100)); - entry.addPosting(spinOffSecurityPosting(portfolio, sourceSecurityB, sourceSecurityB, targetSecurityB, - LedgerPostingDirection.OUTBOUND, CorporateActionLeg.SOURCE_SECURITY, "main", "source-2", - 4, 40)); - entry.addPosting(securityContextPosting(portfolio, contextSecurity, "main", "context-1")); - entry.addPosting(spinOffSecurityPosting(portfolio, targetSecurityA, sourceSecurity, targetSecurityA, - LedgerPostingDirection.INBOUND, CorporateActionLeg.TARGET_SECURITY, "main", "target-1", 3, - 60)); - entry.addPosting(spinOffSecurityPosting(portfolio, targetSecurityB, sourceSecurity, targetSecurityB, - LedgerPostingDirection.INBOUND, CorporateActionLeg.TARGET_SECURITY, "main", "target-2", 7, - 40)); - entry.addPosting(spinOffCashPosting(account, "cash-1", "cash-1", 11)); - entry.addPosting(spinOffCashPosting(account, "cash-2", "cash-2", 22)); - entry.addPosting(unitPosting(LedgerPostingType.FEE, LedgerPostingSemanticRole.FEE, - LedgerPostingUnitRole.FEE, CorporateActionLeg.FEE, "cash-1", "fee-1", 2)); - entry.addPosting(unitPosting(LedgerPostingType.TAX, LedgerPostingSemanticRole.TAX, - LedgerPostingUnitRole.TAX, CorporateActionLeg.TAX, "cash-1", "tax-1", 4)); - - client.getLedger().addEntry(entry); - - return new Fixture(client, entry); - } - - private Fixture defaultedInterestFixture() - { - var client = new Client(); - var account = new Account(); - var portfolio = new Portfolio(); - var contextSecurity = new Security("Defaulted Bond", CurrencyUnit.EUR); - - account.setName("Cash Account"); - account.setCurrencyCode(CurrencyUnit.EUR); - portfolio.setName("Portfolio"); - portfolio.setReferenceAccount(account); - account.setUpdatedAt(UPDATED_AT); - portfolio.setUpdatedAt(UPDATED_AT); - contextSecurity.setUpdatedAt(UPDATED_AT); - - client.addAccount(account); - client.addPortfolio(portfolio); - client.addSecurity(contextSecurity); - - var entry = new LedgerEntry("defaulted-interest-primary-movement"); - entry.setType(LedgerEntryType.CORPORATE_ACTION); - entry.setDateTime(DATE_TIME); - entry.setSource("DEFAULTED_INTEREST primary movement proof"); - entry.addParameter(LedgerParameter.ofCode(LedgerParameterType.CORPORATE_ACTION_KIND, - CorporateActionKind.DEFAULTED_INTEREST)); - entry.addPosting(securityContextPosting(portfolio, contextSecurity, "main", "context-1")); - entry.addPosting(cashPrimaryPosting(account, "cash-1", 33)); - - client.getLedger().addEntry(entry); - - return new Fixture(client, entry); - } - - private Fixture cashDistributionFixture() - { - var client = new Client(); - var account = new Account(); - var portfolio = new Portfolio(); - var contextSecurity = new Security("Cash Distribution AG", CurrencyUnit.EUR); - - account.setName("Cash Account"); - account.setCurrencyCode(CurrencyUnit.EUR); - portfolio.setName("Portfolio"); - portfolio.setReferenceAccount(account); - account.setUpdatedAt(UPDATED_AT); - portfolio.setUpdatedAt(UPDATED_AT); - contextSecurity.setUpdatedAt(UPDATED_AT); - - client.addAccount(account); - client.addPortfolio(portfolio); - client.addSecurity(contextSecurity); - - var entry = new LedgerEntry("cash-distribution"); - - entry.setType(LedgerEntryType.CORPORATE_ACTION); - entry.setDateTime(DATE_TIME); - entry.setSource("CASH_DISTRIBUTION primary movement proof"); - entry.addParameter(LedgerParameter.ofCode(LedgerParameterType.CORPORATE_ACTION_KIND, - CorporateActionKind.CASH_DISTRIBUTION)); - entry.addPosting(securityContextPosting(portfolio, contextSecurity, "main", "context-1")); - entry.addPosting(cashPrimaryPosting(account, "cash-1", 44)); - - client.getLedger().addEntry(entry); - - return new Fixture(client, entry); - } - - private Fixture couponPaymentFixture() - { - var client = new Client(); - var account = new Account(); - var portfolio = new Portfolio(); - var contextSecurity = new Security("Coupon Bond", CurrencyUnit.EUR); - - account.setName("Cash Account"); - account.setCurrencyCode(CurrencyUnit.EUR); - portfolio.setName("Portfolio"); - portfolio.setReferenceAccount(account); - account.setUpdatedAt(UPDATED_AT); - portfolio.setUpdatedAt(UPDATED_AT); - contextSecurity.setUpdatedAt(UPDATED_AT); - - client.addAccount(account); - client.addPortfolio(portfolio); - client.addSecurity(contextSecurity); - - var entry = new LedgerEntry("coupon-payment-interest-detail"); - - entry.setType(LedgerEntryType.CORPORATE_ACTION); - entry.setDateTime(DATE_TIME); - entry.setSource("COUPON_PAYMENT interest detail proof"); - entry.addParameter(LedgerParameter.ofCode(LedgerParameterType.CORPORATE_ACTION_KIND, - CorporateActionKind.COUPON_PAYMENT)); - entry.addPosting(securityContextPosting(portfolio, contextSecurity, "main", "context-1")); - entry.addPosting(cashPrimaryPosting(account, "cash-1", 55)); - entry.addPosting(accruedInterestPosting(account, "cash-1", "interest-1", 55)); - - client.getLedger().addEntry(entry); - - return new Fixture(client, entry); - } - - private LedgerPosting securityPosting(Portfolio portfolio, Security security, LedgerPostingDirection direction, - CorporateActionLeg leg, String groupKey, String localKey, long shares, long amount) - { - var posting = new LedgerPosting("proof-" + localKey); - - posting.setType(LedgerPostingType.SECURITY); - posting.setPortfolio(portfolio); - posting.setSecurity(security); - posting.setShares(Values.Share.factorize(shares)); - posting.setAmount(Values.Amount.factorize(amount)); - posting.setCurrency(CurrencyUnit.EUR); - posting.setSemanticRole(LedgerPostingSemanticRole.SECURITY); - posting.setDirection(direction); - posting.setCorporateActionLeg(leg); - posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); - posting.setGroupKey(groupKey); - posting.setLocalKey(localKey); - - return posting; - } - - private LedgerPosting securityContextPosting(Portfolio portfolio, Security security, String groupKey, - String localKey) - { - var posting = securityPosting(portfolio, security, LedgerPostingDirection.NEUTRAL, - CorporateActionLeg.SECURITY_CONTEXT, groupKey, localKey, 0, 0); - - posting.addParameter(LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_LEG, - CorporateActionLeg.SECURITY_CONTEXT.getCode())); - - return posting; - } - - private LedgerPosting spinOffSecurityPosting(Portfolio portfolio, Security security, Security sourceSecurity, - Security targetSecurity, LedgerPostingDirection direction, CorporateActionLeg leg, String groupKey, - String localKey, long shares, long amount) - { - var posting = securityPosting(portfolio, security, direction, leg, groupKey, localKey, shares, amount); - - posting.addParameter(LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_LEG, leg.getCode())); - posting.addParameter(LedgerParameter.ofSecurity(LedgerParameterType.SOURCE_SECURITY, sourceSecurity)); - posting.addParameter(LedgerParameter.ofSecurity(LedgerParameterType.TARGET_SECURITY, targetSecurity)); - posting.addParameter(LedgerParameter.ofDecimal(LedgerParameterType.RATIO_NUMERATOR, BigDecimal.ONE)); - posting.addParameter(LedgerParameter.ofDecimal(LedgerParameterType.RATIO_DENOMINATOR, BigDecimal.TEN)); - - return posting; - } - - private LedgerPosting spinOffCashPosting(Account account, String groupKey, String localKey, long amount) - { - var posting = cashPosting(account, LedgerPostingDirection.NEUTRAL, groupKey, localKey, amount); - - posting.addParameter(LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_LEG, - CorporateActionLeg.CASH_COMPENSATION.getCode())); - posting.addParameter(LedgerParameter.ofMoney(LedgerParameterType.CASH_IN_LIEU_AMOUNT, - Money.of(CurrencyUnit.EUR, Values.Amount.factorize(amount)))); - - return posting; - } - - private LedgerPosting cashPosting(Account account, LedgerPostingDirection direction, String groupKey, String localKey, - long amount) - { - var posting = new LedgerPosting("proof-" + localKey); - - posting.setType(LedgerPostingType.CASH_COMPENSATION); - posting.setAccount(account); - posting.setAmount(Values.Amount.factorize(amount)); - posting.setCurrency(CurrencyUnit.EUR); - posting.setSemanticRole(LedgerPostingSemanticRole.CASH_COMPENSATION); - posting.setDirection(direction); - posting.setCorporateActionLeg(CorporateActionLeg.CASH_COMPENSATION); - posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); - posting.setGroupKey(groupKey); - posting.setLocalKey(localKey); - - return posting; - } - - private LedgerPosting cashPrimaryPosting(Account account, String localKey, long amount) - { - var posting = new LedgerPosting("proof-" + localKey); - - posting.setType(LedgerPostingType.CASH); - posting.setAccount(account); - posting.setAmount(Values.Amount.factorize(amount)); - posting.setCurrency(CurrencyUnit.EUR); - posting.setSemanticRole(LedgerPostingSemanticRole.CASH); - posting.setDirection(LedgerPostingDirection.NEUTRAL); - posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); - posting.setGroupKey(localKey); - posting.setLocalKey(localKey); - - return posting; - } - - private LedgerPosting accruedInterestPosting(Account account, String groupKey, String localKey, long amount) - { - var posting = new LedgerPosting("proof-" + localKey); - - posting.setType(LedgerPostingType.ACCRUED_INTEREST); - posting.setAccount(account); - posting.setAmount(Values.Amount.factorize(amount)); - posting.setCurrency(CurrencyUnit.EUR); - posting.setSemanticRole(LedgerPostingSemanticRole.ACCRUED_INTEREST); - posting.setDirection(LedgerPostingDirection.NEUTRAL); - posting.setCorporateActionLeg(CorporateActionLeg.ACCRUED_INTEREST); - posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); - posting.setGroupKey(groupKey); - posting.setLocalKey(localKey); - posting.addParameter(LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_LEG, - CorporateActionLeg.ACCRUED_INTEREST.getCode())); - posting.addParameter(LedgerParameter.ofMoney(LedgerParameterType.ACCRUED_INTEREST_AMOUNT, - Money.of(CurrencyUnit.EUR, Values.Amount.factorize(amount)))); - posting.addParameter(LedgerParameter.ofDecimal(LedgerParameterType.COUPON_RATE, - new BigDecimal("4.25"))); - posting.addParameter(LedgerParameter.ofLocalDate(LedgerParameterType.INTEREST_PERIOD_START, - DATE_TIME.toLocalDate().minusMonths(6))); - posting.addParameter(LedgerParameter.ofLocalDate(LedgerParameterType.INTEREST_PERIOD_END, - DATE_TIME.toLocalDate())); - posting.addParameter(LedgerParameter.ofLocalDate(LedgerParameterType.PAYMENT_DATE, - DATE_TIME.toLocalDate())); - - return posting; - } - - private LedgerPosting unitPosting(LedgerPostingType type, LedgerPostingSemanticRole semanticRole, - LedgerPostingUnitRole unitRole, CorporateActionLeg leg, String groupKey, String localKey, - long amount) - { - var posting = new LedgerPosting("proof-" + localKey); - - posting.setType(type); - posting.setAmount(Values.Amount.factorize(amount)); - posting.setCurrency(CurrencyUnit.EUR); - posting.setSemanticRole(semanticRole); - posting.setDirection(LedgerPostingDirection.NEUTRAL); - posting.setCorporateActionLeg(leg); - posting.setUnitRole(unitRole); - posting.setGroupKey(groupKey); - posting.setLocalKey(localKey); - posting.addParameter(LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_LEG, leg.getCode())); - - return posting; - } - - private LedgerParameter basisAllocation(LedgerLegRole targetRole, String targetLocalKey, - String targetGroupKey, String percent) - { - return LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_BASIS_ALLOCATION, - CorporateActionBasisAllocation - .percentage(targetRole, targetLocalKey, targetGroupKey, new BigDecimal(percent)) - .toParameterValue()); - } - - private void assertRepeatedSpinOff(LedgerEntry entry) - { - assertThat(entry.getType(), is(LedgerEntryType.CORPORATE_ACTION)); - assertThat(entry.getPostings().size(), is(9)); - - var sourceSecurities = postings(entry, LedgerPostingType.SECURITY, LedgerPostingDirection.OUTBOUND, - CorporateActionLeg.SOURCE_SECURITY); - var securityContexts = postings(entry, LedgerPostingType.SECURITY, LedgerPostingDirection.NEUTRAL, - CorporateActionLeg.SECURITY_CONTEXT); - var targetSecurities = postings(entry, LedgerPostingType.SECURITY, LedgerPostingDirection.INBOUND, - CorporateActionLeg.TARGET_SECURITY); - var cashMovements = postings(entry, LedgerPostingType.CASH_COMPENSATION, LedgerPostingDirection.NEUTRAL, - CorporateActionLeg.CASH_COMPENSATION); - var descriptors = LedgerDescriptorTestSupport.descriptors(entry); - var targetDescriptors = descriptors.stream() - .filter(descriptor -> descriptor.getRole() == LedgerProjectionRole.NEW_SECURITY_LEG) - .toList(); - var sourceDescriptors = descriptors.stream() - .filter(descriptor -> descriptor.getRole() == LedgerProjectionRole.OLD_SECURITY_LEG) - .toList(); - - assertThat(sourceSecurities.size(), is(2)); - assertThat(localKeys(sourceSecurities), is(Set.of("source-1", "source-2"))); - assertThat(securityContexts.size(), is(1)); - assertThat(localKeys(securityContexts), is(Set.of("context-1"))); - assertTrue(securityContexts.stream().allMatch(posting -> posting.getPortfolio() != null)); - assertTrue(securityContexts.stream().allMatch(posting -> posting.getSecurity() != null)); - assertFalse(securityContexts.stream().anyMatch(posting -> posting.getAccount() != null)); - assertThat(targetSecurities.size(), is(2)); - assertThat(localKeys(targetSecurities), is(Set.of("target-1", "target-2"))); - assertThat(cashMovements.size(), is(2)); - assertThat(localKeys(cashMovements), is(Set.of("cash-1", "cash-2"))); - assertThat(groupKeys(cashMovements), is(Set.of("cash-1", "cash-2"))); - assertThat(sourceDescriptors.size(), is(2)); - assertThat(sourceDescriptors.stream().map(descriptor -> descriptor.getSemanticInstanceKey().orElseThrow()) - .collect(Collectors.toSet()), is(Set.of("source-1", "source-2"))); - assertThat(targetDescriptors.size(), is(2)); - assertThat(targetDescriptors.stream().map(descriptor -> descriptor.getSemanticInstanceKey().orElseThrow()) - .collect(Collectors.toSet()), is(Set.of("target-1", "target-2"))); - assertFalse(descriptors.stream().map(DerivedProjectionDescriptor::getPrimaryPosting) - .anyMatch(posting -> posting.getCorporateActionLeg() == CorporateActionLeg.SECURITY_CONTEXT)); - var runtimeProjectionIds = targetDescriptors.stream().map(DerivedProjectionDescriptor::getRuntimeProjectionId) - .collect(Collectors.toSet()); - - assertThat(runtimeProjectionIds.size(), is(2)); - assertTrue(runtimeProjectionIds.stream().anyMatch(id -> id.endsWith(":NEW_SECURITY_LEG:target-1"))); - assertTrue(runtimeProjectionIds.stream().anyMatch(id -> id.endsWith(":NEW_SECURITY_LEG:target-2"))); - assertBasisTreatment(entry); - } - - private void assertBasisTreatment(LedgerEntry entry) - { - assertTrue(entry.getParameters().stream() - .anyMatch(parameter -> parameter.getType() == LedgerParameterType.CORPORATE_ACTION_BASIS_STATUS - && CorporateActionBasisStatus.PROVIDED.getCode() - .equals(parameter.getValue()))); - assertTrue(entry.getParameters().stream() - .anyMatch(parameter -> parameter.getType() == LedgerParameterType.CORPORATE_ACTION_BASIS_METHOD - && CorporateActionBasisMethod.PERCENTAGE_ALLOCATION.getCode() - .equals(parameter.getValue()))); - assertTrue(entry.getParameters().stream() - .anyMatch(parameter -> parameter.getType() == LedgerParameterType.CORPORATE_ACTION_BASIS_VALUATION_DATE)); - assertTrue(entry.getParameters().stream() - .anyMatch(parameter -> parameter.getType() == LedgerParameterType.CORPORATE_ACTION_BASIS_MANUAL_OVERRIDE - && Boolean.TRUE.equals(parameter.getValue()))); - - var allocations = entry.getParameters().stream() - .filter(parameter -> parameter.getType() == LedgerParameterType.CORPORATE_ACTION_BASIS_ALLOCATION) - .map(LedgerParameter::getValue) - .map(String.class::cast) - .map(CorporateActionBasisAllocation::parse) - .toList(); - - assertThat(allocations.size(), is(3)); - assertTrue(allocations.stream() - .anyMatch(allocation -> allocation.getTargetRole() == LedgerLegRole.SECURITY_CONTEXT_LEG - && "context-1".equals(allocation.getTargetLocalKey()) - && allocation.getPercent().orElseThrow().compareTo(new BigDecimal("75")) == 0)); - assertTrue(allocations.stream() - .anyMatch(allocation -> allocation.getTargetRole() == LedgerLegRole.TARGET_SECURITY_LEG - && "target-1".equals(allocation.getTargetLocalKey()) - && allocation.getPercent().orElseThrow().compareTo(new BigDecimal("20")) == 0)); - assertTrue(allocations.stream() - .anyMatch(allocation -> allocation.getTargetRole() == LedgerLegRole.TARGET_SECURITY_LEG - && "target-2".equals(allocation.getTargetLocalKey()) - && allocation.getPercent().orElseThrow().compareTo(new BigDecimal("5")) == 0)); - } - - private void assertDefaultedInterestPrimaryMovement(LedgerEntry entry) - { - assertThat(entry.getType(), is(LedgerEntryType.CORPORATE_ACTION)); - assertTrue(entry.getParameters().stream() - .anyMatch(parameter -> parameter.getType() == LedgerParameterType.CORPORATE_ACTION_KIND - && CorporateActionKind.DEFAULTED_INTEREST.getCode() - .equals(parameter.getValue()))); - assertThat(postings(entry, LedgerPostingType.SECURITY, LedgerPostingDirection.NEUTRAL, - CorporateActionLeg.SECURITY_CONTEXT).size(), is(1)); - assertThat(entry.getPostings().stream() - .filter(posting -> posting.getType() == LedgerPostingType.CASH) - .filter(posting -> "cash-1".equals(posting.getLocalKey())) //$NON-NLS-1$ - .count(), is(1L)); - var descriptors = LedgerDescriptorTestSupport.descriptors(entry); - assertThat(descriptors.size(), is(1)); - assertThat(descriptors.get(0).getRole(), is(LedgerProjectionRole.ACCOUNT)); - assertThat(descriptors.get(0).getSemanticInstanceKey().orElseThrow(), is("cash-1")); - assertThat(descriptors.get(0).getPrimaryPosting().getType(), is(LedgerPostingType.CASH)); - assertFalse(descriptors.stream().map(DerivedProjectionDescriptor::getPrimaryPosting) - .anyMatch(posting -> posting.getCorporateActionLeg() == CorporateActionLeg.SECURITY_CONTEXT)); - } - - private void assertCashDistribution(LedgerEntry entry) - { - assertThat(entry.getType(), is(LedgerEntryType.CORPORATE_ACTION)); - assertTrue(entry.getParameters().stream() - .anyMatch(parameter -> parameter.getType() == LedgerParameterType.CORPORATE_ACTION_KIND - && CorporateActionKind.CASH_DISTRIBUTION.getCode() - .equals(parameter.getValue()))); - assertThat(postings(entry, LedgerPostingType.SECURITY, LedgerPostingDirection.NEUTRAL, - CorporateActionLeg.SECURITY_CONTEXT).size(), is(1)); - assertThat(entry.getPostings().stream() - .filter(posting -> posting.getType() == LedgerPostingType.CASH) - .filter(posting -> "cash-1".equals(posting.getLocalKey())) //$NON-NLS-1$ - .filter(posting -> "cash-1".equals(posting.getGroupKey())) //$NON-NLS-1$ - .filter(posting -> posting.getAccount() != null) - .count(), is(1L)); - var descriptors = LedgerDescriptorTestSupport.descriptors(entry); - assertThat(descriptors.size(), is(1)); - assertThat(descriptors.get(0).getRole(), is(LedgerProjectionRole.ACCOUNT)); - assertThat(descriptors.get(0).getSemanticInstanceKey().orElseThrow(), is("cash-1")); - assertThat(descriptors.get(0).getPrimaryPosting().getType(), is(LedgerPostingType.CASH)); - assertFalse(descriptors.stream().map(DerivedProjectionDescriptor::getPrimaryPosting) - .anyMatch(posting -> posting.getCorporateActionLeg() == CorporateActionLeg.SECURITY_CONTEXT)); - } - - private void assertCouponPaymentInterestDetail(LedgerEntry entry) - { - assertThat(entry.getType(), is(LedgerEntryType.CORPORATE_ACTION)); - assertTrue(entry.getParameters().stream() - .anyMatch(parameter -> parameter.getType() == LedgerParameterType.CORPORATE_ACTION_KIND - && CorporateActionKind.COUPON_PAYMENT.getCode() - .equals(parameter.getValue()))); - assertThat(postings(entry, LedgerPostingType.SECURITY, LedgerPostingDirection.NEUTRAL, - CorporateActionLeg.SECURITY_CONTEXT).size(), is(1)); - - var cash = entry.getPostings().stream() - .filter(posting -> posting.getType() == LedgerPostingType.CASH) - .filter(posting -> "cash-1".equals(posting.getLocalKey())) //$NON-NLS-1$ - .findFirst().orElseThrow(); - var interest = entry.getPostings().stream() - .filter(posting -> posting.getType() == LedgerPostingType.ACCRUED_INTEREST) - .filter(posting -> "interest-1".equals(posting.getLocalKey())) //$NON-NLS-1$ - .findFirst().orElseThrow(); - - assertThat(cash.getGroupKey(), is("cash-1")); - assertThat(interest.getGroupKey(), is("cash-1")); - assertThat(interest.getCorporateActionLeg(), is(CorporateActionLeg.ACCRUED_INTEREST)); - assertTrue(interest.getParameters().stream() - .anyMatch(parameter -> parameter.getType() == LedgerParameterType.ACCRUED_INTEREST_AMOUNT)); - assertTrue(interest.getParameters().stream() - .anyMatch(parameter -> parameter.getType() == LedgerParameterType.COUPON_RATE)); - assertTrue(interest.getParameters().stream() - .anyMatch(parameter -> parameter.getType() == LedgerParameterType.INTEREST_PERIOD_START)); - assertTrue(interest.getParameters().stream() - .anyMatch(parameter -> parameter.getType() == LedgerParameterType.INTEREST_PERIOD_END)); - var descriptors = LedgerDescriptorTestSupport.descriptors(entry); - assertThat(descriptors.size(), is(1)); - assertThat(descriptors.get(0).getRole(), is(LedgerProjectionRole.ACCOUNT)); - assertThat(descriptors.get(0).getSemanticInstanceKey().orElseThrow(), is("cash-1")); - assertThat(descriptors.get(0).getPrimaryPosting().getType(), is(LedgerPostingType.CASH)); - assertFalse(descriptors.stream().map(DerivedProjectionDescriptor::getPrimaryPosting) - .anyMatch(posting -> posting.getType() == LedgerPostingType.ACCRUED_INTEREST)); - assertFalse(descriptors.stream().map(DerivedProjectionDescriptor::getPrimaryPosting) - .anyMatch(posting -> posting.getCorporateActionLeg() == CorporateActionLeg.SECURITY_CONTEXT)); - } - - private List postings(LedgerEntry entry, LedgerPostingType type, LedgerPostingDirection direction, - CorporateActionLeg leg) - { - return entry.getPostings().stream() - .filter(posting -> posting.getType() == type) - .filter(posting -> posting.getDirection() == direction) - .filter(posting -> posting.getCorporateActionLeg() == leg) - .toList(); - } - - private Set localKeys(List postings) - { - return postings.stream().map(LedgerPosting::getLocalKey).collect(Collectors.toSet()); - } - - private Set groupKeys(List postings) - { - return postings.stream().map(LedgerPosting::getGroupKey).collect(Collectors.toSet()); - } - - private String saveXml(Client client) throws IOException - { - var file = File.createTempFile("ledger-corporate-action-movement", ".xml"); - - try - { - ClientFactory.save(client, file); - return Files.readString(file.toPath(), StandardCharsets.UTF_8); - } - finally - { - Files.deleteIfExists(file.toPath()); - } - } - - private Client loadXml(String xml) throws IOException - { - return ClientFactory.load(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); - } - - private PClient parseProto(byte[] bytes) throws IOException - { - return PClient.parseFrom(new ByteArrayInputStream(bytes, PROTOBUF_SIGNATURE.length, - bytes.length - PROTOBUF_SIGNATURE.length)); - } - - private LedgerEntry onlyLedgerEntry(Client client) - { - assertThat(client.getLedger().getEntries().size(), is(1)); - return client.getLedger().getEntries().get(0); - } - - private void assertValid(Client client) - { - assertTrue(LedgerStructuralValidator.validate(client.getLedger()).toString(), - LedgerStructuralValidator.validate(client.getLedger()).isOK()); - } - - private void assertNativeDefinitionValid(LedgerEntry entry) - { - var result = LedgerNativeEntryDefinitionValidator.validate(entry); - - assertTrue(result.format(), result.isOK()); - } - - private void assertNoLedgerUuidTruth(String xml) - { - var ledgerXml = ledgerSection(xml); - - assertFalse(ledgerXml.matches("(?s).*]*\\buuid=.*")); - assertFalse(ledgerXml.matches("(?s).*]*\\buuid=.*")); - assertFalse(ledgerXml.contains("")); - assertFalse(ledgerXml.contains(""); - var end = xml.indexOf(""); - - assertTrue(start >= 0); - assertTrue(end > start); - - return xml.substring(start, end); - } - - private void assertNoCorporateActionSpecificLegacyTransactionType(PClient proto) - { - for (var transaction : proto.getTransactionsList()) - assertFalse("SPIN_OFF".equals(transaction.getType().name())); - - assertFalse(java.util.Arrays.stream(name.abuchen.portfolio.model.proto.v1.PTransaction.Type.values()) - .map(Enum::name).anyMatch("SPIN_OFF"::equals)); - } - - private record Fixture(Client client, LedgerEntry entry) - { - } -} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionFluentApiTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionFluentApiTest.java deleted file mode 100644 index 0d7a28aadf..0000000000 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionFluentApiTest.java +++ /dev/null @@ -1,249 +0,0 @@ -package name.abuchen.portfolio.model.ledger.nativeentry; - -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; - -import java.math.BigDecimal; -import java.time.LocalDate; -import java.time.LocalDateTime; - -import org.junit.Test; - -import name.abuchen.portfolio.model.Account; -import name.abuchen.portfolio.model.Client; -import name.abuchen.portfolio.model.Portfolio; -import name.abuchen.portfolio.model.Security; -import name.abuchen.portfolio.model.ledger.LedgerMutationContext; -import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.configuration.CorporateActionBasisMethod; -import name.abuchen.portfolio.model.ledger.configuration.CorporateActionBasisStatus; -import name.abuchen.portfolio.model.ledger.configuration.CorporateActionKind; -import name.abuchen.portfolio.model.ledger.configuration.LedgerLegRole; -import name.abuchen.portfolio.money.CurrencyUnit; -import name.abuchen.portfolio.money.Money; -import name.abuchen.portfolio.money.Values; - -@SuppressWarnings("nls") -public class LedgerCorporateActionFluentApiTest -{ - private static final LocalDateTime DATE = LocalDateTime.of(2020, 9, 28, 0, 0); - - @Test - public void testCreatesCashDistribution() - { - var fixture = fixture(); - var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // - .kind(CorporateActionKind.CASH_DISTRIBUTION) // - .date(DATE) // - .securityContext("context-1", "main", fixture.portfolio, fixture.source) // - .cash("cash-1", fixture.account, money(15)) // - .fee("fee-1", fixture.account, money(2), "cash-1") // - .tax("tax-1", fixture.account, money(1), "cash-1") // - .buildDetached().getEntry(); - - assertThat(entry.getPostings().size(), is(4)); - assertThat(posting(entry, LedgerLegRole.CASH_LEG, "cash-1").getAmount(), is(money(15).getAmount())); - } - - @Test - public void testCreatesCouponPaymentWithSameGroupInterestDetail() - { - var fixture = fixture(); - var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // - .kind(CorporateActionKind.COUPON_PAYMENT) // - .date(DATE) // - .securityContext("context-1", "main", fixture.portfolio, fixture.source) // - .cash("cash-1", fixture.account, money(12)) // - .accruedInterest("interest-1", "cash-1", fixture.account, money(12)) // - .buildDetached().getEntry(); - - assertThat(posting(entry, LedgerLegRole.CASH_LEG, "cash-1").getGroupKey(), is("cash-1")); - assertThat(posting(entry, LedgerLegRole.ACCRUED_INTEREST_LEG, "interest-1").getGroupKey(), is("cash-1")); - } - - @Test - public void testCouponPaymentDifferentGroupInterestFailsValidation() - { - var fixture = fixture(); - var exception = assertThrows(LedgerNativeEntryAssemblyException.class, - () -> LedgerNativeEntryAssembler.corporateAction(fixture.client) // - .kind(CorporateActionKind.COUPON_PAYMENT) // - .date(DATE) // - .securityContext("context-1", "main", fixture.portfolio, fixture.source) // - .cash("cash-1", fixture.account, money(12)) // - .accruedInterest("interest-1", "other", fixture.account, money(12)) // - .buildDetached()); - - assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.NATIVE_DEFINITION_VALIDATION_FAILED)); - } - - @Test - public void testCreatesSpinOffWithRepeatedTargetsAndBasis() - { - var fixture = fixture(); - var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // - .kind(CorporateActionKind.SPIN_OFF) // - .date(DATE) // - .effectiveDate(LocalDate.of(2020, 9, 28)) // - .securityContext("context-1", "main", fixture.portfolio, fixture.source) // - .securityIn("target-1", "main", fixture.portfolio, fixture.target, Values.Share.factorize(5)) // - .securityIn("target-2", "main", fixture.portfolio, fixture.secondTarget, - Values.Share.factorize(2)) // - .cash("cash-1", fixture.account, money(3)) // - .basis(CorporateActionBasisStatus.PROVIDED) // - .basisMethod(CorporateActionBasisMethod.PERCENTAGE_ALLOCATION) // - .basisPercentageAllocation(LedgerLegRole.SECURITY_CONTEXT_LEG, "context-1", "main", - new BigDecimal("75")) // - .basisPercentageAllocation(LedgerLegRole.TARGET_SECURITY_LEG, "target-1", "main", - new BigDecimal("20")) // - .basisPercentageAllocation(LedgerLegRole.TARGET_SECURITY_LEG, "target-2", "main", - new BigDecimal("5")) // - .buildDetached().getEntry(); - - assertThat(posting(entry, LedgerLegRole.TARGET_SECURITY_LEG, "target-2").getSecurity(), - is(fixture.secondTarget)); - assertThat(entry.getPostings().stream().filter(posting -> posting.getLocalKey().startsWith("target-")) - .count(), is(2L)); - } - - @Test - public void testCreatesConversionWithBasis() - { - var fixture = fixture(); - var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // - .kind(CorporateActionKind.CONVERSION) // - .date(DATE) // - .securityContext("context-1", "main", fixture.portfolio, fixture.source) // - .securityOut("source-1", "main", fixture.portfolio, fixture.source, - Values.Share.factorize(10)) // - .securityIn("target-1", "main", fixture.portfolio, fixture.target, - Values.Share.factorize(5)) // - .basis(CorporateActionBasisStatus.PROVIDED) // - .basisMethod(CorporateActionBasisMethod.PERCENTAGE_ALLOCATION) // - .basisPercentageAllocation(LedgerLegRole.TARGET_SECURITY_LEG, "target-1", "main", - new BigDecimal("100")) // - .buildDetached().getEntry(); - - assertThat(posting(entry, LedgerLegRole.SOURCE_SECURITY_LEG, "source-1").getShares(), - is(Values.Share.factorize(10))); - } - - @Test - public void testCreatesMaturityWithPrincipal() - { - var fixture = fixture(); - var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // - .kind(CorporateActionKind.MATURITY) // - .date(DATE) // - .securityContext("context-1", "main", fixture.portfolio, fixture.source) // - .securityOut("source-1", "main", fixture.portfolio, fixture.source, - Values.Share.factorize(10)) // - .cash("cash-1", fixture.account, money(100)) // - .principal("principal-1", fixture.account, money(100)) // - .buildDetached().getEntry(); - - assertThat(posting(entry, LedgerLegRole.PRINCIPAL_REDEMPTION_LEG, "principal-1").getAmount(), - is(money(100).getAmount())); - } - - @Test - public void testSemanticEditUpdatesOnlySelectedRepeatedLeg() - { - var fixture = fixture(); - var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // - .kind(CorporateActionKind.SPIN_OFF) // - .date(DATE) // - .effectiveDate(LocalDate.of(2020, 9, 28)) // - .securityContext("context-1", "main", fixture.portfolio, fixture.source) // - .securityIn("target-1", "main", fixture.portfolio, fixture.target, Values.Share.factorize(5)) // - .securityIn("target-2", "main", fixture.portfolio, fixture.secondTarget, - Values.Share.factorize(2)) // - .buildAndAdd().getEntry(); - - LedgerCorporateActionEditSupport.mutatePosting(fixture.client, entry, LedgerLegRole.TARGET_SECURITY_LEG, - "target-2", "main", posting -> posting.setShares(Values.Share.factorize(8))); - - var liveEntry = fixture.client.getLedger().getEntries().get(0); - assertThat(posting(liveEntry, LedgerLegRole.TARGET_SECURITY_LEG, "target-1").getShares(), - is(Values.Share.factorize(5))); - assertThat(posting(liveEntry, LedgerLegRole.TARGET_SECURITY_LEG, "target-2").getShares(), - is(Values.Share.factorize(8))); - } - - @Test - public void testSecurityWithoutPortfolioFailsValidation() - { - var fixture = fixture(); - var exception = assertThrows(LedgerNativeEntryAssemblyException.class, - () -> LedgerNativeEntryAssembler.corporateAction(fixture.client) // - .kind(CorporateActionKind.CASH_DISTRIBUTION) // - .date(DATE) // - .securityContext("context-1", "main", null, fixture.source) // - .cash("cash-1", fixture.account, money(15)) // - .buildDetached()); - - assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.STRUCTURAL_VALIDATION_FAILED)); - } - - @Test - public void testFluentCreatedEntryCanBeDeletedThroughNativeMutationContext() - { - var fixture = fixture(); - var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // - .kind(CorporateActionKind.SPIN_OFF) // - .date(DATE) // - .effectiveDate(LocalDate.of(2020, 9, 28)) // - .securityContext("context-1", "main", fixture.portfolio, fixture.source) // - .securityIn("target-1", "main", fixture.portfolio, fixture.target, Values.Share.factorize(5)) // - .cash("cash-1", fixture.account, money(3)) // - .buildAndAdd().getEntry(); - - assertThat(fixture.client.getLedger().getEntries().size(), is(1)); - - new LedgerMutationContext(fixture.client).removeEntry(entry); - - assertThat(fixture.client.getLedger().getEntries().size(), is(0)); - assertTrue(fixture.account.getTransactions().isEmpty()); - assertTrue(fixture.portfolio.getTransactions().isEmpty()); - } - - private static LedgerPosting posting(name.abuchen.portfolio.model.ledger.LedgerEntry entry, LedgerLegRole role, - String localKey) - { - return LedgerCorporateActionEditSupport.postingBySemanticKey(entry, role, localKey, null); - } - - private static Money money(long amount) - { - return Money.of(CurrencyUnit.EUR, Values.Amount.factorize(amount)); - } - - private static Fixture fixture() - { - var client = new Client(); - var account = new Account(); - var portfolio = new Portfolio(); - var source = new Security("Source AG", CurrencyUnit.EUR); - var target = new Security("Target AG", CurrencyUnit.EUR); - var secondTarget = new Security("Second Target AG", CurrencyUnit.EUR); - - account.setName("Cash"); - account.setCurrencyCode(CurrencyUnit.EUR); - portfolio.setName("Portfolio"); - - client.addAccount(account); - client.addPortfolio(portfolio); - client.addSecurity(source); - client.addSecurity(target); - client.addSecurity(secondTarget); - - return new Fixture(client, account, portfolio, source, target, secondTarget); - } - - private record Fixture(Client client, Account account, Portfolio portfolio, Security source, Security target, - Security secondTarget) - { - } -} From 37648ec0e3d879bb40e730f8368d973bde96f4dc Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Sun, 5 Jul 2026 21:57:59 +0200 Subject: [PATCH 66/68] Further prune redundant Ledger corporate action tests Reduces the corporate-action projection-family tests to representative edge cases for repeated semantic keys, grouping, non-projecting details, standalone fee movement, and mixed open movement projection. The all-kind fluent end-to-end suite now carries the per-action happy-path workflow, so the projection tests no longer repeat each historical implementation slice. Production code, registry definitions, persistence schemas, projection behavior, legacy transaction types, UI/import/report/tax/FIFO/performance, and correction/reversal behavior are intentionally unchanged. --- ...dgerCorporateActionCashProjectionTest.java | 111 +---------- ...ctionConversionExchangeProjectionTest.java | 141 -------------- ...orateActionOpenMovementProjectionTest.java | 103 ---------- ...rporateActionRedemptionProjectionTest.java | 126 ------------ ...rporateActionSecurityInProjectionTest.java | 180 +----------------- 5 files changed, 6 insertions(+), 655 deletions(-) diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerCorporateActionCashProjectionTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerCorporateActionCashProjectionTest.java index b6e197d994..458a7952bf 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerCorporateActionCashProjectionTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerCorporateActionCashProjectionTest.java @@ -3,7 +3,6 @@ import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; @@ -15,20 +14,13 @@ import org.junit.Test; 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.Security; -import name.abuchen.portfolio.model.Transaction.Unit; -import name.abuchen.portfolio.model.ledger.LedgerMutationContext; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; -import name.abuchen.portfolio.model.ledger.configuration.CorporateActionBasisStatus; import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; import name.abuchen.portfolio.model.ledger.configuration.CorporateActionKind; -import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; import name.abuchen.portfolio.model.ledger.nativeentry.LedgerNativeEntryAssembler; -import name.abuchen.portfolio.model.ledger.nativeentry.LedgerNativeEntryAssemblyException; -import name.abuchen.portfolio.model.ledger.nativeentry.LedgerNativeEntryAssemblyIssue; import name.abuchen.portfolio.money.CurrencyUnit; import name.abuchen.portfolio.money.Money; import name.abuchen.portfolio.money.Values; @@ -38,41 +30,6 @@ public class LedgerCorporateActionCashProjectionTest { private static final LocalDateTime DATE = LocalDateTime.of(2026, 1, 2, 0, 0); - @Test - public void testCashDistributionMaterializesSingleCashProjection() - { - var fixture = fixture(); - var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // - .kind(CorporateActionKind.CASH_DISTRIBUTION) // - .date(DATE) // - .note("cash distribution") // - .source("issuer") // - .securityContext("context-1", "main", fixture.portfolio, fixture.security) // - .cash("cash-1", fixture.account, money(15)) // - .fee("fee-1", fixture.account, money(2), "cash-1") // - .tax("tax-1", fixture.account, money(1), "cash-1") // - .basis(CorporateActionBasisStatus.UNKNOWN) // - .buildAndAdd().getEntry(); - - LedgerProjectionService.materialize(fixture.client); - - var projection = ledgerBackedAccountProjection(fixture.account, LedgerProjectionRole.ACCOUNT, "cash-1"); - - assertThat(fixture.account.getTransactions().size(), is(1)); - assertThat(projection.getType(), is(AccountTransaction.Type.DIVIDENDS)); - assertThat(projection.getDateTime(), is(DATE)); - assertThat(projection.getNote(), is("cash distribution")); - assertThat(projection.getSource(), is("issuer")); - assertThat(projection.getAmount(), is(money(15).getAmount())); - assertSame(fixture.security, projection.getSecurity()); - assertThat(projection.getUnit(Unit.Type.FEE).orElseThrow().getAmount().getAmount(), is(money(2).getAmount())); - assertThat(projection.getUnit(Unit.Type.TAX).orElseThrow().getAmount().getAmount(), is(money(1).getAmount())); - assertThat(LedgerProjectionSupport.descriptors(entry).size(), is(1)); - assertFalse(LedgerProjectionSupport.descriptors(entry).stream() - .anyMatch(descriptor -> descriptor.getPrimaryPosting() - .getCorporateActionLeg() == CorporateActionLeg.SECURITY_CONTEXT)); - } - @Test public void testCashDistributionMaterializesRepeatedCashProjections() { @@ -109,71 +66,9 @@ public void testCashDistributionMaterializesRepeatedCashProjections() assertThat(runtimeProjectionIds(List.of(first, second)), is(accountDescriptors.stream().map(DerivedProjectionDescriptor::getRuntimeProjectionId) .collect(Collectors.toSet()))); - } - - @Test - public void testCouponPaymentMaterializesCashProjectionWithGroupedDetails() - { - var fixture = fixture(); - var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // - .kind(CorporateActionKind.COUPON_PAYMENT) // - .date(DATE) // - .securityContext("context-1", "main", fixture.portfolio, fixture.security) // - .cash("coupon-1", fixture.account, money(12)) // - .accruedInterest("interest-1", "coupon-1", fixture.account, money(12)) // - .fee("fee-1", fixture.account, money(2), "coupon-1") // - .tax("tax-1", fixture.account, money(1), "coupon-1") // - .buildAndAdd().getEntry(); - - LedgerProjectionService.materialize(fixture.client); - - var projection = ledgerBackedAccountProjection(fixture.account, LedgerProjectionRole.ACCOUNT, "coupon-1"); - - assertThat(fixture.account.getTransactions().size(), is(1)); - assertThat(projection.getType(), is(AccountTransaction.Type.INTEREST)); - assertThat(projection.getAmount(), is(money(12).getAmount())); - assertSame(fixture.security, projection.getSecurity()); - assertThat(projection.getUnit(Unit.Type.FEE).orElseThrow().getAmount().getAmount(), is(money(2).getAmount())); - assertThat(projection.getUnit(Unit.Type.TAX).orElseThrow().getAmount().getAmount(), is(money(1).getAmount())); - assertThat(LedgerProjectionSupport.descriptors(entry).size(), is(1)); - assertTrue(LedgerProjectionSupport.descriptor(entry, LedgerProjectionRole.ACCOUNT, "coupon-1") - .getUnitPostings().stream() - .noneMatch(posting -> posting.getType() == LedgerPostingType.ACCRUED_INTEREST)); - } - - @Test - public void testCouponPaymentDifferentInterestGroupIsRejectedBeforeProjection() - { - var fixture = fixture(); - var exception = assertThrows(LedgerNativeEntryAssemblyException.class, - () -> LedgerNativeEntryAssembler.corporateAction(fixture.client) // - .kind(CorporateActionKind.COUPON_PAYMENT) // - .date(DATE) // - .securityContext("context-1", "main", fixture.portfolio, fixture.security) // - .cash("coupon-1", fixture.account, money(12)) // - .accruedInterest("interest-1", "other", fixture.account, money(12)) // - .buildDetached()); - - assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.NATIVE_DEFINITION_VALIDATION_FAILED)); - } - - @Test - public void testDeletingCashDistributionRemovesCashProjection() - { - var fixture = fixture(); - var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // - .kind(CorporateActionKind.CASH_DISTRIBUTION) // - .date(DATE) // - .securityContext("context-1", "main", fixture.portfolio, fixture.security) // - .cash("cash-1", fixture.account, money(15)) // - .buildAndAdd().getEntry(); - - assertThat(fixture.account.getTransactions().size(), is(1)); - - new LedgerMutationContext(fixture.client).removeEntry(entry); - - assertTrue(fixture.account.getTransactions().isEmpty()); - assertTrue(fixture.portfolio.getTransactions().isEmpty()); + assertFalse(LedgerProjectionSupport.descriptors(entry).stream() + .anyMatch(descriptor -> descriptor.getPrimaryPosting() + .getCorporateActionLeg() == CorporateActionLeg.SECURITY_CONTEXT)); } private LedgerBackedAccountTransaction ledgerBackedAccountProjection(Account account, LedgerProjectionRole role, diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerCorporateActionConversionExchangeProjectionTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerCorporateActionConversionExchangeProjectionTest.java index 49e42135ff..f72aa7bad8 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerCorporateActionConversionExchangeProjectionTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerCorporateActionConversionExchangeProjectionTest.java @@ -3,11 +3,9 @@ import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.Assert.assertFalse; -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.LocalDateTime; import java.util.List; import java.util.Set; @@ -16,16 +14,12 @@ import org.junit.Test; 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.Unit; import name.abuchen.portfolio.model.ledger.LedgerMutationContext; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; -import name.abuchen.portfolio.model.ledger.configuration.CorporateActionBasisMethod; -import name.abuchen.portfolio.model.ledger.configuration.CorporateActionBasisStatus; import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; import name.abuchen.portfolio.model.ledger.configuration.CorporateActionKind; import name.abuchen.portfolio.model.ledger.configuration.LedgerLegRole; @@ -41,86 +35,6 @@ public class LedgerCorporateActionConversionExchangeProjectionTest { private static final LocalDateTime DATE = LocalDateTime.of(2026, 1, 2, 0, 0); - @Test - public void testConversionMaterializesSourceAndTargetProjections() - { - var fixture = fixture(); - var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // - .kind(CorporateActionKind.CONVERSION) // - .date(DATE) // - .note("bond conversion") // - .securityContext("context-1", "main", fixture.portfolio, fixture.sourceSecurity) // - .securityOut("source-1", "main", fixture.portfolio, fixture.sourceSecurity, shares(10)) // - .securityIn("target-1", "main", fixture.portfolio, fixture.targetSecurity, shares(5)) // - .basis(CorporateActionBasisStatus.PROVIDED) // - .basisMethod(CorporateActionBasisMethod.PERCENTAGE_ALLOCATION) // - .basisPercentageAllocation(LedgerLegRole.TARGET_SECURITY_LEG, "target-1", "main", - BigDecimal.valueOf(100)) // - .buildAndAdd().getEntry(); - - LedgerProjectionService.materialize(fixture.client); - - var sourceProjection = ledgerBackedPortfolioProjection(fixture.portfolio, - LedgerProjectionRole.DELIVERY_OUTBOUND, "source-1"); - var targetProjection = ledgerBackedPortfolioProjection(fixture.portfolio, - LedgerProjectionRole.NEW_SECURITY_LEG, "target-1"); - - assertThat(sourceProjection.getType(), is(PortfolioTransaction.Type.DELIVERY_OUTBOUND)); - assertThat(sourceProjection.getDateTime(), is(DATE)); - assertThat(sourceProjection.getNote(), is("bond conversion")); - assertSame(fixture.sourceSecurity, sourceProjection.getSecurity()); - assertThat(sourceProjection.getShares(), is(shares(10))); - - assertThat(targetProjection.getType(), is(PortfolioTransaction.Type.DELIVERY_INBOUND)); - assertSame(fixture.targetSecurity, targetProjection.getSecurity()); - assertThat(targetProjection.getShares(), is(shares(5))); - - assertThat(LedgerProjectionSupport.descriptors(entry).size(), is(2)); - assertFalse(hasPrimaryDescriptor(entry, CorporateActionLeg.SECURITY_CONTEXT)); - } - - @Test - public void testExchangeMaterializesTargetAndOptionalCashProjections() - { - var fixture = fixture(); - var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // - .kind(CorporateActionKind.EXCHANGE) // - .date(DATE) // - .securityContext("context-1", "main", fixture.portfolio, fixture.sourceSecurity) // - .securityOut("source-1", "main", fixture.portfolio, fixture.sourceSecurity, shares(10)) // - .securityIn("target-1", "main", fixture.portfolio, fixture.targetSecurity, shares(6)) // - .securityIn("target-2", "main", fixture.secondPortfolio, fixture.secondTargetSecurity, - shares(4)) // - .cash("cash-1", "cash-1", fixture.account, money(9)) // - .basis(CorporateActionBasisStatus.PROVIDED) // - .basisMethod(CorporateActionBasisMethod.PERCENTAGE_ALLOCATION) // - .basisPercentageAllocation(LedgerLegRole.TARGET_SECURITY_LEG, "target-1", "main", - BigDecimal.valueOf(60)) // - .basisPercentageAllocation(LedgerLegRole.TARGET_SECURITY_LEG, "target-2", "main", - BigDecimal.valueOf(40)) // - .buildAndAdd().getEntry(); - - LedgerProjectionService.materialize(fixture.client); - - assertThat(ledgerBackedPortfolioProjection(fixture.portfolio, LedgerProjectionRole.DELIVERY_OUTBOUND, - "source-1").getShares(), is(shares(10))); - assertThat(ledgerBackedPortfolioProjection(fixture.portfolio, LedgerProjectionRole.NEW_SECURITY_LEG, - "target-1").getShares(), is(shares(6))); - assertThat(ledgerBackedPortfolioProjection(fixture.secondPortfolio, LedgerProjectionRole.NEW_SECURITY_LEG, - "target-2").getShares(), is(shares(4))); - - var cashProjection = ledgerBackedAccountProjection(fixture.account, LedgerProjectionRole.CASH_COMPENSATION, - "cash-1"); - assertThat(cashProjection.getType(), is(AccountTransaction.Type.DEPOSIT)); - assertThat(cashProjection.getAmount(), is(money(9).getAmount())); - - assertThrows(IllegalArgumentException.class, - () -> new LedgerProjectionFactory().createProjection(entry, LedgerProjectionRole.NEW_SECURITY_LEG)); - assertThat(new LedgerProjectionFactory() - .createProjection(entry, LedgerProjectionRole.NEW_SECURITY_LEG, "target-2").getUUID(), - is(entry.getUUID() + ":NEW_SECURITY_LEG:target-2")); - } - @Test public void testExchangeMaterializesRepeatedSourceTargetAndCashProjections() { @@ -199,30 +113,6 @@ public void testExchangeMaterializesRepeatedSourceTargetAndCashProjections() .collect(Collectors.toSet()))); } - @Test - public void testConversionKeepsOptionalAccruedInterestAsNonProjectingDetail() - { - var fixture = fixture(); - var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // - .kind(CorporateActionKind.CONVERSION) // - .date(DATE) // - .securityContext("context-1", "main", fixture.portfolio, fixture.sourceSecurity) // - .securityOut("source-1", "main", fixture.portfolio, fixture.sourceSecurity, shares(10)) // - .securityIn("target-1", "main", fixture.portfolio, fixture.targetSecurity, shares(5)) // - .cash("cash-1", "cash-1", fixture.account, money(9)) // - .accruedInterest("interest-1", "cash-1", fixture.account, money(5)) // - .buildAndAdd().getEntry(); - - LedgerProjectionService.materialize(fixture.client); - - assertThat(ledgerBackedAccountProjection(fixture.account, LedgerProjectionRole.CASH_COMPENSATION, "cash-1") - .getAmount(), is(money(9).getAmount())); - assertThat(LedgerProjectionSupport.descriptors(entry).size(), is(3)); - assertFalse(hasPrimaryDescriptor(entry, CorporateActionLeg.ACCRUED_INTEREST)); - assertThat(LedgerCorporateActionEditSupport.postingBySemanticKey(entry, LedgerLegRole.ACCRUED_INTEREST_LEG, - "interest-1", "cash-1").getAmount(), is(money(5).getAmount())); - } - @Test public void testConversionAttachesFeeTaxByMatchingGroup() { @@ -249,37 +139,6 @@ public void testConversionAttachesFeeTaxByMatchingGroup() .getUnitPostings().size(), is(2)); } - @Test - public void testDeletingConversionRemovesProjectedTransactions() - { - var fixture = fixture(); - var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // - .kind(CorporateActionKind.CONVERSION) // - .date(DATE) // - .securityContext("context-1", "main", fixture.portfolio, fixture.sourceSecurity) // - .securityOut("source-1", "main", fixture.portfolio, fixture.sourceSecurity, shares(10)) // - .securityIn("target-1", "main", fixture.portfolio, fixture.targetSecurity, shares(5)) // - .cash("cash-1", "cash-1", fixture.account, money(9)) // - .buildAndAdd().getEntry(); - - LedgerProjectionService.materialize(fixture.client); - LedgerProjectionService.materialize(fixture.client); - - assertThat(fixture.portfolio.getTransactions().size(), is(2)); - assertThat(fixture.account.getTransactions().size(), is(1)); - - new LedgerMutationContext(fixture.client).removeEntry(entry); - - assertTrue(fixture.portfolio.getTransactions().isEmpty()); - assertTrue(fixture.account.getTransactions().isEmpty()); - } - - private boolean hasPrimaryDescriptor(name.abuchen.portfolio.model.ledger.LedgerEntry entry, CorporateActionLeg leg) - { - return LedgerProjectionSupport.descriptors(entry).stream().map(DerivedProjectionDescriptor::getPrimaryPosting) - .anyMatch(posting -> posting.getCorporateActionLeg() == leg); - } - private LedgerBackedPortfolioTransaction ledgerBackedPortfolioProjection(Portfolio portfolio, LedgerProjectionRole role, String semanticInstanceKey) { diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerCorporateActionOpenMovementProjectionTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerCorporateActionOpenMovementProjectionTest.java index 80efae1a41..289a9580c2 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerCorporateActionOpenMovementProjectionTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerCorporateActionOpenMovementProjectionTest.java @@ -3,7 +3,6 @@ import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; @@ -20,7 +19,6 @@ import name.abuchen.portfolio.model.Portfolio; import name.abuchen.portfolio.model.PortfolioTransaction; import name.abuchen.portfolio.model.Security; -import name.abuchen.portfolio.model.Transaction.Unit; import name.abuchen.portfolio.model.ledger.LedgerMutationContext; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; @@ -39,54 +37,6 @@ public class LedgerCorporateActionOpenMovementProjectionTest { private static final LocalDateTime DATE = LocalDateTime.of(2026, 1, 2, 0, 0); - @Test - public void testDefaultedInterestMaterializesCashMovement() - { - var fixture = fixture(); - var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // - .kind(CorporateActionKind.DEFAULTED_INTEREST) // - .date(DATE) // - .securityContext("context-1", "cash-1", fixture.portfolio, fixture.sourceSecurity) // - .cash("cash-1", "cash-1", fixture.account, money(12)) // - .fee("fee-1", fixture.account, money(2), "cash-1") // - .tax("tax-1", fixture.account, money(1), "cash-1") // - .buildAndAdd().getEntry(); - - LedgerProjectionService.materialize(fixture.client); - - var projection = ledgerBackedAccountProjection(fixture.account, LedgerProjectionRole.ACCOUNT, "cash-1"); - - assertThat(projection.getType(), is(AccountTransaction.Type.INTEREST)); - assertThat(projection.getAmount(), is(money(12).getAmount())); - assertSame(fixture.sourceSecurity, projection.getSecurity()); - assertThat(projection.getUnit(Unit.Type.FEE).orElseThrow().getAmount().getAmount(), is(money(2).getAmount())); - assertThat(projection.getUnit(Unit.Type.TAX).orElseThrow().getAmount().getAmount(), is(money(1).getAmount())); - assertThat(LedgerProjectionSupport.descriptors(entry).size(), is(1)); - assertFalse(hasPrimaryDescriptor(entry, CorporateActionLeg.SECURITY_CONTEXT)); - } - - @Test - public void testDefaultedInterestMaterializesClaimSecurityMovement() - { - var fixture = fixture(); - - LedgerNativeEntryAssembler.corporateAction(fixture.client) // - .kind(CorporateActionKind.DEFAULTED_INTEREST) // - .date(DATE) // - .securityContext("context-1", "claim-1", fixture.portfolio, fixture.sourceSecurity) // - .securityIn("claim-1", "claim-1", fixture.portfolio, fixture.claimSecurity, shares(3)) // - .buildAndAdd(); - - LedgerProjectionService.materialize(fixture.client); - - var projection = ledgerBackedPortfolioProjection(fixture.portfolio, LedgerProjectionRole.NEW_SECURITY_LEG, - "claim-1"); - - assertThat(projection.getType(), is(PortfolioTransaction.Type.DELIVERY_INBOUND)); - assertSame(fixture.claimSecurity, projection.getSecurity()); - assertThat(projection.getShares(), is(shares(3))); - } - @Test public void testDefaultedInterestMaterializesStandaloneFeeMovement() { @@ -139,34 +89,6 @@ public void testRestructuringMaterializesMixedMovements() is(money(9).getAmount())); } - @Test - public void testDefaultMaterializesBookedMovementAndRejectsContextOnly() - { - var fixture = fixture(); - - LedgerNativeEntryAssembler.corporateAction(fixture.client) // - .kind(CorporateActionKind.DEFAULT) // - .date(DATE) // - .securityContext("context-1", "main", fixture.portfolio, fixture.sourceSecurity) // - .cash("cash-1", "cash-1", fixture.account, money(5)) // - .buildAndAdd(); - - LedgerProjectionService.materialize(fixture.client); - - assertThat(ledgerBackedAccountProjection(fixture.account, LedgerProjectionRole.ACCOUNT, "cash-1").getType(), - is(AccountTransaction.Type.DEPOSIT)); - - var exception = assertThrows(LedgerNativeEntryAssemblyException.class, - () -> LedgerNativeEntryAssembler.corporateAction(fixture.client) // - .kind(CorporateActionKind.DEFAULT) // - .date(DATE) // - .securityContext("context-only", "main", fixture.portfolio, - fixture.sourceSecurity) // - .buildDetached()); - - assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.NATIVE_DEFINITION_VALIDATION_FAILED)); - } - @Test public void testDefaultMaterializesRepeatedCashAndSecurityMovements() { @@ -220,31 +142,6 @@ public void testDefaultMaterializesRepeatedCashAndSecurityMovements() .collect(Collectors.toSet()))); } - @Test - public void testDeletingOpenMovementActionRemovesProjections() - { - var fixture = fixture(); - var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // - .kind(CorporateActionKind.RESTRUCTURING) // - .date(DATE) // - .securityContext("context-1", "main", fixture.portfolio, fixture.sourceSecurity) // - .securityOut("source-1", "main", fixture.portfolio, fixture.sourceSecurity, shares(10)) // - .securityIn("target-1", "main", fixture.portfolio, fixture.targetSecurity, shares(5)) // - .cash("cash-1", "main", fixture.account, money(9)) // - .buildAndAdd().getEntry(); - - LedgerProjectionService.materialize(fixture.client); - LedgerProjectionService.materialize(fixture.client); - - assertThat(fixture.portfolio.getTransactions().size(), is(2)); - assertThat(fixture.account.getTransactions().size(), is(1)); - - new LedgerMutationContext(fixture.client).removeEntry(entry); - - assertTrue(fixture.portfolio.getTransactions().isEmpty()); - assertTrue(fixture.account.getTransactions().isEmpty()); - } - private boolean hasPrimaryDescriptor(name.abuchen.portfolio.model.ledger.LedgerEntry entry, CorporateActionLeg leg) { return LedgerProjectionSupport.descriptors(entry).stream().map(DerivedProjectionDescriptor::getPrimaryPosting) diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerCorporateActionRedemptionProjectionTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerCorporateActionRedemptionProjectionTest.java index ca31dd406c..129a76b894 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerCorporateActionRedemptionProjectionTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerCorporateActionRedemptionProjectionTest.java @@ -78,77 +78,6 @@ public void testMaturityMaterializesSecurityOutAndCashProjection() assertFalse(hasPrimaryDescriptor(entry, CorporateActionLeg.PRINCIPAL)); } - @Test - public void testPartialRedemptionMaterializesSecurityOutAndCashProjection() - { - var fixture = fixture(); - - LedgerNativeEntryAssembler.corporateAction(fixture.client) // - .kind(CorporateActionKind.PARTIAL_REDEMPTION) // - .date(DATE) // - .securityContext("context-1", "redemption-1", fixture.portfolio, fixture.bond) // - .securityOut("source-1", "redemption-1", fixture.portfolio, fixture.bond, shares(4)) // - .cash("cash-1", "redemption-1", fixture.account, money(40)) // - .principal("principal-1", "redemption-1", fixture.account, money(40)) // - .basis(CorporateActionBasisStatus.UNKNOWN) // - .buildAndAdd(); - - LedgerProjectionService.materialize(fixture.client); - - var portfolioProjection = ledgerBackedPortfolioProjection(fixture.portfolio, - LedgerProjectionRole.DELIVERY_OUTBOUND, "source-1"); - var accountProjection = ledgerBackedAccountProjection(fixture.account, LedgerProjectionRole.ACCOUNT, "cash-1"); - - assertThat(portfolioProjection.getType(), is(PortfolioTransaction.Type.DELIVERY_OUTBOUND)); - assertThat(portfolioProjection.getShares(), is(shares(4))); - assertThat(accountProjection.getType(), is(AccountTransaction.Type.DEPOSIT)); - assertThat(accountProjection.getAmount(), is(money(40).getAmount())); - } - - @Test - public void testCallMaterializesSecurityOutAndCashProjection() - { - var fixture = fixture(); - - LedgerNativeEntryAssembler.corporateAction(fixture.client) // - .kind(CorporateActionKind.CALL) // - .date(DATE) // - .securityContext("context-1", "call-1", fixture.portfolio, fixture.bond) // - .securityOut("source-1", "call-1", fixture.portfolio, fixture.bond, shares(8)) // - .cash("cash-1", "call-1", fixture.account, money(80)) // - .principal("principal-1", "call-1", fixture.account, money(80)) // - .buildAndAdd(); - - LedgerProjectionService.materialize(fixture.client); - - assertThat(ledgerBackedPortfolioProjection(fixture.portfolio, LedgerProjectionRole.DELIVERY_OUTBOUND, - "source-1").getShares(), is(shares(8))); - assertThat(ledgerBackedAccountProjection(fixture.account, LedgerProjectionRole.ACCOUNT, "cash-1").getAmount(), - is(money(80).getAmount())); - } - - @Test - public void testPutMaterializesSecurityOutAndCashProjection() - { - var fixture = fixture(); - - LedgerNativeEntryAssembler.corporateAction(fixture.client) // - .kind(CorporateActionKind.PUT) // - .date(DATE) // - .securityContext("context-1", "put-1", fixture.portfolio, fixture.bond) // - .securityOut("source-1", "put-1", fixture.portfolio, fixture.bond, shares(3)) // - .cash("cash-1", "put-1", fixture.account, money(30)) // - .principal("principal-1", "put-1", fixture.account, money(30)) // - .buildAndAdd(); - - LedgerProjectionService.materialize(fixture.client); - - assertThat(ledgerBackedPortfolioProjection(fixture.portfolio, LedgerProjectionRole.DELIVERY_OUTBOUND, - "source-1").getType(), is(PortfolioTransaction.Type.DELIVERY_OUTBOUND)); - assertThat(ledgerBackedAccountProjection(fixture.account, LedgerProjectionRole.ACCOUNT, "cash-1").getType(), - is(AccountTransaction.Type.DEPOSIT)); - } - @Test public void testMaturityMaterializesRepeatedSecurityOutAndCashProjections() { @@ -213,36 +142,6 @@ public void testMaturityMaterializesRepeatedSecurityOutAndCashProjections() .collect(Collectors.toSet()))); } - @Test - public void testMaturityKeepsOptionalAccruedInterestAsNonProjectingDetail() - { - var fixture = fixture(); - var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // - .kind(CorporateActionKind.MATURITY) // - .date(DATE) // - .securityContext("context-1", "redemption-1", fixture.portfolio, fixture.bond) // - .securityOut("source-1", "redemption-1", fixture.portfolio, fixture.bond, shares(10)) // - .cash("cash-1", "redemption-1", fixture.account, money(100)) // - .principal("principal-1", "redemption-1", fixture.account, money(100)) // - .accruedInterest("interest-1", "redemption-1", fixture.account, money(5)) // - .fee("fee-1", fixture.account, money(2), "redemption-1") // - .tax("tax-1", fixture.account, money(1), "redemption-1") // - .buildAndAdd().getEntry(); - - LedgerProjectionService.materialize(fixture.client); - - var accountProjection = ledgerBackedAccountProjection(fixture.account, LedgerProjectionRole.ACCOUNT, "cash-1"); - - assertThat(accountProjection.getUnit(Unit.Type.FEE).orElseThrow().getAmount().getAmount(), - is(money(2).getAmount())); - assertThat(accountProjection.getUnit(Unit.Type.TAX).orElseThrow().getAmount().getAmount(), - is(money(1).getAmount())); - assertThat(LedgerProjectionSupport.descriptors(entry).size(), is(2)); - assertFalse(hasPrimaryDescriptor(entry, CorporateActionLeg.ACCRUED_INTEREST)); - assertThat(LedgerCorporateActionEditSupport.postingBySemanticKey(entry, LedgerLegRole.ACCRUED_INTEREST_LEG, - "interest-1", "redemption-1").getAmount(), is(money(5).getAmount())); - } - @Test public void testMaturityDoesNotAttachMismatchedFeeTaxOrAccruedInterest() { @@ -270,31 +169,6 @@ public void testMaturityDoesNotAttachMismatchedFeeTaxOrAccruedInterest() assertFalse(hasPrimaryDescriptor(entry, CorporateActionLeg.ACCRUED_INTEREST)); } - @Test - public void testDeletingMaturityRemovesRedemptionProjections() - { - var fixture = fixture(); - var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // - .kind(CorporateActionKind.MATURITY) // - .date(DATE) // - .securityContext("context-1", "redemption-1", fixture.portfolio, fixture.bond) // - .securityOut("source-1", "redemption-1", fixture.portfolio, fixture.bond, shares(10)) // - .cash("cash-1", "redemption-1", fixture.account, money(100)) // - .principal("principal-1", "redemption-1", fixture.account, money(100)) // - .buildAndAdd().getEntry(); - - LedgerProjectionService.materialize(fixture.client); - LedgerProjectionService.materialize(fixture.client); - - assertThat(fixture.portfolio.getTransactions().size(), is(1)); - assertThat(fixture.account.getTransactions().size(), is(1)); - - new LedgerMutationContext(fixture.client).removeEntry(entry); - - assertTrue(fixture.portfolio.getTransactions().isEmpty()); - assertTrue(fixture.account.getTransactions().isEmpty()); - } - private boolean hasPrimaryDescriptor(name.abuchen.portfolio.model.ledger.LedgerEntry entry, CorporateActionLeg leg) { return LedgerProjectionSupport.descriptors(entry).stream().map(DerivedProjectionDescriptor::getPrimaryPosting) diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerCorporateActionSecurityInProjectionTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerCorporateActionSecurityInProjectionTest.java index 22dbc8329f..23fe41b97e 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerCorporateActionSecurityInProjectionTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerCorporateActionSecurityInProjectionTest.java @@ -5,7 +5,6 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; import java.time.LocalDateTime; import java.util.List; @@ -15,18 +14,13 @@ import org.junit.Test; 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.Unit; -import name.abuchen.portfolio.model.ledger.LedgerMutationContext; import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; -import name.abuchen.portfolio.model.ledger.configuration.CorporateActionBasisStatus; import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; import name.abuchen.portfolio.model.ledger.configuration.CorporateActionKind; -import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; import name.abuchen.portfolio.model.ledger.nativeentry.LedgerNativeEntryAssembler; import name.abuchen.portfolio.money.CurrencyUnit; import name.abuchen.portfolio.money.Money; @@ -37,36 +31,6 @@ public class LedgerCorporateActionSecurityInProjectionTest { private static final LocalDateTime DATE = LocalDateTime.of(2026, 1, 2, 0, 0); - @Test - public void testStockDividendMaterializesSingleInboundProjection() - { - var fixture = fixture(); - var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // - .kind(CorporateActionKind.STOCK_DIVIDEND) // - .date(DATE) // - .note("stock dividend") // - .securityContext("context-1", "main", fixture.portfolio, fixture.sourceSecurity) // - .securityIn("target-1", fixture.portfolio, fixture.targetSecurity, shares(5)) // - .basis(CorporateActionBasisStatus.UNKNOWN) // - .buildAndAdd().getEntry(); - - LedgerProjectionService.materialize(fixture.client); - - var projection = ledgerBackedPortfolioProjection(fixture.portfolio, LedgerProjectionRole.NEW_SECURITY_LEG, - "target-1"); - - assertThat(fixture.portfolio.getTransactions().size(), is(1)); - assertThat(projection.getType(), is(PortfolioTransaction.Type.DELIVERY_INBOUND)); - assertThat(projection.getDateTime(), is(DATE)); - assertThat(projection.getNote(), is("stock dividend")); - assertSame(fixture.targetSecurity, projection.getSecurity()); - assertThat(projection.getShares(), is(shares(5))); - assertThat(LedgerProjectionSupport.descriptors(entry).size(), is(1)); - assertFalse(LedgerProjectionSupport.descriptors(entry).stream() - .anyMatch(descriptor -> descriptor.getPrimaryPosting() - .getCorporateActionLeg() == CorporateActionLeg.SECURITY_CONTEXT)); - } - @Test public void testStockDividendMaterializesRepeatedInboundProjections() { @@ -108,130 +72,9 @@ public void testStockDividendMaterializesRepeatedInboundProjections() assertThat(runtimeProjectionIds(List.of(first, second)), is(securityDescriptors.stream().map(DerivedProjectionDescriptor::getRuntimeProjectionId) .collect(Collectors.toSet()))); - } - - @Test - public void testBonusIssueMaterializesInboundProjection() - { - var fixture = fixture(); - - LedgerNativeEntryAssembler.corporateAction(fixture.client) // - .kind(CorporateActionKind.BONUS_ISSUE) // - .date(DATE) // - .securityContext("context-1", "main", fixture.portfolio, fixture.sourceSecurity) // - .securityIn("target-1", fixture.portfolio, fixture.targetSecurity, shares(4)) // - .buildAndAdd(); - - LedgerProjectionService.materialize(fixture.client); - - var projection = ledgerBackedPortfolioProjection(fixture.portfolio, LedgerProjectionRole.NEW_SECURITY_LEG, - "target-1"); - - assertThat(projection.getType(), is(PortfolioTransaction.Type.DELIVERY_INBOUND)); - assertSame(fixture.targetSecurity, projection.getSecurity()); - assertThat(projection.getShares(), is(shares(4))); - } - - @Test - public void testRightsDistributionMaterializesInboundProjection() - { - var fixture = fixture(); - - LedgerNativeEntryAssembler.corporateAction(fixture.client) // - .kind(CorporateActionKind.RIGHTS_DISTRIBUTION) // - .date(DATE) // - .securityContext("context-1", "main", fixture.portfolio, fixture.sourceSecurity) // - .rightIn("right-1", fixture.portfolio, fixture.rightSecurity, shares(9)) // - .buildAndAdd(); - - LedgerProjectionService.materialize(fixture.client); - - var projection = ledgerBackedPortfolioProjection(fixture.portfolio, LedgerProjectionRole.NEW_SECURITY_LEG, - "right-1"); - - assertThat(projection.getType(), is(PortfolioTransaction.Type.DELIVERY_INBOUND)); - assertSame(fixture.rightSecurity, projection.getSecurity()); - assertThat(projection.getShares(), is(shares(9))); - } - - @Test - public void testPikInterestMaterializesInboundProjectionWithGroupedInterestDetail() - { - var fixture = fixture(); - var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // - .kind(CorporateActionKind.PIK_INTEREST) // - .date(DATE) // - .securityContext("context-1", "pik-1", fixture.portfolio, fixture.sourceSecurity) // - .securityIn("target-1", "pik-1", fixture.portfolio, fixture.targetSecurity, shares(6)) // - .accruedInterest("interest-1", "pik-1", fixture.account, money(12)) // - .buildAndAdd().getEntry(); - - LedgerProjectionService.materialize(fixture.client); - - var projection = ledgerBackedPortfolioProjection(fixture.portfolio, LedgerProjectionRole.NEW_SECURITY_LEG, - "target-1"); - - assertThat(projection.getType(), is(PortfolioTransaction.Type.DELIVERY_INBOUND)); - assertThat(projection.getShares(), is(shares(6))); - assertSame(fixture.targetSecurity, projection.getSecurity()); - assertThat(LedgerProjectionSupport.descriptors(entry).size(), is(1)); - assertFalse(LedgerProjectionSupport.descriptors(entry).stream().map(DerivedProjectionDescriptor::getPrimaryPosting) - .anyMatch(posting -> posting.getType() == LedgerPostingType.ACCRUED_INTEREST)); - assertFalse(LedgerProjectionSupport.descriptors(entry).stream().map(DerivedProjectionDescriptor::getPrimaryPosting) - .anyMatch(posting -> posting.getCorporateActionLeg() == CorporateActionLeg.SECURITY_CONTEXT)); - } - - @Test - public void testSecurityInActionMaterializesOptionalCashCompensationProjection() - { - var fixture = fixture(); - - LedgerNativeEntryAssembler.corporateAction(fixture.client) // - .kind(CorporateActionKind.STOCK_DIVIDEND) // - .date(DATE) // - .securityContext("context-1", "main", fixture.portfolio, fixture.sourceSecurity) // - .securityIn("target-1", fixture.portfolio, fixture.targetSecurity, shares(5)) // - .cash("cash-1", fixture.account, money(7)) // - .fee("fee-1", fixture.account, money(2), "cash-1") // - .tax("tax-1", fixture.account, money(1), "cash-1") // - .buildAndAdd(); - - LedgerProjectionService.materialize(fixture.client); - - var securityProjection = ledgerBackedPortfolioProjection(fixture.portfolio, - LedgerProjectionRole.NEW_SECURITY_LEG, "target-1"); - var cashProjection = ledgerBackedAccountProjection(fixture.account, LedgerProjectionRole.CASH_COMPENSATION, - "cash-1"); - - assertThat(securityProjection.getType(), is(PortfolioTransaction.Type.DELIVERY_INBOUND)); - assertThat(cashProjection.getType(), is(AccountTransaction.Type.DEPOSIT)); - assertThat(cashProjection.getAmount(), is(money(7).getAmount())); - assertThat(cashProjection.getUnit(Unit.Type.FEE).orElseThrow().getAmount().getAmount(), is(money(2).getAmount())); - assertThat(cashProjection.getUnit(Unit.Type.TAX).orElseThrow().getAmount().getAmount(), is(money(1).getAmount())); - } - - @Test - public void testDeletingSecurityInActionRemovesProjections() - { - var fixture = fixture(); - var entry = LedgerNativeEntryAssembler.corporateAction(fixture.client) // - .kind(CorporateActionKind.STOCK_DIVIDEND) // - .date(DATE) // - .securityContext("context-1", "main", fixture.portfolio, fixture.sourceSecurity) // - .securityIn("target-1", fixture.portfolio, fixture.targetSecurity, shares(5)) // - .cash("cash-1", fixture.account, money(7)) // - .buildAndAdd().getEntry(); - - LedgerProjectionService.materialize(fixture.client); - LedgerProjectionService.materialize(fixture.client); - - assertThat(fixture.portfolio.getTransactions().size(), is(1)); - assertThat(fixture.account.getTransactions().size(), is(1)); - - new LedgerMutationContext(fixture.client).removeEntry(entry); - - assertTrue(fixture.portfolio.getTransactions().isEmpty()); - assertTrue(fixture.account.getTransactions().isEmpty()); + assertFalse(LedgerProjectionSupport.descriptors(entry).stream() + .anyMatch(descriptor -> descriptor.getPrimaryPosting() + .getCorporateActionLeg() == CorporateActionLeg.SECURITY_CONTEXT)); } private LedgerBackedPortfolioTransaction ledgerBackedPortfolioProjection(Portfolio portfolio, @@ -246,18 +89,6 @@ private LedgerBackedPortfolioTransaction ledgerBackedPortfolioProjection(Portfol .findFirst().orElseThrow(); } - private LedgerBackedAccountTransaction ledgerBackedAccountProjection(Account account, LedgerProjectionRole role, - String semanticInstanceKey) - { - return account.getTransactions().stream() // - .filter(LedgerBackedAccountTransaction.class::isInstance) // - .map(LedgerBackedAccountTransaction.class::cast) // - .filter(transaction -> transaction.getLedgerProjectionRole() == role) // - .filter(transaction -> transaction.getLedgerProjectionDescriptor().getSemanticInstanceKey() - .filter(semanticInstanceKey::equals).isPresent()) // - .findFirst().orElseThrow(); - } - private Set semanticKeys(List descriptors) { return descriptors.stream().map(descriptor -> descriptor.getSemanticInstanceKey().orElseThrow()) @@ -274,11 +105,6 @@ private static long shares(long shares) return Values.Share.factorize(shares); } - private static Money money(long amount) - { - return Money.of(CurrencyUnit.EUR, Values.Amount.factorize(amount)); - } - private static Fixture fixture() { var client = new Client(); From e983e117c0766c5c6824974df8f8a102f83d1db4 Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Sun, 5 Jul 2026 22:15:21 +0200 Subject: [PATCH 67/68] Reduce redundant Ledger test coverage Removes historical Ledger slice tests whose happy-path coverage is now exercised by the fluent corporate-action end-to-end suite, compact projection tests, and persistence tests. This reduces maintenance noise while keeping validator, persistence, projection edge, UI guardrail, diagnostic, and service write-path coverage focused on unique contracts. Production code, registry definitions, schemas, projection behavior, legacy transaction types, UI/import/report/tax/FIFO/performance, and correction/reversal behavior are intentionally unchanged. --- .../ledger/LedgerSpinOffScenarioTest.java | 307 ------------ ...LedgerSpinOffXmlDefinitionCheckerTest.java | 454 ------------------ .../LedgerNativeEntryAssemblerTest.java | 213 -------- ...erivedProjectionDescriptorServiceTest.java | 49 -- 4 files changed, 1023 deletions(-) delete mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffXmlDefinitionCheckerTest.java diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffScenarioTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffScenarioTest.java index 7093467c84..8edcd2e99f 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffScenarioTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffScenarioTest.java @@ -1,6 +1,5 @@ package name.abuchen.portfolio.model.ledger; -import static org.hamcrest.CoreMatchers.instanceOf; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.Assert.assertFalse; @@ -9,8 +8,6 @@ import static org.junit.Assert.assertTrue; import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.lang.reflect.InvocationTargetException; import java.math.BigDecimal; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -76,103 +73,6 @@ public class LedgerSpinOffScenarioTest private static final LocalDateTime BUY_DATE = LocalDateTime.of(2020, 1, 2, 0, 0); private static final Instant UPDATED_AT = Instant.parse("2026-06-15T08:00:00Z"); - /** - * Checks the Ledger-V6 scenario: spin off uses ledger native targeted policy. - * The result must keep ledger truth and visible runtime rows consistent. - * This protects against duplicate truth or partial mutation. - */ - @Test - public void testSpinOffUsesLedgerNativeTargetedPolicy() - { - assertFalse(LedgerEntryType.CORPORATE_ACTION.isLegacyFixedShape()); - assertTrue(LedgerEntryType.CORPORATE_ACTION.isLedgerNativeTargeted()); - assertTrue(LedgerEntryType.CORPORATE_ACTION.requiresTargetedDerivedDescriptors()); - assertTrue(LedgerEntryType.CORPORATE_ACTION.usesSignedTargetedProjectionFacts()); - } - - /** - * Checks the Ledger-V6 scenario: creates targeted spin off shape. - * The result must keep ledger truth and visible runtime rows consistent. - * This protects against duplicate truth or partial mutation. - */ - @Test - public void testCreatesTargetedSpinOffShape() - { - var fixture = fixture(); - var client = fixture.client(); - var entry = spinOffEntry(client); - - assertThat(entry.getPostings().size(), is(7)); - assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).size(), is(4)); - var oldSiemensOut = securityPosting(entry, fixture.siemens(), - CorporateActionLeg.SOURCE_SECURITY.getCode(), fixture.siemensEnergy()); - var siemensBackIn = securityPosting(entry, fixture.siemens(), - CorporateActionLeg.TARGET_SECURITY.getCode(), fixture.siemens()); - var siemensEnergyIn = securityPosting(entry, fixture.siemensEnergy(), - CorporateActionLeg.TARGET_SECURITY.getCode(), fixture.siemensEnergy()); - var compensation = posting(entry, LedgerPostingType.CASH_COMPENSATION, fixture.account(), - Values.Amount.factorize(5)); - posting(entry, LedgerPostingType.FEE, fixture.account(), Values.Amount.factorize(2)); - posting(entry, LedgerPostingType.TAX, fixture.account(), Values.Amount.factorize(1)); - - assertProjectionTargets(entry, LedgerProjectionRole.OLD_SECURITY_LEG, oldSiemensOut, null); - assertProjectionTargets(entry, LedgerProjectionRole.DELIVERY_INBOUND, siemensBackIn, null); - assertProjectionTargets(entry, LedgerProjectionRole.NEW_SECURITY_LEG, siemensEnergyIn, null); - assertProjectionTargets(entry, LedgerProjectionRole.CASH_COMPENSATION, compensation, compensation); - assertTrue(LedgerStructuralValidator.validate(client.getLedger()).isOK()); - } - - /** - * Checks the Ledger-V6 scenario: materializes spin off compatibility projections. - * The result must keep ledger truth and visible runtime rows consistent. - * This protects against duplicate truth or partial mutation. - */ - @Test - public void testMaterializesSpinOffCompatibilityProjections() - { - var fixture = fixture(); - LedgerProjectionService.materialize(fixture.client()); - LedgerProjectionService.materialize(fixture.client()); - - assertThat(fixture.portfolio().getTransactions().size(), is(4)); - assertThat(fixture.account().getTransactions().size(), is(3)); - - var oldLeg = portfolioProjection(fixture.portfolio(), LedgerProjectionRole.OLD_SECURITY_LEG); - var retainedLeg = portfolioProjection(fixture.portfolio(), LedgerProjectionRole.DELIVERY_INBOUND, - fixture.siemens()); - var newLeg = portfolioProjection(fixture.portfolio(), LedgerProjectionRole.NEW_SECURITY_LEG); - var compensation = accountProjection(fixture.account(), LedgerProjectionRole.CASH_COMPENSATION); - - assertThat(oldLeg, instanceOf(LedgerBackedTransaction.class)); - assertThat(oldLeg.getType(), is(PortfolioTransaction.Type.DELIVERY_OUTBOUND)); - assertSame(fixture.siemens(), oldLeg.getSecurity()); - assertThat(oldLeg.getShares(), is(Values.Share.factorize(10))); - assertThat(oldLeg.getUnits().count(), is(0L)); - - assertThat(retainedLeg, instanceOf(LedgerBackedTransaction.class)); - assertThat(retainedLeg.getType(), is(PortfolioTransaction.Type.DELIVERY_INBOUND)); - assertSame(fixture.siemens(), retainedLeg.getSecurity()); - assertThat(retainedLeg.getShares(), is(Values.Share.factorize(10))); - assertThat(retainedLeg.getAmount(), is(oldLeg.getAmount())); - assertThat(retainedLeg.getUnits().count(), is(0L)); - - assertThat(newLeg, instanceOf(LedgerBackedTransaction.class)); - assertThat(newLeg.getType(), is(PortfolioTransaction.Type.DELIVERY_INBOUND)); - assertSame(fixture.siemensEnergy(), newLeg.getSecurity()); - assertThat(newLeg.getShares(), is(Values.Share.factorize(5))); - assertThat(newLeg.getUnits().count(), is(0L)); - - assertThat(compensation, instanceOf(LedgerBackedTransaction.class)); - assertThat(compensation.getType(), is(AccountTransaction.Type.DEPOSIT)); - assertThat(compensation.getAmount(), is(Values.Amount.factorize(5))); - assertThat(compensation.getUnit(Unit.Type.FEE).orElseThrow().getAmount().getAmount(), - is(Values.Amount.factorize(2))); - assertThat(compensation.getUnit(Unit.Type.TAX).orElseThrow().getAmount().getAmount(), - is(Values.Amount.factorize(1))); - assertThat(fixture.client().getAllTransactions().size(), is(6)); - assertSpinOffSiemensPositionUnchanged(fixture.portfolio(), fixture.siemens()); - } - /** * Checks the Ledger-V6 scenario: share adjustment helper scales selected targeted spin off postings. * The result must keep ledger truth and visible runtime rows consistent. @@ -259,57 +159,6 @@ public void testShareAdjustmentHelperRejectsTargetedProjectionWithoutPrimaryPost assertThat(portfolio.getTransactions().size(), is(0)); } - /** - * Checks the Ledger-V6 scenario: xml roundtrip preserves targeted spin off shape. - * The result must keep ledger truth and visible runtime rows consistent. - * This protects against duplicate truth or partial mutation. - */ - @Test - public void testXmlRoundtripPreservesTargetedSpinOffShape() throws Exception - { - var client = fixture().client(); - LedgerProjectionService.materialize(client); - - var xml = saveXml(client); - - assertTrue(xml.contains("")); - assertFalse(xml.contains(" "DE0007236101".equals(security.getIsin())) - .count(), is(1L)); - assertThat(client.getSecurities().stream().filter(security -> "Siemens Energy AG".equals(security.getName())) - .count(), is(1L)); - assertThat(client.getPortfolios().get(0).getReferenceAccount(), is(client.getAccounts().get(0))); - assertThat(entry.getType(), is(LedgerEntryType.CORPORATE_ACTION)); - assertThat(entry.getUpdatedAt(), is(UPDATED_AT)); - var hasSecurityContext = entry.getPostings().stream() - .anyMatch(posting -> posting.getCorporateActionLeg() == CorporateActionLeg.SECURITY_CONTEXT); - assertThat(entry.getPostings().size(), is(hasSecurityContext ? 7 : 6)); - var oldSecurityLeg = securityPosting(entry, siemens(client), CorporateActionLeg.SOURCE_SECURITY.getCode(), - siemensEnergy(client)); - var retainedSecurityLeg = securityPosting(entry, siemens(client), CorporateActionLeg.TARGET_SECURITY.getCode(), - siemens(client)); - var newSecurityLeg = securityPosting(entry, siemensEnergy(client), CorporateActionLeg.TARGET_SECURITY.getCode(), - siemensEnergy(client)); - - if (name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).isEmpty()) - { - var compensationLeg = cashCompensationPosting(entry); - - assertThat(oldSecurityLeg.getCorporateActionLeg(), is(CorporateActionLeg.SOURCE_SECURITY)); - assertThat(retainedSecurityLeg.getCorporateActionLeg(), is(CorporateActionLeg.TARGET_SECURITY)); - assertThat(retainedSecurityLeg.getLocalKey(), is(LedgerProjectionRole.DELIVERY_INBOUND.name())); - assertThat(newSecurityLeg.getCorporateActionLeg(), is(CorporateActionLeg.TARGET_SECURITY)); - assertThat(newSecurityLeg.getLocalKey(), is(LedgerProjectionRole.NEW_SECURITY_LEG.name())); - assertThat(compensationLeg.getCorporateActionLeg(), is(CorporateActionLeg.CASH_COMPENSATION)); - } - else - { - var compensationLeg = primaryPosting(entry, LedgerProjectionRole.CASH_COMPENSATION); - - assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).size(), is(4)); - assertProjectionTargets(entry, LedgerProjectionRole.OLD_SECURITY_LEG, oldSecurityLeg, null); - assertProjectionTargets(entry, LedgerProjectionRole.DELIVERY_INBOUND, retainedSecurityLeg, null); - assertProjectionTargets(entry, LedgerProjectionRole.NEW_SECURITY_LEG, newSecurityLeg, null); - assertProjectionTargets(entry, LedgerProjectionRole.CASH_COMPENSATION, compensationLeg, compensationLeg); - } - assertThat(client.getPortfolios().get(0).getTransactions().size(), is(4)); - assertThat(client.getAccounts().get(0).getTransactions().size(), is(3)); - assertThat(buyProjection(client, siemens(client)).getType(), is(PortfolioTransaction.Type.BUY)); - assertThat(portfolioProjection(client.getPortfolios().get(0), LedgerProjectionRole.OLD_SECURITY_LEG).getType(), - is(PortfolioTransaction.Type.DELIVERY_OUTBOUND)); - assertSame(siemens(client), - portfolioProjection(client.getPortfolios().get(0), LedgerProjectionRole.OLD_SECURITY_LEG) - .getSecurity()); - assertThat(portfolioProjection(client.getPortfolios().get(0), LedgerProjectionRole.OLD_SECURITY_LEG) - .getShares(), - is(Values.Share.factorize(10))); - assertThat(portfolioProjection(client.getPortfolios().get(0), LedgerProjectionRole.DELIVERY_INBOUND, - siemens(client)).getType(), is(PortfolioTransaction.Type.DELIVERY_INBOUND)); - assertSame(siemens(client), - portfolioProjection(client.getPortfolios().get(0), LedgerProjectionRole.DELIVERY_INBOUND, - siemens(client)).getSecurity()); - assertThat(portfolioProjection(client.getPortfolios().get(0), LedgerProjectionRole.DELIVERY_INBOUND, - siemens(client)).getShares(), - is(Values.Share.factorize(10))); - assertThat(portfolioProjection(client.getPortfolios().get(0), LedgerProjectionRole.DELIVERY_INBOUND, - siemens(client)).getAmount(), is(portfolioProjection(client.getPortfolios().get(0), - LedgerProjectionRole.OLD_SECURITY_LEG).getAmount())); - assertThat(portfolioProjection(client.getPortfolios().get(0), LedgerProjectionRole.NEW_SECURITY_LEG).getType(), - is(PortfolioTransaction.Type.DELIVERY_INBOUND)); - assertSame(siemensEnergy(client), - portfolioProjection(client.getPortfolios().get(0), LedgerProjectionRole.NEW_SECURITY_LEG) - .getSecurity()); - assertThat(portfolioProjection(client.getPortfolios().get(0), LedgerProjectionRole.NEW_SECURITY_LEG) - .getShares(), - is(Values.Share.factorize(5))); - assertThat(accountProjection(client.getAccounts().get(0), LedgerProjectionRole.CASH_COMPENSATION).getUnits() - .count(), is(2L)); - assertSpinOffSiemensPositionUnchanged(client.getPortfolios().get(0), siemens(client)); - assertTrue(LedgerStructuralValidator.validate(client.getLedger()).isOK()); - } - private void assertSpinOffSiemensPositionUnchanged(Portfolio portfolio, Security siemens) { var oldLeg = portfolioProjection(portfolio, LedgerProjectionRole.OLD_SECURITY_LEG); @@ -637,13 +405,6 @@ private void assertSpinOffSiemensPositionUnchanged(Portfolio portfolio, Security assertThat(retainedLeg.getShares() - oldLeg.getShares(), is(0L)); } - private LedgerPosting posting(LedgerEntry entry, LedgerPostingType type, Account account, long amount) - { - return entry.getPostings().stream().filter(posting -> posting.getType() == type) - .filter(posting -> posting.getAccount() == account).filter(posting -> posting.getAmount() == amount) - .findFirst().orElseThrow(); - } - private LedgerPosting securityPosting(LedgerEntry entry, Security security, String leg, Security targetSecurity) { @@ -654,25 +415,6 @@ private LedgerPosting securityPosting(LedgerEntry entry, Security security, .findFirst().orElseThrow(); } - private LedgerPosting cashCompensationPosting(LedgerEntry entry) - { - return entry.getPostings().stream() - .filter(posting -> posting.getSemanticRole() == LedgerPostingSemanticRole.CASH_COMPENSATION) - .filter(posting -> posting.getCorporateActionLeg() == CorporateActionLeg.CASH_COMPENSATION) - .findFirst().orElseThrow(); - } - - private void assertProjectionTargets(LedgerEntry entry, LedgerProjectionRole role, LedgerPosting primaryPosting, - LedgerPosting postingGroup) - { - var projection = projection(entry, role); - assertThat(projection.getRole(), is(role)); - assertThat(projection.getPrimaryPosting().getUUID(), is(primaryPosting.getUUID())); - assertThat(projection.getPrimaryPosting().getGroupKey(), is(postingGroup != null - ? primaryPosting.getGroupKey() - : projection.getPrimaryPosting().getGroupKey())); - } - private PortfolioTransaction portfolioProjection(Portfolio portfolio, LedgerProjectionRole role) { return portfolio.getTransactions().stream().filter(LedgerBackedTransaction.class::isInstance) @@ -816,55 +558,6 @@ private Client loadXml(String xml) throws Exception return ClientFactory.load(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); } - private byte[] saveProtobuf(Client client) throws Exception - { - var stream = new ByteArrayOutputStream(); - var writer = protobufWriter(); - var save = writer.getClass().getDeclaredMethod("save", Client.class, java.io.OutputStream.class); - - save.setAccessible(true); - invoke(save, writer, client, stream); - - return stream.toByteArray(); - } - - private Client loadProtobuf(byte[] bytes) throws Exception - { - var writer = protobufWriter(); - var load = writer.getClass().getDeclaredMethod("load", java.io.InputStream.class); - - load.setAccessible(true); - return (Client) invoke(load, writer, new ByteArrayInputStream(bytes)); - } - - private Object protobufWriter() throws Exception - { - var type = Class.forName("name.abuchen.portfolio.model.ProtobufWriter"); - var constructor = type.getDeclaredConstructor(); - - constructor.setAccessible(true); - return constructor.newInstance(); - } - - private Object invoke(java.lang.reflect.Method method, Object target, Object... args) throws Exception - { - try - { - return method.invoke(target, args); - } - catch (InvocationTargetException e) - { - var cause = e.getCause(); - - if (cause instanceof Exception exception) - throw exception; - if (cause instanceof Error error) - throw error; - - throw new AssertionError(cause); - } - } - private Account account(String name) { var account = new Account(); diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffXmlDefinitionCheckerTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffXmlDefinitionCheckerTest.java deleted file mode 100644 index ec131592d0..0000000000 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffXmlDefinitionCheckerTest.java +++ /dev/null @@ -1,454 +0,0 @@ -package name.abuchen.portfolio.model.ledger; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.EnumSet; -import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; - -import org.junit.Test; - -import name.abuchen.portfolio.model.ClientFactory; -import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinition; -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.model.ledger.configuration.rule.LedgerParameterRule; -import name.abuchen.portfolio.model.ledger.configuration.rule.LedgerPostingRule; -import name.abuchen.portfolio.model.ledger.configuration.rule.LedgerRequirementGroup; - -@SuppressWarnings("nls") -public final class LedgerSpinOffXmlDefinitionCheckerTest -{ - private static final Path XML_EXAMPLE = Path - .of("name.abuchen.portfolio.tests", "src", "name", "abuchen", "portfolio", "model", "ledger", - "ledger-v6-spin-off-siemens-energy-example.xml"); - - /** - * Checks the persistence scenario: siemens energy xml example matches static ledger definitions. - * The loaded model must rebuild visible rows from ledger truth. - * This protects compatibility data from becoming a second transaction source. - */ - @Test - public void testSiemensEnergyXmlExampleMatchesStaticLedgerDefinitions() throws IOException - { - var report = check(XML_EXAMPLE); - var ledgerXml = ledgerSection(Files.readString(resolve(XML_EXAMPLE))); - - System.out.println(report.markdown()); - - assertFalse(ledgerXml.contains("")); - assertFalse(ledgerXml.contains("")); - assertFalse(ledgerXml.contains(""); - var end = xml.indexOf(""); - - assertTrue(start >= 0); - assertTrue(end > start); - - return xml.substring(start, end); - } - - public static Report check(Path xmlFile) throws IOException - { - xmlFile = resolve(xmlFile); - - var client = ClientFactory.load(Files.newInputStream(xmlFile)); - var structuralResult = LedgerStructuralValidator.validate(client.getLedger()); - var summary = new Summary(); - var sections = new ArrayList(); - var codeDomainRows = new ArrayList(); - var spinOffEntryCount = 0; - - for (var entry : client.getLedger().getEntries()) - { - if (entry.getType() == LedgerEntryType.CORPORATE_ACTION) - spinOffEntryCount++; - - var definition = entry.getType() == null ? null - : LedgerEntryDefinitionRegistry.lookup(entry.getType()).orElse(null); - - sections.add(entrySection(entry, definition, codeDomainRows, summary)); - - for (var posting : entry.getPostings()) - sections.add(postingSection(entry, posting, definition, codeDomainRows, summary)); - } - - return new Report(xmlFile, structuralResult, sections, codeDomainRows, summary, spinOffEntryCount); - } - - private static Path resolve(Path file) - { - var current = Path.of("").toAbsolutePath(); - - while (current != null) - { - var candidate = current.resolve(file); - - if (Files.exists(candidate)) - return candidate; - - current = current.getParent(); - } - - return Path.of("").toAbsolutePath().resolve(file); - } - - private static String entrySection(LedgerEntry entry, LedgerEntryDefinition definition, - List codeDomainRows, Summary summary) - { - var builder = new StringBuilder(); - var allowed = definition == null ? Set.of() : definition.getEntryParameterTypes(); - - builder.append("## LedgerEntry `").append(entry.getUUID()).append("`\n\n"); - builder.append("* Entry type: `").append(entry.getType()).append("`\n"); - builder.append("* Definition: `") - .append(definition == null ? "missing" : definition.getEntryType().name()) - .append("`\n\n"); - builder.append("### Entry Parameters\n\n"); - builder.append("| Parameter | ValueKind | Expected | Rule | Alternative group | Code domain | Result |\n"); - builder.append("| --- | --- | --- | --- | --- | --- | --- |\n"); - - for (var parameter : entry.getParameters()) - { - var type = parameter.getType(); - var definitionAllowed = type != null && allowed.contains(type); - var rule = definition == null || type == null ? "-" - : parameterRule(definition.getEntryParameterRules(), type); - var alternative = definition == null || type == null ? "-" - : alternativeGroups(definition.getAlternativeRequirementGroups(), type); - var result = parameterResult(parameter, definitionAllowed, false, summary); - - appendParameterRow(builder, parameter, rule, alternative, result); - appendCodeDomainRow(codeDomainRows, "entry " + entry.getUUID(), parameter, summary); - } - - if (entry.getParameters().isEmpty()) - builder.append("| _none_ | | | | | | |\n"); - - builder.append("\n"); - builder.append("Allowed entry parameters from `LedgerEntryDefinition`: ") - .append(formatTypes(allowed)).append("\n\n"); - - return builder.toString(); - } - - private static String postingSection(LedgerEntry entry, LedgerPosting posting, LedgerEntryDefinition entryDefinition, - List codeDomainRows, Summary summary) - { - var builder = new StringBuilder(); - var entryPostingTypes = entryDefinition == null ? Set.of() - : entryDefinition.getPostingTypes(); - var entryPostingParameters = entryDefinition == null ? Set.of() - : entryDefinition.getPostingParameterTypes(); - var postingDefinition = posting.getType() == null ? null - : LedgerPostingTypeDefinitionRegistry.lookup(posting.getType()).orElse(null); - var postingParameters = postingDefinition == null ? Set.of() - : postingDefinition.getComponentParameterTypes(); - var postingRule = entryDefinition == null || posting.getType() == null ? null - : postingRule(entryDefinition.getPostingRules(), posting.getType()); - var postingTypeAllowed = entryDefinition == null - || posting.getType() != null && entryPostingTypes.contains(posting.getType()); - - if (entryDefinition != null && !postingTypeAllowed) - summary.definitionGapCount++; - - builder.append("## LedgerPosting `").append(posting.getUUID()).append("`\n\n"); - builder.append("* Posting type: `").append(posting.getType()).append("`\n"); - builder.append("* Posting type allowed by entry definition: `") - .append(entryDefinition == null ? "not applicable" : postingTypeAllowed).append("`\n"); - builder.append("* Posting rule: `").append(postingRule == null ? "-" : postingRule.getRequirement()) - .append("`\n\n"); - builder.append("### Posting Parameters\n\n"); - builder.append("| Parameter | ValueKind | Expected | EntryDefinition | PostingTypeDefinition | Rule | Code domain | Result |\n"); - builder.append("| --- | --- | --- | --- | --- | --- | --- | --- |\n"); - - for (var parameter : posting.getParameters()) - { - var type = parameter.getType(); - var entryAllowed = type != null && entryPostingParameters.contains(type); - var postingAllowed = type != null && postingParameters.contains(type); - var result = parameterResult(parameter, entryAllowed, postingAllowed, summary); - var rule = postingRuleDescription(postingRule, type); - - builder.append("| `").append(type).append("` | `").append(parameter.getValueKind()) - .append("` | `").append(type == null ? "" : type.getExpectedValueKind()) - .append("` | ").append(definitionStatus(entryAllowed)).append(" | ") - .append(definitionStatus(postingAllowed)).append(" | ") - .append(escape(rule)).append(" | ") - .append(codeDomain(parameter)).append(" | ").append(result).append(" |\n"); - - appendCodeDomainRow(codeDomainRows, "posting " + posting.getUUID(), parameter, summary); - } - - if (posting.getParameters().isEmpty()) - builder.append("| _none_ | | | | | | | |\n"); - - builder.append("\n"); - builder.append("Entry-definition posting parameter vocabulary: ") - .append(formatTypes(entryPostingParameters)).append("\n\n"); - builder.append("Posting-type definition parameter vocabulary: ") - .append(formatTypes(postingParameters)).append("\n\n"); - - return builder.toString(); - } - - private static String parameterResult(LedgerParameter parameter, boolean definitionAllowed, - boolean postingDefinitionAllowed, Summary summary) - { - var type = parameter.getType(); - - if (type == null) - { - summary.unknownParameterCount++; - return "`UNKNOWN_PARAMETER`"; - } - - if (parameter.getValueKind() == null || parameter.getValue() == null - || !type.supportsValueKind(parameter.getValueKind()) - || !parameter.getValueKind().supportsValue(parameter.getValue())) - { - summary.valueKindMismatchCount++; - return "`VALUE_KIND_MISMATCH`"; - } - - if (!definitionAllowed && !postingDefinitionAllowed) - { - summary.definitionGapCount++; - return "`DEFINITION_GAP`"; - } - - summary.okCount++; - - if (definitionAllowed && postingDefinitionAllowed) - return "`OK_BOTH_DEFINITIONS`"; - - if (definitionAllowed) - return "`OK_ENTRY_DEFINITION_ONLY`"; - - return "`OK_POSTING_TYPE_DEFINITION_ONLY`"; - } - - private static void appendParameterRow(StringBuilder builder, LedgerParameter parameter, String rule, - String alternative, String result) - { - var type = parameter.getType(); - - builder.append("| `").append(type).append("` | `").append(parameter.getValueKind()).append("` | `") - .append(type == null ? "" : type.getExpectedValueKind()).append("` | ") - .append(escape(rule)).append(" | ").append(escape(alternative)).append(" | ") - .append(codeDomain(parameter)).append(" | ").append(result).append(" |\n"); - } - - private static void appendCodeDomainRow(List rows, String owner, LedgerParameter parameter, Summary summary) - { - var type = parameter.getType(); - - if (type == null || !type.hasCodeDomain()) - return; - - var value = String.valueOf(parameter.getValue()); - var domain = type.getCodeDomain(); - var allowed = domain.allows(value); - - if (!allowed) - summary.notAllowedCount++; - - rows.add(new Row(owner, type.name(), value, domain.name(), - String.join(", ", domain.getAllowedCodes()), allowed ? "OK" : "NOT_ALLOWED")); - } - - private static String codeDomain(LedgerParameter parameter) - { - var type = parameter.getType(); - - if (type == null || !type.hasCodeDomain()) - return "`-`"; - - return "`" + type.getCodeDomain() + "`"; - } - - private static String parameterRule(Set rules, LedgerParameterType type) - { - for (var rule : rules) - if (rule.getParameterType() == type) - return rule.getRequirement() + (rule.isRepeatable() ? ", repeatable" : ""); - - return "-"; - } - - private static String postingRuleDescription(LedgerPostingRule postingRule, LedgerParameterType type) - { - if (postingRule == null || type == null) - return "-"; - - if (postingRule.getRequiredParameterTypes().contains(type)) - return "REQUIRED"; - - if (postingRule.getOptionalParameterTypes().contains(type)) - return "OPTIONAL"; - - return "-"; - } - - private static LedgerPostingRule postingRule(Set rules, LedgerPostingType type) - { - for (var rule : rules) - if (rule.getPostingType() == type) - return rule; - - return null; - } - - private static String alternativeGroups(Set groups, LedgerParameterType type) - { - var names = groups.stream().filter(group -> group.getParameterTypes().contains(type)) - .map(group -> group.getName() + " " + group.getRequirement()) - .collect(Collectors.joining(", ")); - - return names.isBlank() ? "-" : names; - } - - private static String definitionStatus(boolean allowed) - { - return allowed ? "`allowed`" : "`not listed`"; - } - - private static String formatTypes(Set types) - { - if (types.isEmpty()) - return "`-`"; - - return types.stream().map(type -> "`" + type.name() + "`").collect(Collectors.joining(", ")); - } - - private static String escape(String value) - { - return value == null || value.isBlank() ? "`-`" : "`" + value.replace("|", "\\|") + "`"; - } - - public record Report(Path xmlFile, LedgerStructuralValidator.ValidationResult structuralResult, - List sections, List codeDomainRows, Summary summary, int spinOffEntryCount) - { - public boolean structuralValidationOK() - { - return structuralResult.isOK(); - } - - public String markdown() - { - var builder = new StringBuilder(); - - builder.append("# Ledger Spin-Off XML Definition Check\n\n"); - builder.append("* XML file: `").append(xmlFile).append("`\n"); - builder.append("* XML structural load / LedgerStructuralValidator: `") - .append(structuralResult.isOK() ? "OK" : "NOT_OK").append("`\n"); - builder.append("* Spin-off entries: `").append(spinOffEntryCount).append("`\n\n"); - builder.append("This diagnostic reads the XML through `ClientFactory`, inspects `Client#getLedger()`, ") - .append("and compares the stored `LedgerParameter` values against the static ") - .append("Ledger definition registries. It reports required, optional, and alternative ") - .append("definition metadata, but it does not implement productive corporate-action ") - .append("completeness validation.\n\n"); - builder.append("## Summary\n\n"); - builder.append("| Counter | Value |\n"); - builder.append("| --- | ---: |\n"); - builder.append("| OK | ").append(summary.okCount()).append(" |\n"); - builder.append("| NOT_ALLOWED | ").append(summary.notAllowedCount()).append(" |\n"); - builder.append("| UNKNOWN_PARAMETER | ").append(summary.unknownParameterCount()).append(" |\n"); - builder.append("| VALUE_KIND_MISMATCH | ").append(summary.valueKindMismatchCount()).append(" |\n"); - builder.append("| DEFINITION_GAP | ").append(summary.definitionGapCount()).append(" |\n\n"); - builder.append("## Static Event Parameter Vocabulary\n\n"); - builder.append(formatTypes(EnumSet.copyOf(LedgerEventParameterDefinition.getParameterTypes()))) - .append("\n\n"); - builder.append(String.join("\n", sections)); - builder.append("## Code Domain Results\n\n"); - builder.append("| Owner | ParameterType | XML value | CodeDomain | Allowed codes | Result |\n"); - builder.append("| --- | --- | --- | --- | --- | --- |\n"); - - for (var row : codeDomainRows) - builder.append("| ").append(escapeCell(row.owner())).append(" | `").append(row.parameterType()) - .append("` | `").append(escapeCell(row.value())).append("` | `") - .append(row.codeDomain()).append("` | ") - .append(escapeCell(row.allowedCodes())).append(" | `").append(row.result()) - .append("` |\n"); - - if (codeDomainRows.isEmpty()) - builder.append("| _none_ | | | | | |\n"); - - builder.append("\n## Boundary Statement\n\n"); - builder.append("The diagnostic separates XML structural load, generic `LedgerStructuralValidator` ") - .append("result, definition vocabulary checks, and code-domain checks. It does not ") - .append("change production validation and does not state that Portfolio Performance ") - .append("productively enforces corporate-action completeness.\n"); - - return builder.toString(); - } - - private static String escapeCell(String value) - { - return value.replace("|", "\\|"); - } - } - - public static final class Summary - { - private int okCount; - private int notAllowedCount; - private int unknownParameterCount; - private int valueKindMismatchCount; - private int definitionGapCount; - - public int okCount() - { - return okCount; - } - - public int notAllowedCount() - { - return notAllowedCount; - } - - public int unknownParameterCount() - { - return unknownParameterCount; - } - - public int valueKindMismatchCount() - { - return valueKindMismatchCount; - } - - public int definitionGapCount() - { - return definitionGapCount; - } - } - - public record Row(String owner, String parameterType, String value, String codeDomain, String allowedCodes, - String result) - { - } -} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblerTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblerTest.java index 80f664c43c..d1af4981a9 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblerTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblerTest.java @@ -313,34 +313,6 @@ public void testRejectsInvalidCodeDomainValue() assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.PARAMETER_CODE_NOT_ALLOWED)); } - /** - * Checks the Ledger-V6 scenario: builds detached minimal spin off. - * The result must keep ledger truth and visible runtime rows consistent. - * This protects against duplicate truth or partial mutation. - */ - @Test - public void testBuildsDetachedMinimalSpinOff() - { - var fixture = fixture(); - var result = validSpinOff(fixture).buildDetached(); - var entry = result.getEntry(); - - assertThat(entry.getType(), is(LedgerEntryType.CORPORATE_ACTION)); - assertThat(entry.getPostings().size(), is(6)); - assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).size(), is(3)); - assertThat(fixture.client.getLedger().getEntries().size(), is(0)); - assertTrue(result.getValidationResult().isOK()); - - assertThat(parameter(entry.getParameters(), LedgerParameterType.CORPORATE_ACTION_KIND).getValue(), - is(CorporateActionKind.SPIN_OFF.getCode())); - assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream().filter(ref -> ref.getRole() == LedgerProjectionRole.OLD_SECURITY_LEG) - .count(), is(1L)); - assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream().filter(ref -> ref.getRole() == LedgerProjectionRole.NEW_SECURITY_LEG) - .count(), is(1L)); - assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream() - .filter(ref -> ref.getRole() == LedgerProjectionRole.CASH_COMPENSATION).count(), is(1L)); - } - /** * Checks the Ledger-V6 scenario: build detached does not mutate client. * The result must keep ledger truth and visible runtime rows consistent. @@ -358,78 +330,6 @@ public void testBuildDetachedDoesNotMutateClient() assertThat(fixture.portfolio.getTransactions().size(), is(0)); } - @Test - public void testNativeAssemblerEmitsSemanticPostingsForSiemensSpinOff() - { - var fixture = fixture(); - var entry = spinOffWithRetainedAndNewLegs(fixture).buildDetached().getEntry(); - var oldLeg = descriptor(entry, LedgerProjectionRole.OLD_SECURITY_LEG).getPrimaryPosting(); - var retainedLeg = descriptor(entry, LedgerProjectionRole.DELIVERY_INBOUND).getPrimaryPosting(); - var newLeg = descriptor(entry, LedgerProjectionRole.NEW_SECURITY_LEG).getPrimaryPosting(); - var compensation = descriptor(entry, LedgerProjectionRole.CASH_COMPENSATION).getPrimaryPosting(); - - assertPrimarySemantics(oldLeg, LedgerPostingSemanticRole.SECURITY, LedgerPostingDirection.OUTBOUND, - CorporateActionLeg.SOURCE_SECURITY, LedgerProjectionRole.OLD_SECURITY_LEG); - assertPrimarySemantics(retainedLeg, LedgerPostingSemanticRole.SECURITY, LedgerPostingDirection.INBOUND, - CorporateActionLeg.TARGET_SECURITY, LedgerProjectionRole.DELIVERY_INBOUND); - assertPrimarySemantics(newLeg, LedgerPostingSemanticRole.SECURITY, LedgerPostingDirection.INBOUND, - CorporateActionLeg.TARGET_SECURITY, LedgerProjectionRole.NEW_SECURITY_LEG); - assertPrimarySemantics(compensation, LedgerPostingSemanticRole.CASH_COMPENSATION, - LedgerPostingDirection.NEUTRAL, CorporateActionLeg.CASH_COMPENSATION, - LedgerProjectionRole.CASH_COMPENSATION); - assertThat(entry.getPostings().stream().filter(posting -> posting.getType() == LedgerPostingType.FEE) - .findFirst().orElseThrow().getGroupKey(), is(LedgerProjectionRole.CASH_COMPENSATION.name())); - assertThat(entry.getPostings().stream().filter(posting -> posting.getType() == LedgerPostingType.TAX) - .findFirst().orElseThrow().getUnitRole(), is(LedgerPostingUnitRole.TAX)); - } - - @Test - public void testNativeAssemblerCreatesRepeatedSpinOffMovementLegsWithSemanticKeys() - { - var fixture = fixture(); - var secondTarget = new Security("Siemens Healthineers AG", CurrencyUnit.EUR); - secondTarget.setIsin("DE000SHL1006"); - fixture.client.addSecurity(secondTarget); - - var entry = repeatedSpinOff(fixture, secondTarget).buildDetached().getEntry(); - var targetPostings = postings(entry, LedgerPostingType.SECURITY, CorporateActionLeg.TARGET_SECURITY); - var cashPostings = postings(entry, LedgerPostingType.CASH_COMPENSATION, - CorporateActionLeg.CASH_COMPENSATION); - var targetDescriptors = descriptors(entry).stream() - .filter(descriptor -> descriptor.getRole() == LedgerProjectionRole.NEW_SECURITY_LEG) - .toList(); - var cashOneDescriptor = descriptor(entry, LedgerProjectionRole.CASH_COMPENSATION, "cash-1"); - var cashTwoDescriptor = descriptor(entry, LedgerProjectionRole.CASH_COMPENSATION, "cash-2"); - - assertThat(targetPostings.size(), is(2)); - assertThat(targetPostings.stream().map(LedgerPosting::getLocalKey).collect(Collectors.toSet()), - is(java.util.Set.of("target-1", "target-2"))); - assertThat(targetPostings.stream().map(LedgerPosting::getGroupKey).collect(Collectors.toSet()), - is(java.util.Set.of("main"))); - - assertThat(cashPostings.size(), is(2)); - assertThat(cashPostings.stream().map(LedgerPosting::getLocalKey).collect(Collectors.toSet()), - is(java.util.Set.of("cash-1", "cash-2"))); - assertThat(cashPostings.stream().map(LedgerPosting::getGroupKey).collect(Collectors.toSet()), - is(java.util.Set.of("cash-1", "cash-2"))); - - assertThat(targetDescriptors.size(), is(2)); - assertThat(targetDescriptors.stream().map(DerivedProjectionDescriptor::getSemanticInstanceKey) - .map(Optional::orElseThrow).collect(Collectors.toSet()), - is(java.util.Set.of("target-1", "target-2"))); - assertThat(targetDescriptors.stream().map(DerivedProjectionDescriptor::getRuntimeProjectionId) - .collect(Collectors.toSet()).size(), is(2)); - assertThat(descriptor(entry, LedgerProjectionRole.NEW_SECURITY_LEG, "target-1").getPrimaryPosting() - .getSecurity(), is(fixture.siemensEnergy)); - assertThat(descriptor(entry, LedgerProjectionRole.NEW_SECURITY_LEG, "target-2").getPrimaryPosting() - .getSecurity(), is(secondTarget)); - - assertThat(cashOneDescriptor.getUnitPostings().stream().map(LedgerPosting::getLocalKey) - .collect(Collectors.toSet()), is(java.util.Set.of("fee-1", "tax-1"))); - assertThat(cashTwoDescriptor.getUnitPostings().isEmpty(), is(true)); - assertTrue(LedgerStructuralValidator.validate(ledger(entry)).isOK()); - } - @Test public void testNativeAssemblerRepeatedTargetDuplicateLocalKeyIsRejected() { @@ -484,79 +384,6 @@ public void testNativeAssemblerRepeatedCashDuplicateLocalKeyIsRejected() assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.NATIVE_DEFINITION_VALIDATION_FAILED)); } - /** - * Checks the Ledger-V6 scenario: build and add creates spin off and materializes runtime projections. - * The result must keep ledger truth and visible runtime rows consistent. - * This protects against duplicate truth or partial mutation. - */ - @Test - public void testBuildAndAddCreatesSpinOffAndMaterializesRuntimeProjections() - { - var fixture = fixture(); - var result = validSpinOff(fixture).buildAndAdd(); - var entry = result.getEntry(); - - assertThat(fixture.client.getLedger().getEntries().size(), is(1)); - assertThat(fixture.client.getLedger().getEntries().get(0), is(entry)); - assertTrue(result.getValidationResult().isOK()); - assertTrue(LedgerStructuralValidator.validate(fixture.client.getLedger()).isOK()); - - assertThat(fixture.portfolio.getTransactions().size(), is(2)); - assertThat(fixture.account.getTransactions().size(), is(1)); - assertThat(portfolioProjection(fixture.portfolio, LedgerProjectionRole.OLD_SECURITY_LEG).getLedgerEntry(), - is(entry)); - assertThat(portfolioProjection(fixture.portfolio, LedgerProjectionRole.NEW_SECURITY_LEG).getLedgerEntry(), - is(entry)); - assertThat(accountProjection(fixture.account, LedgerProjectionRole.CASH_COMPENSATION).getLedgerEntry(), - is(entry)); - } - - /** - * Checks the Ledger-V6 scenario: build and add descriptor ids are runtime projection ids. - * The result must keep ledger truth and visible runtime rows consistent. - * This protects against duplicate truth or partial mutation. - */ - @Test - public void testBuildAndAddDescriptorIdsAreRuntimeProjectionIds() - { - var fixture = fixture(); - var entry = validSpinOff(fixture).buildAndAdd().getEntry(); - var descriptorIds = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream() - .map(descriptor -> name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport - .runtimeProjectionId(entry, descriptor.getRole())) - .collect(Collectors.toSet()); - var runtimeUUIDs = java.util.stream.Stream.concat( - fixture.portfolio.getTransactions().stream() - .filter(LedgerBackedTransaction.class::isInstance) - .map(PortfolioTransaction::getUUID), - fixture.account.getTransactions().stream() - .filter(LedgerBackedTransaction.class::isInstance) - .map(AccountTransaction::getUUID)) - .collect(Collectors.toSet()); - - assertThat(runtimeUUIDs, is(descriptorIds)); - assertThat(runtimeUUIDs.size(), is(3)); - } - - /** - * Checks the Ledger-V6 scenario: build and add does not duplicate runtime projections. - * The result must keep ledger truth and visible runtime rows consistent. - * This protects against duplicate truth or partial mutation. - */ - @Test - public void testBuildAndAddDoesNotDuplicateRuntimeProjections() - { - var fixture = fixture(); - - validSpinOff(fixture).buildAndAdd(); - validSpinOff(fixture).buildAndAdd(); - - assertThat(fixture.client.getLedger().getEntries().size(), is(2)); - assertThat(fixture.portfolio.getTransactions().size(), is(4)); - assertThat(fixture.account.getTransactions().size(), is(2)); - assertThat(runtimeProjectionUUIDs(fixture).size(), is(6)); - } - /** * Checks the Ledger-V6 scenario: build and add invalid code domain leaves client unchanged. * The result must keep ledger truth and visible runtime rows consistent. @@ -616,46 +443,6 @@ public void testBuildAndAddStructuralValidationFailureLeavesClientUnchanged() assertClientUnchanged(fixture); } - /** - * Checks the Ledger-V6 scenario: generated detached entry passes structural validator when added to scratch ledger. - * The result must keep ledger truth and visible runtime rows consistent. - * This protects against duplicate truth or partial mutation. - */ - @Test - public void testGeneratedDetachedEntryPassesStructuralValidatorWhenAddedToScratchLedger() - { - var fixture = fixture(); - var result = validSpinOff(fixture).buildDetached(); - var ledger = new Ledger(); - - ledger.addEntry(result.getEntry()); - - assertTrue(LedgerStructuralValidator.validate(ledger).isOK()); - } - - /** - * Checks the Ledger-V6 scenario: generated descriptors target assembler owned postings. - * The result must keep ledger truth and visible runtime rows consistent. - * This protects against duplicate truth or partial mutation. - */ - @Test - public void testGeneratedDescriptorsTargetAssemblerOwnedPostings() - { - var fixture = fixture(); - var entry = validSpinOff(fixture).buildDetached().getEntry(); - var postingUUIDs = entry.getPostings().stream().map(LedgerPosting::getUUID).collect(Collectors.toSet()); - - for (var ref : name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry)) - { - assertTrue(postingUUIDs.contains(ref.getPrimaryPosting().getUUID())); - assertThat(ref.getPrimaryPosting().getUnitRole(), is(LedgerPostingUnitRole.PRIMARY)); - - if (ref.getPrimaryPosting().getGroupKey() != null) - assertTrue(ref.getUnitPostings().stream() - .allMatch(posting -> ref.getPrimaryPosting().getGroupKey().equals(posting.getGroupKey()))); - } - } - private static LedgerNativeEntryAssembler.EntryBuilder validSpinOff(Fixture fixture) { return baseSpinOff(fixture) // diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorServiceTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorServiceTest.java index 527b5d8802..ca22954dce 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorServiceTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorServiceTest.java @@ -172,55 +172,6 @@ public void testSecurityTransferDerivesSourceAndTargetWithoutPostingOrder() LedgerProjectionRole.TARGET_PORTFOLIO); } - @Test - public void testSiemensSpinOffDescriptorsDeriveTargetedRuntimeViews() - { - var fixture = fixture(); - var entry = spinOffEntry(fixture); - - var descriptors = descriptors(entry); - - assertThat(roles(descriptors), is(Set.of(LedgerProjectionRole.OLD_SECURITY_LEG, - LedgerProjectionRole.DELIVERY_INBOUND, LedgerProjectionRole.NEW_SECURITY_LEG, - LedgerProjectionRole.CASH_COMPENSATION))); - assertDescriptor(entry, descriptor(descriptors, LedgerProjectionRole.OLD_SECURITY_LEG), - LedgerProjectionRole.OLD_SECURITY_LEG); - assertDescriptor(entry, descriptor(descriptors, LedgerProjectionRole.DELIVERY_INBOUND), - LedgerProjectionRole.DELIVERY_INBOUND); - assertDescriptor(entry, descriptor(descriptors, LedgerProjectionRole.NEW_SECURITY_LEG), - LedgerProjectionRole.NEW_SECURITY_LEG); - assertDescriptor(entry, descriptor(descriptors, LedgerProjectionRole.CASH_COMPENSATION), - LedgerProjectionRole.CASH_COMPENSATION); - } - - @Test - public void testSecurityContextDoesNotDeriveDescriptor() - { - var fixture = fixture(); - var entry = spinOffEntry(fixture); - - entry.addPosting(securityContextPosting("context-1", fixture.portfolio, fixture.siemens)); //$NON-NLS-1$ - - var descriptors = descriptors(entry); - - assertThat(roles(descriptors), is(Set.of(LedgerProjectionRole.OLD_SECURITY_LEG, - LedgerProjectionRole.DELIVERY_INBOUND, LedgerProjectionRole.NEW_SECURITY_LEG, - LedgerProjectionRole.CASH_COMPENSATION))); - assertFalse(descriptors.stream().map(DerivedProjectionDescriptor::getPrimaryPosting) - .anyMatch(posting -> posting.getCorporateActionLeg() == CorporateActionLeg.SECURITY_CONTEXT)); - } - - @Test - public void testExistingSpinOffDescriptorRuntimeIdRemainsRoleOnly() - { - var entry = spinOffEntry(fixture()); - - var descriptor = descriptor(descriptors(entry), LedgerProjectionRole.NEW_SECURITY_LEG); - - assertFalse(descriptor.hasSemanticInstanceKey()); - assertThat(descriptor.getRuntimeProjectionId(), is("spin-off:NEW_SECURITY_LEG")); - } - @Test public void testRepeatedTargetDescriptorsUseSemanticInstanceKeys() { From 399c54887fa4849cdaabdbc0d5d40cb417a426f2 Mon Sep 17 00:00:00 2001 From: Morpheus1w3 <103072607+Morpheus1w3@users.noreply.github.com> Date: Sun, 5 Jul 2026 22:50:07 +0200 Subject: [PATCH 68/68] Consolidate Ledger test suite Removes historical low-level Ledger compatibility, native assembler, transaction creator, spin-off scenario, and save/load parity slice tests while keeping the durable migration, persistence, projection, editor, validator, and UI guardrail coverage as the source of truth. This reduces the broad Ledger test inventory substantially and keeps the real Ledger-specific suite below the requested 30k-line target when non-Ledger importer false positives are excluded. Production code, registry definitions, schemas, projection behavior, legacy transaction types, UI/import/report/tax/FIFO/performance, and correction/reversal behavior are intentionally unchanged. --- .../model/LedgerSaveLoadParityTest.java | 863 ------------------ .../ledger/LedgerSpinOffScenarioTest.java | 619 ------------- .../ledger/LedgerTransactionCreatorTest.java | 792 ---------------- ...dgerAccountOnlyTransactionCreatorTest.java | 439 --------- ...TransferToDepositRemovalConverterTest.java | 651 ------------- ...AccountTransferTransactionCreatorTest.java | 420 --------- .../LedgerAccountTypeToggleConverterTest.java | 483 ---------- .../LedgerBuySellDeliveryConverterTest.java | 854 ----------------- .../LedgerBuySellReversalConverterTest.java | 482 ---------- .../LedgerBuySellTransactionCreatorTest.java | 529 ----------- .../LedgerDeliveryDirectionConverterTest.java | 416 --------- .../LedgerDeliveryTransactionCreatorTest.java | 424 --------- .../LedgerDividendTransactionCreatorTest.java | 394 -------- .../LedgerInvestmentPlanRefSupportTest.java | 107 --- ...dgerNativeComponentInspectorModelTest.java | 276 ------ ...rtfolioTransferTransactionCreatorTest.java | 433 --------- .../LedgerTransferDirectionConverterTest.java | 562 ------------ .../LedgerNativeEntryAssemblerTest.java | 746 --------------- 18 files changed, 9490 deletions(-) delete mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerSaveLoadParityTest.java delete mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffScenarioTest.java delete mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerTransactionCreatorTest.java delete mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountOnlyTransactionCreatorTest.java delete mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferToDepositRemovalConverterTest.java delete mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferTransactionCreatorTest.java delete mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTypeToggleConverterTest.java delete mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellDeliveryConverterTest.java delete mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellReversalConverterTest.java delete mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellTransactionCreatorTest.java delete mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryDirectionConverterTest.java delete mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryTransactionCreatorTest.java delete mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDividendTransactionCreatorTest.java delete mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerInvestmentPlanRefSupportTest.java delete mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerNativeComponentInspectorModelTest.java delete mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferTransactionCreatorTest.java delete mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerTransferDirectionConverterTest.java delete mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblerTest.java diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerSaveLoadParityTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerSaveLoadParityTest.java deleted file mode 100644 index a84e9e92cc..0000000000 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerSaveLoadParityTest.java +++ /dev/null @@ -1,863 +0,0 @@ -package name.abuchen.portfolio.model; - -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.junit.Assert.assertTrue; - -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.IOException; -import java.math.BigDecimal; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.time.Instant; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.Comparator; -import java.util.List; - -import org.junit.Test; - -import name.abuchen.portfolio.model.Transaction.Unit; -import name.abuchen.portfolio.model.ledger.LedgerEntry; -import name.abuchen.portfolio.model.ledger.LedgerParameter; -import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; -import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; -import name.abuchen.portfolio.model.ledger.LedgerTransactionMetadata; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountCashLeg; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerCashTransferLeg; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerCreationUnit; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerCreationUnits; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerDeliveryLeg; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerDividend; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerForexAmount; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerOptionalSecurity; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioSecurityLeg; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferLeg; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferSecurity; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerSecurityQuantity; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerTransactionCreator; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerUnitPostingEdit; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerUnitPostingPatch; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerUnitPostingUpdater; -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.model.ledger.projection.LedgerBackedTransaction; -import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionService; -import name.abuchen.portfolio.model.proto.v1.PClient; -import name.abuchen.portfolio.money.CurrencyUnit; -import name.abuchen.portfolio.money.Money; -import name.abuchen.portfolio.money.Values; - -/** - * Tests persistence compatibility for ledger-backed transactions. - * These tests make sure save/load rebuilds runtime rows from ledger truth and keeps compatibility data stable. - */ -@SuppressWarnings("nls") -public class LedgerSaveLoadParityTest -{ - private static final byte[] PROTOBUF_SIGNATURE = new byte[] { 'P', 'P', 'P', 'B', 'V', '1' }; - private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 3, 4, 5, 6); - private static final LocalDateTime EX_DATE = LocalDateTime.of(2026, 2, 27, 0, 0); - private static final Instant UPDATED_AT = Instant.parse("2026-03-04T05:06:07Z"); - - /** - * Verifies that XML save-load-save keeps ledger truth and runtime projections equivalent. - * Repeated persistence must not drift owner lists away from the ledger. - */ - @Test - public void testXmlSaveLoadSavePreservesLedgerTruthAndRuntimeProjectionParity() throws Exception - { - var fixture = parityFixture(); - var expectedLedger = ledgerSnapshot(fixture.client()); - var expectedTransactions = transactionSnapshots(fixture.client()); - var expectedPlans = planSnapshots(fixture.client()); - - var firstXml = saveXml(fixture.client()); - var loaded = loadXml(firstXml); - var secondXml = saveXml(loaded); - var reloaded = loadXml(secondXml); - - assertXmlContainsLedgerTruthOnly(secondXml); - assertParity(reloaded, expectedLedger, expectedTransactions, expectedPlans); - } - - /** - * Verifies that protobuf save-load-save keeps ledger truth and runtime projections equivalent. - * Repeated persistence must not drift owner lists away from the ledger. - */ - @Test - public void testProtobufSaveLoadSavePreservesLedgerTruthAndRuntimeProjectionParity() throws Exception - { - var fixture = parityFixture(); - var expectedLedger = ledgerSnapshot(fixture.client()); - var expectedTransactions = transactionSnapshots(fixture.client()); - var expectedPlans = planSnapshots(fixture.client()); - - var loaded = loadProtobuf(saveProtobuf(fixture.client())); - var secondBytes = saveProtobuf(loaded); - var reloaded = loadProtobuf(secondBytes); - var secondProto = parseProtobuf(secondBytes); - - assertThat(secondProto.getLedger().getEntriesCount(), is(8)); - assertThat(secondProto.getTransactionsCount(), is(8)); - assertParity(reloaded, expectedLedger, expectedTransactions, expectedPlans); - } - - /** - * Verifies that XML and protobuf restore equivalent ledger truth and projections. - * Both formats must materialize the same owner-list views from the persisted ledger. - */ - @Test - public void testXmlAndProtobufRoundtripsRestoreEquivalentLedgerTruthAndProjections() throws Exception - { - var fixture = parityFixture(); - var xmlLoaded = loadXml(saveXml(fixture.client())); - var protobufLoaded = loadProtobuf(saveProtobuf(fixture.client())); - - assertThat(ledgerSnapshot(protobufLoaded), is(ledgerSnapshot(xmlLoaded))); - assertThat(transactionSnapshots(protobufLoaded), is(transactionSnapshots(xmlLoaded))); - assertThat(planSnapshots(protobufLoaded), is(planSnapshots(xmlLoaded))); - } - - /** - * Verifies that ledger parameters remain owned by their entry or posting after both roundtrips. - * Persistence must not move business facts between ledger levels. - */ - - - /** - * Verifies that the new ledger parameter vocabulary roundtrips through XML and protobuf. - * Boolean and local-date facts must survive in both persistence formats. - */ - - - /** - * Verifies that hidden transfer units and primary forex facts survive both formats. - * Transfer persistence must not lose facts that are not visible as normal runtime projections. - */ - @Test - public void testXmlAndProtobufRoundtripsPreserveHiddenTransferUnitsAndPrimaryForex() throws Exception - { - var fixture = hiddenTransferFactsFixture(); - var expectedLedger = ledgerSnapshot(fixture.client()); - var expectedTransactions = transactionSnapshots(fixture.client()); - - var xmlLoaded = loadXml(saveXml(fixture.client())); - var protobufLoaded = loadProtobuf(saveProtobuf(fixture.client())); - - assertValid(xmlLoaded); - assertThat(ledgerSnapshot(xmlLoaded), is(expectedLedger)); - assertThat(transactionSnapshots(xmlLoaded), is(expectedTransactions)); - - assertValid(protobufLoaded); - assertThat(ledgerSnapshot(protobufLoaded), is(expectedLedger)); - assertThat(transactionSnapshots(protobufLoaded), is(expectedTransactions)); - } - - /** - * Verifies that deposit and removal units survive XML and protobuf roundtrips. - * Splitting or standalone cash bookings must keep their unit facts. - */ - @Test - public void testXmlAndProtobufRoundtripsPreserveDepositAndRemovalUnits() throws Exception - { - var fixture = depositRemovalUnitFactsFixture(); - var expectedLedger = ledgerSnapshot(fixture.client()); - var expectedTransactions = transactionSnapshots(fixture.client()); - - var xmlLoaded = loadXml(saveXml(fixture.client())); - var protobufLoaded = loadProtobuf(saveProtobuf(fixture.client())); - - assertValid(xmlLoaded); - assertThat(ledgerSnapshot(xmlLoaded), is(expectedLedger)); - assertThat(transactionSnapshots(xmlLoaded), is(expectedTransactions)); - - assertValid(protobufLoaded); - assertThat(ledgerSnapshot(protobufLoaded), is(expectedLedger)); - assertThat(transactionSnapshots(protobufLoaded), is(expectedTransactions)); - } - - /** - * Verifies that newly created standard ledger transactions rematerialize and roundtrip. - * Creator output must remain stable through both persistence formats. - */ - @Test - public void testNewlyCreatedStandardTransactionsRematerializeAndRoundtrip() throws Exception - { - var fixture = standardCreationFixture(); - var expectedLedger = ledgerSnapshot(fixture.client()); - var expectedProjectionUUIDs = projectionUUIDs(fixture.client()); - - clearLedgerBackedRuntimeProjections(fixture.client()); - var result = LedgerProjectionService.restoreIfValid(fixture.client()); - - assertTrue(result.format(), result.isOK()); - assertThat(ledgerSnapshot(fixture.client()), is(expectedLedger)); - assertMaterializedProjectionUUIDs(fixture.client(), expectedProjectionUUIDs); - assertThat(fixture.client().getAllTransactions().size(), is(fixture.client().getLedger().getEntries().size())); - - var xmlLoaded = loadXml(saveXml(fixture.client())); - assertThat(ledgerSnapshot(xmlLoaded), is(expectedLedger)); - assertMaterializedProjectionUUIDs(xmlLoaded, expectedProjectionUUIDs); - assertThat(xmlLoaded.getAllTransactions().size(), is(xmlLoaded.getLedger().getEntries().size())); - - var protobufLoaded = loadProtobuf(saveProtobuf(fixture.client())); - assertThat(ledgerSnapshot(protobufLoaded), is(expectedLedger)); - assertMaterializedProjectionUUIDs(protobufLoaded, expectedProjectionUUIDs); - assertThat(protobufLoaded.getAllTransactions().size(), is(protobufLoaded.getLedger().getEntries().size())); - } - - /** - * Verifies that old scalar-only XML remains loadable and is adapted in memory. - * Backward compatibility must not require persisted projection memberships. - */ - - - /** - * Verifies that XML persists projection memberships on new files. - * Membership roles and posting targets must survive save/load/save. - */ - - - /** - * Verifies that protobuf persists projection memberships on new files. - * Membership roles and posting targets must survive save/load/save. - */ - - - /** - * Verifies that XML scalar and membership target conflicts are diagnosed. - * Load recovery may return the client, but validation must keep the conflict visible. - */ - - - /** - * Verifies that invalid XML projection memberships do not make a parseable file unloadable. - * The invalid Ledger entry remains available and is not materialized. - */ - - - /** - * Verifies that invalid protobuf projection memberships do not make a parseable file unloadable. - * The invalid Ledger entry remains available and is not materialized. - */ - - - /** - * Verifies that XML ledger truth with a broken primary posting ref loads without materialization. - * The invalid Ledger entry must remain available for later diagnostic and repair workflows. - */ - - - /** - * Verifies that invalid protobuf ledger truth loads without shadow remigration. - * Compatibility rows must not hide an invalid persisted ledger entry. - */ - - - private void assertParity(Client client, LedgerSnapshot expectedLedger, List expectedTransactions, - List expectedPlans) - { - assertValid(client); - assertThat(ledgerSnapshot(client), is(expectedLedger)); - assertThat(transactionSnapshots(client), is(expectedTransactions)); - assertThat(planSnapshots(client), is(expectedPlans)); - assertPlanExecutionRefsRestored(client); - assertNoDuplicates(client); - } - - private void assertNoDuplicates(Client client) - { - var projectionUUIDs = client.getLedger().getEntries().stream() - .flatMap(entry -> name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream()).map(descriptor -> descriptor.getRuntimeProjectionId()) - .toList(); - var materializedUUIDs = client.getAccounts().stream().flatMap(account -> account.getTransactions().stream()) - .filter(LedgerBackedTransaction.class::isInstance).map(Transaction::getUUID) - .toList(); - materializedUUIDs = java.util.stream.Stream - .concat(materializedUUIDs.stream(), - client.getPortfolios().stream() - .flatMap(portfolio -> portfolio.getTransactions().stream()) - .filter(LedgerBackedTransaction.class::isInstance) - .map(Transaction::getUUID)) - .toList(); - - assertThat(client.getLedger().getEntries().size(), is(8)); - assertThat(client.getAllTransactions().size(), is(8)); - assertThat(projectionUUIDs.stream().distinct().count(), is((long) projectionUUIDs.size())); - assertThat(materializedUUIDs.stream().distinct().count(), is((long) materializedUUIDs.size())); - assertThat(materializedUUIDs.stream().sorted().toList(), is(projectionUUIDs.stream().sorted().toList())); - } - - private void assertMaterializedProjectionUUIDs(Client client, List expectedProjectionUUIDs) - { - assertValid(client); - assertThat(materializedProjectionUUIDs(client).size(), is(expectedProjectionUUIDs.size())); - assertTrue(client.getAllTransactions().stream().allMatch(pair -> pair.getTransaction() - instanceof LedgerBackedTransaction)); - } - - private List projectionUUIDs(Client client) - { - return client.getLedger().getEntries().stream() // - .flatMap(entry -> name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream()) // - .map(descriptor -> descriptor.getRuntimeProjectionId()) // - .sorted() // - .toList(); - } - - private List materializedProjectionUUIDs(Client client) - { - return java.util.stream.Stream - .concat(client.getAccounts().stream().flatMap(account -> account.getTransactions().stream()), - client.getPortfolios().stream() - .flatMap(portfolio -> portfolio.getTransactions().stream())) - .filter(LedgerBackedTransaction.class::isInstance) // - .map(Transaction::getUUID) // - .sorted() // - .toList(); - } - - private void clearLedgerBackedRuntimeProjections(Client client) - { - for (var account : client.getAccounts()) - account.getTransactions().removeIf(LedgerBackedTransaction.class::isInstance); - - for (var portfolio : client.getPortfolios()) - portfolio.getTransactions().removeIf(LedgerBackedTransaction.class::isInstance); - } - - private void assertXmlContainsLedgerTruthOnly(String xml) - { - assertTrue(xml.contains("")); - assertThat(xml.contains("LedgerBacked"), is(false)); - assertThat(xml.contains(" new ProjectionSnapshot(descriptor.getRole(), - uuid(descriptor.getAccount()), uuid(descriptor.getPortfolio()), - descriptor.getPrimaryPosting().getType(), - descriptor.getPrimaryPosting().getGroupKey())) - .sorted(Comparator.comparing(ProjectionSnapshot::role)).toList()); - } - - private PostingSnapshot postingSnapshot(LedgerPosting posting) - { - return new PostingSnapshot(posting.getType(), posting.getAmount(), posting.getCurrency(), - posting.getForexAmount(), posting.getForexCurrency(), posting.getExchangeRate(), - uuid(posting.getSecurity()), posting.getShares(), uuid(posting.getAccount()), - uuid(posting.getPortfolio()), - posting.getParameters().stream().map(this::parameterSnapshot) - .sorted(Comparator.comparing(ParameterSnapshot::type) - .thenComparing(ParameterSnapshot::value)) - .toList()); - } - - private ParameterSnapshot parameterSnapshot(LedgerParameter parameter) - { - return new ParameterSnapshot(parameter.getType(), parameter.getValueKind(), String.valueOf(parameter.getValue())); - } - - - private List transactionSnapshots(Client client) - { - return client.getAllTransactions().stream().map(pair -> transactionSnapshot(pair.getOwner(), pair.getTransaction())) - .sorted(Comparator.comparing(TransactionSnapshot::ownerUUID) - .thenComparing(TransactionSnapshot::transactionClass) - .thenComparing(TransactionSnapshot::type)).toList(); - } - - private TransactionSnapshot transactionSnapshot(TransactionOwner owner, Transaction transaction) - { - var crossEntry = transaction.getCrossEntry(); - - return new TransactionSnapshot(owner.getUUID(), transaction.getClass().getSimpleName(), typeName(transaction), - transaction.getDateTime(), transaction.getAmount(), - transaction.getCurrencyCode(), uuid(transaction.getSecurity()), transaction.getShares(), - transaction.getNote(), transaction.getSource(), exDate(transaction), unitSnapshots(transaction), - crossEntry != null ? crossEntry.getCrossOwner(transaction).getUUID() : null); - } - - private String typeName(Transaction transaction) - { - if (transaction instanceof AccountTransaction accountTransaction) - return accountTransaction.getType().name(); - if (transaction instanceof PortfolioTransaction portfolioTransaction) - return portfolioTransaction.getType().name(); - throw new AssertionError(transaction.getClass().getName()); - } - - private LocalDateTime exDate(Transaction transaction) - { - if (transaction instanceof AccountTransaction accountTransaction) - return accountTransaction.getExDate(); - return null; - } - - private List unitSnapshots(Transaction transaction) - { - return transaction.getUnits().map(this::unitSnapshot) - .sorted(Comparator.comparing(UnitSnapshot::type).thenComparing(UnitSnapshot::amount) - .thenComparing(UnitSnapshot::forexAmount, - Comparator.nullsFirst(Comparator.naturalOrder()))) - .toList(); - } - - private UnitSnapshot unitSnapshot(Unit unit) - { - return new UnitSnapshot(unit.getType(), unit.getAmount().getAmount(), unit.getAmount().getCurrencyCode(), - unit.getForex() != null ? unit.getForex().getAmount() : null, - unit.getForex() != null ? unit.getForex().getCurrencyCode() : null, unit.getExchangeRate()); - } - - private List planSnapshots(Client client) - { - return client.getPlans().stream() - .map(plan -> new PlanSnapshot(plan.getName(), - plan.getTransactions(client).stream() - .map(pair -> new PlanTransactionSnapshot(pair.getOwner().getUUID(), - typeName(pair.getTransaction()))) - .toList())) - .toList(); - } - - private void assertPlanExecutionRefsRestored(Client client) - { - assertThat(client.getPlans().stream().flatMap(plan -> plan.getTransactions().stream()).count(), is(0L)); - assertThat(client.getPlans().stream().flatMap(plan -> plan.getLedgerExecutionRefs().stream()).count(), is(0L)); - } - - private ParityFixture parityFixture() - { - var client = new Client(); - var account = register(client, account("Account")); - var otherAccount = register(client, account("Other Account")); - var portfolio = register(client, portfolio("Portfolio")); - var otherPortfolio = register(client, portfolio("Other Portfolio")); - var security = register(client, security()); - var creator = new LedgerTransactionCreator(client); - - var deposit = creator.createDeposit(metadata("deposit"), LedgerAccountCashLeg.of(account, money(11))).getEntry(); - creator.createDividend(metadata("dividend"), - LedgerDividend.withExDate( - LedgerAccountCashLeg.of(account, money(120), - LedgerForexAmount.of( - Money.of(CurrencyUnit.USD, - Values.Amount.factorize(132)), - new BigDecimal("1.1000"))), - LedgerOptionalSecurity.of(security), - LedgerCreationUnits.of( - LedgerCreationUnit.fee(money(2), - LedgerForexAmount.of( - Money.of(CurrencyUnit.USD, - Values.Amount - .factorize(4)), - new BigDecimal("0.5000"))), - LedgerCreationUnit.tax(money(3)), - LedgerCreationUnit.grossValue(money(125), - LedgerForexAmount.of( - Money.of(CurrencyUnit.USD, - Values.Amount - .factorize(250)), - new BigDecimal("0.5000")))), - EX_DATE)) - .getEntry(); - var buy = creator.createBuy(metadata("buy"), - LedgerAccountCashLeg.of(account, money(100), - LedgerForexAmount.of( - Money.of(CurrencyUnit.USD, Values.Amount.factorize(80)), - new BigDecimal("1.2500"))), - LedgerPortfolioSecurityLeg.of(portfolio, - LedgerSecurityQuantity.of(security, Values.Share.factorize(5)), money(100), - LedgerForexAmount.of( - Money.of(CurrencyUnit.USD, Values.Amount.factorize(125)), - new BigDecimal("0.8000"))), - LedgerCreationUnits.of(LedgerCreationUnit.fee(money(4)))).getEntry(); - creator.createSell(metadata("sell"), LedgerAccountCashLeg.of(account, money(50)), - securityLeg(portfolio, security, 2, 50), LedgerCreationUnits.none()); - creator.createAccountTransfer(metadata("account transfer"), LedgerCashTransferLeg.of(account, money(15)), - LedgerCashTransferLeg.of(otherAccount, money(15))); - creator.createPortfolioTransfer(metadata("portfolio transfer"), - LedgerPortfolioTransferSecurity.of(security, Values.Share.factorize(3)), - LedgerPortfolioTransferLeg.of(portfolio, money(30)), - LedgerPortfolioTransferLeg.of(otherPortfolio, money(30))); - var inboundDelivery = creator.createInboundDelivery(metadata("inbound delivery"), - LedgerDeliveryLeg.of(otherPortfolio, - LedgerSecurityQuantity.of(security, Values.Share.factorize(4)), money(80))) - .getEntry(); - creator.createOutboundDelivery(metadata("outbound delivery"), - LedgerDeliveryLeg.of(portfolio, - LedgerSecurityQuantity.of(security, Values.Share.factorize(1)), money(20))); - client.getLedger().getEntries().forEach(entry -> entry.setUpdatedAt(UPDATED_AT)); - - LedgerProjectionService.materialize(client); - - addPlan(client, "Deposit Plan", account, portfolio, security, - ledgerBacked(account.getTransactions(), projectionUUID(deposit, LedgerProjectionRole.ACCOUNT))); - addPlan(client, "Buy Plan", account, portfolio, security, - ledgerBacked(portfolio.getTransactions(), projectionUUID(buy, LedgerProjectionRole.PORTFOLIO))); - addPlan(client, "Delivery Plan", account, otherPortfolio, security, - ledgerBacked(otherPortfolio.getTransactions(), - projectionUUID(inboundDelivery, LedgerProjectionRole.DELIVERY_INBOUND))); - - assertValid(client); - - return new ParityFixture(client); - } - - private ParityFixture standardCreationFixture() - { - var client = new Client(); - var account = register(client, account("Account")); - var otherAccount = register(client, account("Other Account")); - var portfolio = register(client, portfolio("Portfolio")); - var otherPortfolio = register(client, portfolio("Other Portfolio")); - var security = register(client, security()); - var creator = new LedgerTransactionCreator(client); - - creator.createDeposit(metadata("deposit"), LedgerAccountCashLeg.of(account, money(11))); - creator.createBuy(metadata("buy"), LedgerAccountCashLeg.of(account, money(100)), - securityLeg(portfolio, security, 5, 100), LedgerCreationUnits.none()); - creator.createSell(metadata("sell"), LedgerAccountCashLeg.of(account, money(50)), - securityLeg(portfolio, security, 2, 50), LedgerCreationUnits.none()); - creator.createInboundDelivery(metadata("inbound delivery"), - LedgerDeliveryLeg.of(otherPortfolio, - LedgerSecurityQuantity.of(security, Values.Share.factorize(4)), money(80))); - creator.createOutboundDelivery(metadata("outbound delivery"), - LedgerDeliveryLeg.of(portfolio, - LedgerSecurityQuantity.of(security, Values.Share.factorize(1)), money(20))); - creator.createAccountTransfer(metadata("account transfer"), LedgerCashTransferLeg.of(account, money(15)), - LedgerCashTransferLeg.of(otherAccount, money(15))); - creator.createPortfolioTransfer(metadata("portfolio transfer"), - LedgerPortfolioTransferSecurity.of(security, Values.Share.factorize(3)), - LedgerPortfolioTransferLeg.of(portfolio, money(30)), - LedgerPortfolioTransferLeg.of(otherPortfolio, money(30))); - - LedgerProjectionService.materialize(client); - - assertValid(client); - assertMaterializedProjectionUUIDs(client, projectionUUIDs(client)); - - return new ParityFixture(client); - } - - private ParityFixture hiddenTransferFactsFixture() - { - var client = new Client(); - var account = register(client, account("Account")); - var otherAccount = register(client, account("Other Account")); - var portfolio = register(client, portfolio("Portfolio")); - var otherPortfolio = register(client, portfolio("Other Portfolio")); - var security = register(client, security()); - var creator = new LedgerTransactionCreator(client); - var accountTransfer = creator.createAccountTransfer(metadata("account transfer"), - LedgerCashTransferLeg.of(account, money(15), - LedgerForexAmount.of(Money.of(CurrencyUnit.USD, Values.Amount.factorize(12)), - new BigDecimal("1.2500"))), - LedgerCashTransferLeg.of(otherAccount, money(16), - LedgerForexAmount.of(Money.of(CurrencyUnit.USD, Values.Amount.factorize(20)), - new BigDecimal("0.8000")))) - .getEntry(); - var portfolioTransfer = creator.createPortfolioTransfer(metadata("portfolio transfer"), - LedgerPortfolioTransferSecurity.of(security, Values.Share.factorize(3)), - LedgerPortfolioTransferLeg.of(portfolio, money(30), - LedgerForexAmount.of( - Money.of(CurrencyUnit.USD, Values.Amount.factorize(24)), - new BigDecimal("1.2500"))), - LedgerPortfolioTransferLeg.of(otherPortfolio, money(30), - LedgerForexAmount.of( - Money.of(CurrencyUnit.USD, Values.Amount.factorize(40)), - new BigDecimal("0.7500")))) - .getEntry(); - - addHiddenUnits(accountTransfer); - addHiddenUnits(portfolioTransfer); - - client.getLedger().getEntries().forEach(entry -> entry.setUpdatedAt(UPDATED_AT)); - LedgerProjectionService.materialize(client); - - assertValid(client); - - return new ParityFixture(client); - } - - private ParityFixture depositRemovalUnitFactsFixture() - { - var client = new Client(); - var account = register(client, account("Account")); - var creator = new LedgerTransactionCreator(client); - var units = LedgerCreationUnits.of( - LedgerCreationUnit.grossValue(money(30), - LedgerForexAmount.of( - Money.of(CurrencyUnit.USD, Values.Amount.factorize(24)), - new BigDecimal("1.2500"))), - LedgerCreationUnit.fee(money(3), - LedgerForexAmount.of( - Money.of(CurrencyUnit.USD, Values.Amount.factorize(6)), - new BigDecimal("0.5000"))), - LedgerCreationUnit.tax(money(4), - LedgerForexAmount.of( - Money.of(CurrencyUnit.USD, Values.Amount.factorize(5)), - new BigDecimal("0.8000")))); - - creator.createDeposit(metadata("deposit"), LedgerAccountCashLeg.of(account, money(11)), units); - creator.createRemoval(metadata("removal"), LedgerAccountCashLeg.of(account, money(12)), units); - - client.getLedger().getEntries().forEach(entry -> entry.setUpdatedAt(UPDATED_AT)); - LedgerProjectionService.materialize(client); - - assertValid(client); - - return new ParityFixture(client); - } - - private void addHiddenUnits(LedgerEntry entry) - { - new LedgerUnitPostingUpdater().apply(entry, LedgerUnitPostingPatch.of( - LedgerUnitPostingEdit.add(LedgerPostingType.GROSS_VALUE, money(30), - LedgerForexAmount.of( - Money.of(CurrencyUnit.USD, Values.Amount.factorize(24)), - new BigDecimal("1.2500"))), - LedgerUnitPostingEdit.add(LedgerPostingType.FEE, money(3), - LedgerForexAmount.of( - Money.of(CurrencyUnit.USD, Values.Amount.factorize(6)), - new BigDecimal("0.5000"))), - LedgerUnitPostingEdit.add(LedgerPostingType.TAX, money(4)))); - } - - private void addPlan(Client client, String name, Account account, Portfolio portfolio, Security security, - Transaction transaction) - { - var plan = plan(name, account, portfolio, security); - - plan.getTransactions().add(transaction); - client.addPlan(plan); - } - - private InvestmentPlan plan(String name, Account account, Portfolio portfolio, Security security) - { - var plan = new InvestmentPlan(name); - - plan.setType(InvestmentPlan.Type.PURCHASE_OR_DELIVERY); - plan.setAccount(account); - plan.setPortfolio(portfolio); - plan.setSecurity(security); - plan.setStart(LocalDate.of(2026, 3, 1)); - plan.setInterval(1); - plan.setAmount(Values.Amount.factorize(100)); - - return plan; - } - - private LedgerTransactionMetadata metadata(String note) - { - return LedgerTransactionMetadata.of(DATE_TIME).withNote(note).withSource("source-" + note); - } - - private LedgerPortfolioSecurityLeg securityLeg(Portfolio portfolio, Security security, int shares, int amount) - { - return LedgerPortfolioSecurityLeg.of(portfolio, - LedgerSecurityQuantity.of(security, Values.Share.factorize(shares)), money(amount)); - } - - private Transaction ledgerBacked(List transactions, String uuid) - { - return transactions.stream().filter(LedgerBackedTransaction.class::isInstance) - .filter(transaction -> uuid.equals(transaction.getUUID())).findFirst().orElseThrow(); - } - - private String projectionUUID(LedgerEntry entry, LedgerProjectionRole role) - { - return name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream().filter(ref -> ref.getRole() == role).findFirst().orElseThrow() - .getRuntimeProjectionId(); - } - - private String saveXml(Client client) throws IOException - { - var file = File.createTempFile("ledger-parity", ".xml"); - - try - { - ClientFactory.save(client, file); - return Files.readString(file.toPath(), StandardCharsets.UTF_8); - } - finally - { - Files.deleteIfExists(file.toPath()); - } - } - - private Client loadXml(String xml) throws IOException - { - return ClientFactory.load(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); - } - - private byte[] saveProtobuf(Client client) throws IOException - { - var stream = new ByteArrayOutputStream(); - - new ProtobufWriter().save(client, stream); - - return stream.toByteArray(); - } - - private Client loadProtobuf(byte[] bytes) throws IOException - { - return new ProtobufWriter().load(new ByteArrayInputStream(bytes)); - } - - private PClient parseProtobuf(byte[] bytes) throws IOException - { - return PClient.parseFrom(new ByteArrayInputStream(bytes, PROTOBUF_SIGNATURE.length, - bytes.length - PROTOBUF_SIGNATURE.length)); - } - - private void assertValid(Client client) - { - assertTrue(LedgerStructuralValidator.validate(client.getLedger()).isOK()); - } - - private String uuid(Object object) - { - if (object instanceof Account account) - return account.getUUID(); - if (object instanceof Portfolio portfolio) - return portfolio.getUUID(); - if (object instanceof Security security) - return security.getUUID(); - return null; - } - - private Account register(Client client, Account account) - { - client.addAccount(account); - return account; - } - - private Portfolio register(Client client, Portfolio portfolio) - { - client.addPortfolio(portfolio); - return portfolio; - } - - private Security register(Client client, Security security) - { - client.addSecurity(security); - return security; - } - - private Account account(String name) - { - var account = new Account(); - - account.setName(name); - account.setCurrencyCode(CurrencyUnit.EUR); - account.setUpdatedAt(UPDATED_AT); - - return account; - } - - private Portfolio portfolio(String name) - { - var portfolio = new Portfolio(); - - portfolio.setName(name); - portfolio.setUpdatedAt(UPDATED_AT); - - return portfolio; - } - - private Security security() - { - var security = new Security("Security", CurrencyUnit.EUR); - - security.setUpdatedAt(UPDATED_AT); - - return security; - } - - private Money money(int amount) - { - return Money.of(CurrencyUnit.EUR, Values.Amount.factorize(amount)); - } - - private record ParityFixture(Client client) - { - } - - private record LedgerSnapshot(List entries) - { - } - - private record EntrySnapshot(LedgerEntryType type, LocalDateTime dateTime, String note, String source, - List parameters, List postings, - List descriptors) - { - } - - private record PostingSnapshot(LedgerPostingType type, long amount, String currency, Long forexAmount, - String forexCurrency, BigDecimal exchangeRate, String securityUUID, long shares, String accountUUID, - String portfolioUUID, List parameters) - { - } - - private record ParameterSnapshot(LedgerParameterType type, LedgerParameter.ValueKind valueKind, - String value) - { - } - - private record ProjectionSnapshot(LedgerProjectionRole role, String accountUUID, String portfolioUUID, - LedgerPostingType primaryPostingType, String groupKey) - { - } - - private record TransactionSnapshot(String ownerUUID, String transactionClass, String type, - LocalDateTime dateTime, long amount, String currency, String securityUUID, long shares, String note, - String source, LocalDateTime exDate, List units, String crossOwnerUUID) - { - } - - private record UnitSnapshot(Unit.Type type, long amount, String currency, Long forexAmount, String forexCurrency, - BigDecimal exchangeRate) - { - } - - private record PlanSnapshot(String name, List transactions) - { - } - - private record PlanTransactionSnapshot(String ownerUUID, String transactionType) - { - } - -} - - - diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffScenarioTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffScenarioTest.java deleted file mode 100644 index 8edcd2e99f..0000000000 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerSpinOffScenarioTest.java +++ /dev/null @@ -1,619 +0,0 @@ -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.assertSame; -import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; - -import java.io.ByteArrayInputStream; -import java.math.BigDecimal; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.Instant; -import java.time.LocalDateTime; - -import org.junit.Test; - -import name.abuchen.portfolio.model.Account; -import name.abuchen.portfolio.model.AccountTransaction; -import name.abuchen.portfolio.model.Client; -import name.abuchen.portfolio.model.ClientFactory; -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.model.Transaction.Unit; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerAccountCashLeg; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerBuySellEdit; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerBuySellEditor; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerCreationUnits; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioSecurityLeg; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerSecurityQuantity; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerShareAdjustmentHelper; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerTransactionCreator; -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.EventStage; -import name.abuchen.portfolio.model.ledger.configuration.FeeReason; -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.model.ledger.nativeentry.LedgerNativeEntryAssembler; -import name.abuchen.portfolio.model.ledger.nativeentry.NativeCashCompensation; -import name.abuchen.portfolio.model.ledger.nativeentry.NativeCorporateActionEvent; -import name.abuchen.portfolio.model.ledger.nativeentry.NativeEntryMetadata; -import name.abuchen.portfolio.model.ledger.nativeentry.NativeFee; -import name.abuchen.portfolio.model.ledger.nativeentry.NativeSecurityLeg; -import name.abuchen.portfolio.model.ledger.nativeentry.NativeTax; -import name.abuchen.portfolio.model.ledger.nativeentry.Ratio; -import name.abuchen.portfolio.model.ledger.projection.LedgerBackedPortfolioTransaction; -import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; -import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionService; -import name.abuchen.portfolio.money.CurrencyUnit; -import name.abuchen.portfolio.money.Money; -import name.abuchen.portfolio.money.Values; - -/** - * Tests ledger-native entry assembly for advanced transaction shapes. - * These tests make sure structural facts can be represented without enabling unsupported UI workflows. - */ -@SuppressWarnings("nls") -public class LedgerSpinOffScenarioTest -{ - private static final Path XML_EXAMPLE = Path - .of("name.abuchen.portfolio.tests", "src", "name", "abuchen", "portfolio", "model", "ledger", - "ledger-v6-spin-off-siemens-energy-example.xml"); - - private static final LocalDateTime SPIN_OFF_DATE = LocalDateTime.of(2020, 9, 28, 0, 0); - private static final LocalDateTime BUY_DATE = LocalDateTime.of(2020, 1, 2, 0, 0); - private static final Instant UPDATED_AT = Instant.parse("2026-06-15T08:00:00Z"); - - /** - * Checks the Ledger-V6 scenario: share adjustment helper scales selected targeted spin off postings. - * The result must keep ledger truth and visible runtime rows consistent. - * This protects against duplicate truth or partial mutation. - */ - @Test - public void testShareAdjustmentHelperScalesSelectedTargetedSpinOffPostings() throws Exception - { - var fixture = fixture(); - var client = fixture.client(); - LedgerProjectionService.materialize(client); - - var entry = spinOffEntry(client); - var entryUUID = entry.getUUID(); - var postingUUIDs = entry.getPostings().stream().map(LedgerPosting::getUUID).toList(); - var projectionUUIDs = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream().map(descriptor -> descriptor.getRuntimeProjectionId()).toList(); - var selected = fixture.siemens().getTransactions(client).stream() - .map(pair -> (Transaction) pair.getTransaction()) - .filter(transaction -> transaction.getDateTime().isBefore(SPIN_OFF_DATE.plusDays(1))).toList(); - - LedgerShareAdjustmentHelper.plan(client, fixture.siemens(), selected, shares -> shares * 2).apply(); - LedgerProjectionService.materialize(client); - - var editedEntry = spinOffEntry(client); - assertThat(editedEntry.getUUID(), is(entryUUID)); - assertThat(editedEntry.getPostings().stream().map(LedgerPosting::getUUID).toList(), is(postingUUIDs)); - assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(editedEntry).stream().map(descriptor -> descriptor.getRuntimeProjectionId()).toList(), - is(projectionUUIDs)); - assertThat(buyProjection(client, fixture.siemens()).getShares(), is(Values.Share.factorize(20))); - - var oldLeg = portfolioProjection(fixture.portfolio(), LedgerProjectionRole.OLD_SECURITY_LEG); - var retainedLeg = portfolioProjection(fixture.portfolio(), LedgerProjectionRole.DELIVERY_INBOUND, - fixture.siemens()); - var newLeg = portfolioProjection(fixture.portfolio(), LedgerProjectionRole.NEW_SECURITY_LEG); - - assertThat(oldLeg.getShares(), is(Values.Share.factorize(20))); - assertThat(retainedLeg.getShares(), is(Values.Share.factorize(20))); - assertThat(retainedLeg.getShares() - oldLeg.getShares(), is(0L)); - assertThat(newLeg.getShares(), is(Values.Share.factorize(5))); - assertTrue(LedgerStructuralValidator.validate(client.getLedger()).isOK()); - - var loaded = loadXml(saveXml(client)); - assertThat(buyProjection(loaded, siemens(loaded)).getShares(), is(Values.Share.factorize(20))); - assertThat(portfolioProjection(loaded.getPortfolios().get(0), LedgerProjectionRole.OLD_SECURITY_LEG) - .getShares(), is(Values.Share.factorize(20))); - assertThat(portfolioProjection(loaded.getPortfolios().get(0), LedgerProjectionRole.DELIVERY_INBOUND, - siemens(loaded)).getShares(), is(Values.Share.factorize(20))); - assertThat(portfolioProjection(loaded.getPortfolios().get(0), LedgerProjectionRole.NEW_SECURITY_LEG) - .getShares(), is(Values.Share.factorize(5))); - } - - /** - * Checks the Ledger-V6 scenario: share adjustment helper rejects targeted projection without primary posting. - * The result must keep ledger truth and visible runtime rows consistent. - * This protects against duplicate truth or partial mutation. - */ - @Test - public void testShareAdjustmentHelperRejectsTargetedProjectionWithoutPrimaryPosting() - { - var client = new Client(); - var portfolio = portfolio("Targeted portfolio"); - var security = security("Targeted Security", "DE000TARGET0", "TGT.DE"); - var entry = new LedgerEntry(); - - client.addPortfolio(portfolio); - client.addSecurity(security); - entry.setType(LedgerEntryType.CORPORATE_ACTION); - entry.setDateTime(SPIN_OFF_DATE); - entry.addParameter(LedgerParameter.ofCode(LedgerParameterType.CORPORATE_ACTION_KIND, - CorporateActionKind.SPIN_OFF)); - - var posting = invalidTargetSecurityPosting(portfolio, security, Values.Share.factorize(10), - Values.Amount.factorize(100), CorporateActionLeg.TARGET_SECURITY.getCode(), security, - security); - entry.addPosting(posting); - client.getLedger().addEntry(entry); - - var exception = assertThrows(IllegalArgumentException.class, - () -> LedgerProjectionService.createProjection(entry, LedgerProjectionRole.NEW_SECURITY_LEG)); - - assertFalse(exception.getMessage().isBlank()); - assertThat(posting.getShares(), is(Values.Share.factorize(10))); - assertThat(client.getLedger().getEntries().size(), is(1)); - assertThat(portfolio.getTransactions().size(), is(0)); - } - - /** - * Checks the Ledger-V6 scenario: edit loaded spin off example buy shares only without uuid literals. - * The result must keep ledger truth and visible runtime rows consistent. - * This protects against duplicate truth or partial mutation. - */ - @Test - public void testEditLoadedSpinOffExampleBuySharesOnlyWithoutUuidLiterals() throws Exception - { - var client = loadXmlExample(); - var siemens = siemens(client); - var buy = buyProjection(client, siemens); - var buyEntry = ((LedgerBackedTransaction) buy).getLedgerEntry(); - var entryUUID = buyEntry.getUUID(); - var projectionUUIDs = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(buyEntry).stream().map(descriptor -> descriptor.getRuntimeProjectionId()).toList(); - - assertThat(buy.getShares(), is(Values.Share.factorize(10))); - - new LedgerBuySellEditor().apply((LedgerBackedPortfolioTransaction) buy, - LedgerBuySellEdit.builder().shares(Values.Share.factorize(100)).build()); - LedgerProjectionService.materialize(client); - - var editedBuy = buyProjection(client, siemens); - var editedEntry = ((LedgerBackedTransaction) editedBuy).getLedgerEntry(); - - assertThat(editedEntry.getUUID(), is(entryUUID)); - assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(editedEntry).stream().map(descriptor -> descriptor.getRuntimeProjectionId()).toList(), - is(projectionUUIDs)); - assertSame(siemens, editedBuy.getSecurity()); - assertThat(editedBuy.getShares(), is(Values.Share.factorize(100))); - // This is a shares-only correction; the supporting BUY cash amount remains unchanged. - assertThat(accountProjection(editedEntry).getAmount(), is(118640L)); - assertXmlRoundtripHasEditedBuy(client); - } - - /** - * Checks the Ledger-V6 scenario: edit loaded spin off example cash compensation without uuid literals. - * The result must keep ledger truth and visible runtime rows consistent. - * This protects against duplicate truth or partial mutation. - */ - @Test - public void testEditLoadedSpinOffExampleCashCompensationWithoutUuidLiterals() throws Exception - { - var client = loadXmlExample(); - var entry = spinOffEntry(client); - var oldSiemensOut = securityPosting(entry, siemens(client), CorporateActionLeg.SOURCE_SECURITY.getCode(), - siemensEnergy(client)); - var siemensBackIn = securityPosting(entry, siemens(client), CorporateActionLeg.TARGET_SECURITY.getCode(), - siemens(client)); - var siemensEnergyIn = securityPosting(entry, siemensEnergy(client), - CorporateActionLeg.TARGET_SECURITY.getCode(), siemensEnergy(client)); - var compensation = primaryPosting(entry, LedgerProjectionRole.CASH_COMPENSATION); - var entryUUID = entry.getUUID(); - var postingUUIDs = entry.getPostings().stream().map(LedgerPosting::getUUID).toList(); - var projectionUUIDs = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream().map(descriptor -> descriptor.getRuntimeProjectionId()).toList(); - - assertThat(compensation.getAmount(), is(Values.Amount.factorize(5))); - - new LedgerMutationContext(client).mutateEntry(entry, - edited -> primaryPosting(edited, LedgerProjectionRole.CASH_COMPENSATION) - .setAmount(Values.Amount.factorize(100))); - - var edited = spinOffEntry(client); - var editedCompensation = primaryPosting(edited, LedgerProjectionRole.CASH_COMPENSATION); - - assertThat(edited.getUUID(), is(entryUUID)); - assertThat(edited.getPostings().stream().map(LedgerPosting::getUUID).toList(), is(postingUUIDs)); - assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(edited).stream().map(descriptor -> descriptor.getRuntimeProjectionId()).toList(), - is(projectionUUIDs)); - assertSecurityPostingUnchanged(edited, oldSiemensOut); - assertSecurityPostingUnchanged(edited, siemensBackIn); - assertSecurityPostingUnchanged(edited, siemensEnergyIn); - assertThat(editedCompensation.getAmount(), is(Values.Amount.factorize(100))); - assertThat(accountProjection(client.getAccounts().get(0), LedgerProjectionRole.CASH_COMPENSATION).getAmount(), - is(Values.Amount.factorize(100))); - assertThat(accountProjection(client.getAccounts().get(0), LedgerProjectionRole.CASH_COMPENSATION) - .getUnit(Unit.Type.FEE).orElseThrow().getAmount().getAmount(), is(Values.Amount.factorize(2))); - assertThat(accountProjection(client.getAccounts().get(0), LedgerProjectionRole.CASH_COMPENSATION) - .getUnit(Unit.Type.TAX).orElseThrow().getAmount().getAmount(), is(Values.Amount.factorize(1))); - assertXmlRoundtripHasEditedCompensation(client); - } - - /** - * Checks the Ledger-V6 scenario: spin off documentation does not expose uuid construction. - * The result must keep ledger truth and visible runtime rows consistent. - * This protects against duplicate truth or partial mutation. - */ - private SpinOffFixture fixture() - { - var client = new Client(); - var account = account("Spin-off cash account"); - var portfolio = portfolio("Corporate action portfolio"); - var siemens = security("Siemens AG", "DE0007236101", "SIE.DE"); - var siemensEnergy = security("Siemens Energy AG", "DE000ENER6Y0", "ENR.DE"); - - client.addAccount(account); - client.addPortfolio(portfolio); - client.addSecurity(siemens); - client.addSecurity(siemensEnergy); - portfolio.setReferenceAccount(account); - createStandardDeposit(client, account); - createStandardBuy(client, account, portfolio, siemens); - createSpinOffEntry(client, account, portfolio, siemens, siemensEnergy); - - return new SpinOffFixture(client, account, portfolio, siemens, siemensEnergy); - } - - private LedgerEntry spinOffEntry(Client client) - { - return client.getLedger().getEntries().stream().filter(entry -> entry.getType() == LedgerEntryType.CORPORATE_ACTION) - .filter(entry -> SPIN_OFF_DATE.equals(entry.getDateTime())).findFirst().orElseThrow(); - } - - private LedgerEntry createSpinOffEntry(Client client, Account account, Portfolio portfolio, Security siemens, - Security siemensEnergy) - { - var entry = LedgerNativeEntryAssembler.forClient(client).spinOff() - .metadata(NativeEntryMetadata.of(SPIN_OFF_DATE) - .note("Siemens Energy spin-off") - .source("Ledger")) - .event(NativeCorporateActionEvent.builder() - .kind(CorporateActionKind.SPIN_OFF) - .subtype(CorporateActionSubtype.STANDARD) - .reference("SIEMENS-ENERGY-2020") - .stage(EventStage.SETTLED) - .effectiveDate(SPIN_OFF_DATE.toLocalDate()) - .build()) - .securityLeg(NativeSecurityLeg.source() - .portfolio(portfolio) - .security(siemens) - .shares(Values.Share.factorize(10)) - .amount(Money.of(CurrencyUnit.EUR, 109960L)) - .sourceSecurity(siemens) - .targetSecurity(siemensEnergy) - .ratio(Ratio.of(BigDecimal.ONE, BigDecimal.valueOf(2))) - .build()) - .securityLeg(NativeSecurityLeg.context() - .portfolio(portfolio) - .security(siemens) - .shares(0L) - .amount(Money.of(CurrencyUnit.EUR, 0L)) - .groupKey("main") - .localKey("context-1") - .build()) - .securityLeg(NativeSecurityLeg.target() - .portfolio(portfolio) - .security(siemens) - .shares(Values.Share.factorize(10)) - .amount(Money.of(CurrencyUnit.EUR, 109960L)) - .sourceSecurity(siemens) - .targetSecurity(siemens) - .ratio(Ratio.of(BigDecimal.ONE, BigDecimal.valueOf(2))) - .projectAs(LedgerProjectionRole.DELIVERY_INBOUND) - .build()) - .securityLeg(NativeSecurityLeg.target() - .portfolio(portfolio) - .security(siemensEnergy) - .shares(Values.Share.factorize(5)) - .amount(Money.of(CurrencyUnit.EUR, 10605L)) - .sourceSecurity(siemens) - .targetSecurity(siemensEnergy) - .ratio(Ratio.of(BigDecimal.ONE, BigDecimal.valueOf(2))) - .build()) - .cashCompensation(NativeCashCompensation.builder() - .account(account) - .amount(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(5))) - .kind(CashCompensationKind.CASH_IN_LIEU) - .build()) - .fee(NativeFee.of(account, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(2)), - FeeReason.CORPORATE_ACTION_FEE)) - .tax(NativeTax.withholding(account, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(1)))) - .buildAndAdd() - .getEntry(); - - entry.setUpdatedAt(UPDATED_AT); - - return entry; - } - - private void createStandardDeposit(Client client, Account account) - { - var entry = new LedgerTransactionCreator(client) - .createDeposit(LedgerTransactionMetadata.of(LocalDateTime.of(2019, 12, 30, 0, 0)), - LedgerAccountCashLeg.of(account, - Money.of(CurrencyUnit.EUR, - Values.Amount.factorize(10000)))) - .getEntry(); - - entry.setUpdatedAt(Instant.parse("2026-06-15T10:41:50.210577100Z")); - } - - private void createStandardBuy(Client client, Account account, Portfolio portfolio, Security siemens) - { - var entry = new LedgerTransactionCreator(client) - .createBuy(LedgerTransactionMetadata.of(BUY_DATE), - LedgerAccountCashLeg.of(account, Money.of(CurrencyUnit.EUR, 118640L)), - LedgerPortfolioSecurityLeg.of(portfolio, - LedgerSecurityQuantity.of(siemens, - Values.Share.factorize(10)), - Money.of(CurrencyUnit.EUR, 118640L)), - LedgerCreationUnits.none()) - .getEntry(); - - entry.setUpdatedAt(Instant.parse("2026-06-15T10:41:34.896212600Z")); - } - - private LedgerPosting invalidTargetSecurityPosting(Portfolio portfolio, Security security, long shares, long amount, - String leg, Security sourceSecurity, Security targetSecurity) - { - var posting = new LedgerPosting(); - - posting.setType(LedgerPostingType.SECURITY); - posting.setPortfolio(portfolio); - posting.setSecurity(security); - posting.setShares(shares); - posting.setAmount(amount); - posting.setCurrency(CurrencyUnit.EUR); - if (leg != null) - posting.addParameter(LedgerParameter.ofString( - LedgerParameterType.CORPORATE_ACTION_LEG, leg)); - if (sourceSecurity != null) - posting.addParameter(LedgerParameter.ofSecurity(LedgerParameterType.SOURCE_SECURITY, - sourceSecurity)); - if (targetSecurity != null) - posting.addParameter(LedgerParameter.ofSecurity(LedgerParameterType.TARGET_SECURITY, - targetSecurity)); - if (sourceSecurity != null && targetSecurity != null) - { - posting.addParameter(LedgerParameter.ofDecimal(LedgerParameterType.RATIO_NUMERATOR, - BigDecimal.ONE)); - posting.addParameter(LedgerParameter.ofDecimal(LedgerParameterType.RATIO_DENOMINATOR, - BigDecimal.valueOf(2))); - } - - return posting; - } - - private void assertSpinOffSiemensPositionUnchanged(Portfolio portfolio, Security siemens) - { - var oldLeg = portfolioProjection(portfolio, LedgerProjectionRole.OLD_SECURITY_LEG); - var retainedLeg = portfolioProjection(portfolio, LedgerProjectionRole.DELIVERY_INBOUND, siemens); - - assertThat(oldLeg.getShares(), is(Values.Share.factorize(10))); - assertThat(retainedLeg.getShares(), is(Values.Share.factorize(10))); - assertThat(retainedLeg.getShares() - oldLeg.getShares(), is(0L)); - } - - private LedgerPosting securityPosting(LedgerEntry entry, Security security, - String leg, Security targetSecurity) - { - return entry.getPostings().stream().filter(posting -> posting.getType() == LedgerPostingType.SECURITY) - .filter(posting -> posting.getSecurity() == security) - .filter(posting -> hasCorporateActionLeg(posting, leg)) - .filter(posting -> targetSecurity == null || hasTargetSecurity(posting, targetSecurity)) - .findFirst().orElseThrow(); - } - - private PortfolioTransaction portfolioProjection(Portfolio portfolio, LedgerProjectionRole role) - { - return portfolio.getTransactions().stream().filter(LedgerBackedTransaction.class::isInstance) - .filter(transaction -> ((LedgerBackedTransaction) transaction).getLedgerProjectionDescriptor() - .getRole() == role) - .findFirst().orElseThrow(); - } - - private PortfolioTransaction portfolioProjection(Portfolio portfolio, LedgerProjectionRole role, Security security) - { - return portfolio.getTransactions().stream().filter(LedgerBackedTransaction.class::isInstance) - .filter(transaction -> ((LedgerBackedTransaction) transaction).getLedgerProjectionDescriptor() - .getRole() == role) - .filter(transaction -> transaction.getSecurity() == security).findFirst().orElseThrow(); - } - - private AccountTransaction accountProjection(Account account, LedgerProjectionRole role) - { - return account.getTransactions().stream().filter(LedgerBackedTransaction.class::isInstance) - .filter(transaction -> ((LedgerBackedTransaction) transaction).getLedgerProjectionDescriptor() - .getRole() == role) - .findFirst().orElseThrow(); - } - - private AccountTransaction accountProjection(LedgerEntry entry) - { - var accountProjection = projection(entry, LedgerProjectionRole.ACCOUNT); - - return accountProjection.getAccount().getTransactions().stream().filter(LedgerBackedTransaction.class::isInstance) - .filter(transaction -> ((LedgerBackedTransaction) transaction).getLedgerEntry() == entry) - .filter(transaction -> ((LedgerBackedTransaction) transaction).getLedgerProjectionDescriptor() - .getRole() == LedgerProjectionRole.ACCOUNT) - .findFirst().orElseThrow(); - } - - private PortfolioTransaction buyProjection(Client client, Security siemens) - { - LedgerProjectionService.materialize(client); - - return client.getPortfolios().stream().flatMap(portfolio -> portfolio.getTransactions().stream()) - .filter(LedgerBackedTransaction.class::isInstance) - .filter(transaction -> transaction.getType() == PortfolioTransaction.Type.BUY) - .filter(transaction -> BUY_DATE.equals(transaction.getDateTime())) - .filter(transaction -> transaction.getSecurity() == siemens).findFirst().orElseThrow(); - } - - private name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor projection(LedgerEntry entry, LedgerProjectionRole role) - { - return name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream().filter(ref -> ref.getRole() == role).findFirst().orElseThrow(); - } - - private LedgerPosting primaryPosting(LedgerEntry entry, LedgerProjectionRole role) - { - return projection(entry, role).getPrimaryPosting(); - } - - private boolean hasCorporateActionLeg(LedgerPosting posting, String leg) - { - return posting.getParameters().stream() - .filter(parameter -> parameter.getType() == LedgerParameterType.CORPORATE_ACTION_LEG) - .anyMatch(parameter -> parameter.getValue().equals(leg)); - } - - private boolean hasTargetSecurity(LedgerPosting posting, Security security) - { - return posting.getParameters().stream() - .filter(parameter -> parameter.getType() == LedgerParameterType.TARGET_SECURITY) - .anyMatch(parameter -> parameter.getValue() == security); - } - - private void assertSecurityPostingUnchanged(LedgerEntry edited, LedgerPosting before) - { - var after = edited.getPostings().stream().filter(posting -> posting.getUUID().equals(before.getUUID())) - .findFirst().orElseThrow(); - - assertThat(after.getType(), is(before.getType())); - assertThat(after.getSecurity(), is(before.getSecurity())); - assertThat(after.getShares(), is(before.getShares())); - assertThat(after.getAmount(), is(before.getAmount())); - assertThat(after.getCurrency(), is(before.getCurrency())); - } - - private void assertXmlRoundtripHasEditedBuy(Client client) throws Exception - { - var loaded = loadXml(saveXml(client)); - var buy = buyProjection(loaded, siemens(loaded)); - - assertThat(buy.getShares(), is(Values.Share.factorize(100))); - assertFalse(saveXml(loaded).contains(" "DE0007236101".equals(security.getIsin())) - .findFirst().orElseThrow(); - } - - private Security siemensEnergy(Client client) - { - return client.getSecurities().stream().filter(security -> "DE000ENER6Y0".equals(security.getIsin())) - .findFirst().orElseThrow(); - } - - private String saveXml(Client client) throws Exception - { - var file = Files.createTempFile("ledger-spin-off", ".xml"); - - try - { - ClientFactory.save(client, file.toFile()); - return Files.readString(file, StandardCharsets.UTF_8); - } - finally - { - Files.deleteIfExists(file); - } - } - - private Client loadXml(String xml) throws Exception - { - return ClientFactory.load(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); - } - - private Account account(String name) - { - var account = new Account(); - - account.setName(name); - account.setCurrencyCode(CurrencyUnit.EUR); - account.setUpdatedAt(UPDATED_AT); - - return account; - } - - private Portfolio portfolio(String name) - { - var portfolio = new Portfolio(); - - portfolio.setName(name); - portfolio.setUpdatedAt(UPDATED_AT); - - return portfolio; - } - - private Security security(String name, String isin, String ticker) - { - var security = new Security(name, CurrencyUnit.EUR); - - security.setIsin(isin); - security.setTickerSymbol(ticker); - security.setUpdatedAt(UPDATED_AT); - - return security; - } - - private Path xmlExample() - { - return findInWorkingTree(XML_EXAMPLE); - } - - private Path findInWorkingTree(Path relativePath) - { - var current = Path.of("").toAbsolutePath(); - - while (current != null) - { - var candidate = current.resolve(relativePath); - - if (Files.exists(candidate)) - return candidate; - - current = current.getParent(); - } - - return Path.of("").toAbsolutePath().resolve(relativePath); - } - - private record SpinOffFixture(Client client, Account account, Portfolio portfolio, Security siemens, - Security siemensEnergy) - { - } -} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerTransactionCreatorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerTransactionCreatorTest.java deleted file mode 100644 index 7ad981cdc9..0000000000 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerTransactionCreatorTest.java +++ /dev/null @@ -1,792 +0,0 @@ -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.assertNull; -import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; - -import java.lang.reflect.Modifier; -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.Arrays; - -import org.junit.Test; - -import name.abuchen.portfolio.model.Account; -import name.abuchen.portfolio.model.Client; -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.compatibility.LedgerAccountCashLeg; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerCashTransferLeg; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerCreationUnit; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerCreationUnits; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerDeliveryLeg; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerDividend; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerForexAmount; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerOptionalSecurity; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioSecurityLeg; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferLeg; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerPortfolioTransferSecurity; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerSecurityQuantity; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerTransactionCreator; -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.model.ledger.projection.LedgerBackedAccountTransaction; -import name.abuchen.portfolio.model.ledger.projection.LedgerProjectionService; -import name.abuchen.portfolio.money.CurrencyUnit; -import name.abuchen.portfolio.money.Money; -import name.abuchen.portfolio.money.Values; - -/** - * Tests ledger-aware creation and editing of transactions in this family. - * These tests make sure user-visible rows are rebuilt from ledger truth and structural facts are not written through legacy projections. - */ -@SuppressWarnings("nls") -public class LedgerTransactionCreatorTest -{ - private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 1, 2, 0, 0); - - /** - * Checks the ledger-backed editing scenario: metadata allows absent blank note and blank source. - * The visible transaction must reflect the ledger entry after the operation. - * This protects structural facts from being written through legacy setters. - */ - @Test - public void testMetadataAllowsAbsentBlankNoteAndBlankSource() - { - var absent = LedgerTransactionMetadata.of(DATE_TIME); - var blankNote = absent.withNote(" "); - var blankSource = absent.withSource(""); - - assertNull(absent.getNote()); - assertNull(absent.getSource()); - assertThat(blankNote.getNote(), is(" ")); - assertThat(blankSource.getSource(), is("")); - } - - /** - * Checks the ledger-backed editing scenario: cash leg allows zero and negative amounts. - * The visible transaction must reflect the ledger entry after the operation. - * This protects structural facts from being written through legacy setters. - */ - @Test - public void testCashLegAllowsZeroAndNegativeAmounts() - { - var account = account(); - var zero = LedgerAccountCashLeg.of(account, Money.of(CurrencyUnit.EUR, 0L)); - var negative = LedgerAccountCashLeg.of(account, Money.of(CurrencyUnit.EUR, -1L)); - - assertThat(zero.getAmount().getAmount(), is(0L)); - assertThat(negative.getAmount().getAmount(), is(-1L)); - } - - /** - * Checks the ledger-backed editing scenario: security quantity allows zero and negative shares. - * The visible transaction must reflect the ledger entry after the operation. - * This protects structural facts from being written through legacy setters. - */ - @Test - public void testSecurityQuantityAllowsZeroAndNegativeShares() - { - var security = security(); - var zero = LedgerSecurityQuantity.of(security, 0L); - var negative = LedgerSecurityQuantity.of(security, -1L); - - assertThat(zero.getShares(), is(0L)); - assertThat(negative.getShares(), is(-1L)); - } - - /** - * Checks the ledger-backed editing scenario: account cash leg does not reject currency mismatch. - * The visible transaction must reflect the ledger entry after the operation. - * This protects structural facts from being written through legacy setters. - */ - @Test - public void testAccountCashLegDoesNotRejectCurrencyMismatch() - { - var account = account(); - - account.setCurrencyCode(CurrencyUnit.USD); - - var leg = LedgerAccountCashLeg.of(account, money(10)); - - assertSame(account, leg.getAccount()); - assertThat(leg.getAmount().getCurrencyCode(), is(CurrencyUnit.EUR)); - } - - /** - * Checks the ledger-backed editing scenario: create deposit adds one ledger entry. - * The visible transaction must reflect the ledger entry after the operation. - * This protects structural facts from being written through legacy setters. - */ - @Test - public void testCreateDepositAddsOneLedgerEntry() - { - var client = new Client(); - var account = account(); - var creator = new LedgerTransactionCreator(client); - - var created = creator.createDeposit(metadata(), cashLeg(account, 100)); - var entry = created.getEntry(); - - assertThat(client.getLedger().getEntries().size(), is(1)); - assertSame(entry, client.getLedger().getEntries().get(0)); - assertThat(entry.getType(), is(LedgerEntryType.DEPOSIT)); - assertThat(entry.getPostings().size(), is(1)); - assertThat(entry.getPostings().get(0).getType(), is(LedgerPostingType.CASH)); - assertThat(entry.getPostings().get(0).getAmount(), is(Values.Amount.factorize(100))); - assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).size(), is(1)); - assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0).getRole(), is(LedgerProjectionRole.ACCOUNT)); - assertSame(account, name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0).getAccount()); - assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0) - .getPrimaryPosting().getUUID(), is(entry.getPostings().get(0).getUUID())); - assertOK(client); - } - - /** - * Checks the ledger-backed editing scenario: create removal uses positive amount. - * The visible transaction must reflect the ledger entry after the operation. - * This protects structural facts from being written through legacy setters. - */ - @Test - public void testCreateRemovalUsesPositiveAmount() - { - var client = new Client(); - var creator = new LedgerTransactionCreator(client); - var created = creator.createRemoval(metadata(), cashLeg(account(), 25)); - - assertThat(created.getEntry().getType(), is(LedgerEntryType.REMOVAL)); - assertThat(created.getEntry().getPostings().get(0).getAmount(), is(Values.Amount.factorize(25))); - assertTrue(created.getEntry().getPostings().get(0).getAmount() > 0); - assertOK(client); - } - - /** - * Checks the ledger-backed editing scenario: create deposit and removal support units and unit forex. - * The visible transaction must reflect the ledger entry after the operation. - * This protects structural facts from being written through legacy setters. - */ - @Test - public void testCreateDepositAndRemovalSupportUnitsAndUnitForex() - { - var client = new Client(); - var account = account(); - var creator = new LedgerTransactionCreator(client); - var units = LedgerCreationUnits.of( - LedgerCreationUnit.grossValue(money(100), - LedgerForexAmount.of(Money.of(CurrencyUnit.USD, Values.Amount.factorize(90)), - new BigDecimal("1.1111"))), - LedgerCreationUnit.fee(money(2), - LedgerForexAmount.of(Money.of(CurrencyUnit.USD, Values.Amount.factorize(3)), - new BigDecimal("0.6667"))), - LedgerCreationUnit.tax(money(4), - LedgerForexAmount.of(Money.of(CurrencyUnit.USD, Values.Amount.factorize(5)), - new BigDecimal("0.8000")))); - - var deposit = creator.createDeposit(metadata(), cashLeg(account, 10), units).getEntry(); - var removal = creator.createRemoval(metadata(), cashLeg(account, 11), units).getEntry(); - - assertThat(deposit.getType(), is(LedgerEntryType.DEPOSIT)); - assertThat(removal.getType(), is(LedgerEntryType.REMOVAL)); - assertUnitForex(deposit, LedgerPostingType.GROSS_VALUE, Values.Amount.factorize(90), - new BigDecimal("1.1111")); - assertUnitForex(deposit, LedgerPostingType.FEE, Values.Amount.factorize(3), new BigDecimal("0.6667")); - assertUnitForex(deposit, LedgerPostingType.TAX, Values.Amount.factorize(5), new BigDecimal("0.8000")); - assertUnitForex(removal, LedgerPostingType.GROSS_VALUE, Values.Amount.factorize(90), - new BigDecimal("1.1111")); - assertUnitForex(removal, LedgerPostingType.FEE, Values.Amount.factorize(3), new BigDecimal("0.6667")); - assertUnitForex(removal, LedgerPostingType.TAX, Values.Amount.factorize(5), new BigDecimal("0.8000")); - - LedgerProjectionService.materialize(client); - - var depositProjection = (LedgerBackedAccountTransaction) account.getTransactions().get(0); - var removalProjection = (LedgerBackedAccountTransaction) account.getTransactions().get(1); - - assertThat(depositProjection.getUnit(name.abuchen.portfolio.model.Transaction.Unit.Type.GROSS_VALUE) - .orElseThrow().getForex().getAmount(), is(Values.Amount.factorize(90))); - assertThat(removalProjection.getUnit(name.abuchen.portfolio.model.Transaction.Unit.Type.TAX) - .orElseThrow().getForex().getAmount(), is(Values.Amount.factorize(5))); - assertOK(client); - } - - /** - * Checks the ledger-backed editing scenario: create interest supports optional security and units. - * The visible transaction must reflect the ledger entry after the operation. - * This protects structural facts from being written through legacy setters. - */ - @Test - public void testCreateInterestSupportsOptionalSecurityAndUnits() - { - var client = new Client(); - var creator = new LedgerTransactionCreator(client); - var security = security(); - var units = LedgerCreationUnits.of(LedgerCreationUnit.fee(money(1))); - - var created = creator.createInterest(metadata(), cashLeg(account(), 10), LedgerOptionalSecurity.of(security), - units); - var entry = created.getEntry(); - - assertThat(entry.getType(), is(LedgerEntryType.INTEREST)); - assertSame(security, entry.getPostings().get(0).getSecurity()); - assertTrue(entry.getPostings().stream().anyMatch(p -> p.getType() == LedgerPostingType.FEE)); - assertOK(client); - } - - /** - * Checks the ledger-backed editing scenario: create fee tax families create valid account shapes. - * The visible transaction must reflect the ledger entry after the operation. - * This protects structural facts from being written through legacy setters. - */ - @Test - public void testCreateFeeTaxFamiliesCreateValidAccountShapes() - { - var client = new Client(); - var creator = new LedgerTransactionCreator(client); - var account = account(); - var security = LedgerOptionalSecurity.none(); - var units = LedgerCreationUnits.none(); - - assertThat(creator.createFee(metadata(), LedgerAccountCashLeg.of(account, money(1)), security, units) - .getEntry().getType(), is(LedgerEntryType.FEES)); - assertThat(creator.createFeeRefund(metadata(), LedgerAccountCashLeg.of(account, money(2)), security, units) - .getEntry().getType(), is(LedgerEntryType.FEES_REFUND)); - assertThat(creator.createTax(metadata(), LedgerAccountCashLeg.of(account, money(3)), security, units) - .getEntry().getType(), is(LedgerEntryType.TAXES)); - assertThat(creator.createTaxRefund(metadata(), LedgerAccountCashLeg.of(account, money(4)), security, units) - .getEntry().getType(), is(LedgerEntryType.TAX_REFUND)); - assertOK(client); - } - - /** - * Checks the ledger-backed editing scenario: create standard families bind descriptors to primary postings. - * The visible transaction must reflect the ledger entry after the operation. - * This protects structural facts from being written through legacy setters. - */ - @Test - public void testCreateStandardFamiliesBindDescriptorsToPrimaryPostings() - { - assertAccountProjectionTargetsPrimaryPosting(LedgerEntryType.DEPOSIT, - creator -> creator.createDeposit(metadata(), cashLeg(account(), 10)).getEntry()); - assertAccountProjectionTargetsPrimaryPosting(LedgerEntryType.REMOVAL, - creator -> creator.createRemoval(metadata(), cashLeg(account(), 10)).getEntry()); - assertAccountProjectionTargetsPrimaryPosting(LedgerEntryType.INTEREST, - creator -> creator.createInterest(metadata(), cashLeg(account(), 10), - LedgerOptionalSecurity.none(), LedgerCreationUnits.none()).getEntry()); - assertAccountProjectionTargetsPrimaryPosting(LedgerEntryType.INTEREST_CHARGE, - creator -> creator.createInterestCharge(metadata(), cashLeg(account(), 10), - LedgerOptionalSecurity.none(), LedgerCreationUnits.none()).getEntry()); - assertAccountProjectionTargetsPrimaryPosting(LedgerEntryType.FEES, - creator -> creator.createFee(metadata(), cashLeg(account(), 10), - LedgerOptionalSecurity.none(), LedgerCreationUnits.none()).getEntry()); - assertAccountProjectionTargetsPrimaryPosting(LedgerEntryType.FEES_REFUND, - creator -> creator.createFeeRefund(metadata(), cashLeg(account(), 10), - LedgerOptionalSecurity.none(), LedgerCreationUnits.none()).getEntry()); - assertAccountProjectionTargetsPrimaryPosting(LedgerEntryType.TAXES, - creator -> creator.createTax(metadata(), cashLeg(account(), 10), - LedgerOptionalSecurity.none(), LedgerCreationUnits.none()).getEntry()); - assertAccountProjectionTargetsPrimaryPosting(LedgerEntryType.TAX_REFUND, - creator -> creator.createTaxRefund(metadata(), cashLeg(account(), 10), - LedgerOptionalSecurity.none(), LedgerCreationUnits.none()).getEntry()); - assertAccountProjectionTargetsPrimaryPosting(LedgerEntryType.DIVIDENDS, - creator -> creator.createDividend(metadata(), - LedgerDividend.withoutExDate(cashLeg(account(), 10), - LedgerOptionalSecurity.of(security()), - LedgerCreationUnits.none())) - .getEntry()); - - assertTwoProjectionTargetsPrimaryPostings(LedgerEntryType.BUY, - creator -> creator.createBuy(metadata(), cashLeg(account(), 10), - portfolioLeg(new Portfolio(), 10), LedgerCreationUnits.none()).getEntry(), - LedgerProjectionRole.ACCOUNT, LedgerProjectionRole.PORTFOLIO); - assertTwoProjectionTargetsPrimaryPostings(LedgerEntryType.SELL, - creator -> creator.createSell(metadata(), cashLeg(account(), 10), - portfolioLeg(new Portfolio(), 10), LedgerCreationUnits.none()).getEntry(), - LedgerProjectionRole.ACCOUNT, LedgerProjectionRole.PORTFOLIO); - - assertPortfolioProjectionTargetsPrimaryPosting(LedgerEntryType.DELIVERY_INBOUND, - creator -> creator.createInboundDelivery(metadata(), deliveryLeg(new Portfolio())).getEntry()); - assertPortfolioProjectionTargetsPrimaryPosting(LedgerEntryType.DELIVERY_OUTBOUND, - creator -> creator.createOutboundDelivery(metadata(), deliveryLeg(new Portfolio())).getEntry()); - - assertTwoProjectionTargetsPrimaryPostings(LedgerEntryType.CASH_TRANSFER, - creator -> creator.createAccountTransfer(metadata(), - LedgerCashTransferLeg.of(account(), money(10)), - LedgerCashTransferLeg.of(account(), money(10))).getEntry(), - LedgerProjectionRole.SOURCE_ACCOUNT, LedgerProjectionRole.TARGET_ACCOUNT); - assertTwoProjectionTargetsPrimaryPostings(LedgerEntryType.SECURITY_TRANSFER, - creator -> creator.createPortfolioTransfer(metadata(), - LedgerPortfolioTransferSecurity.of(security(), Values.Share.factorize(5)), - LedgerPortfolioTransferLeg.of(new Portfolio(), money(10)), - LedgerPortfolioTransferLeg.of(new Portfolio(), money(10))).getEntry(), - LedgerProjectionRole.SOURCE_PORTFOLIO, LedgerProjectionRole.TARGET_PORTFOLIO); - } - - @Test - public void testCreateStandardFamiliesEmitSemanticPostings() - { - assertAccountOnlySemantics(LedgerEntryType.DEPOSIT, - creator -> creator.createDeposit(metadata(), cashLeg(account(), 10)).getEntry(), - LedgerPostingType.CASH, LedgerPostingSemanticRole.CASH); - assertAccountOnlySemantics(LedgerEntryType.REMOVAL, - creator -> creator.createRemoval(metadata(), cashLeg(account(), 10)).getEntry(), - LedgerPostingType.CASH, LedgerPostingSemanticRole.CASH); - assertAccountOnlySemantics(LedgerEntryType.INTEREST, - creator -> creator.createInterest(metadata(), cashLeg(account(), 10), - LedgerOptionalSecurity.none(), LedgerCreationUnits.none()).getEntry(), - LedgerPostingType.CASH, LedgerPostingSemanticRole.CASH); - assertAccountOnlySemantics(LedgerEntryType.INTEREST_CHARGE, - creator -> creator.createInterestCharge(metadata(), cashLeg(account(), 10), - LedgerOptionalSecurity.none(), LedgerCreationUnits.none()).getEntry(), - LedgerPostingType.CASH, LedgerPostingSemanticRole.CASH); - assertAccountOnlySemantics(LedgerEntryType.DIVIDENDS, - creator -> creator.createDividend(metadata(), - LedgerDividend.withoutExDate(cashLeg(account(), 10), - LedgerOptionalSecurity.of(security()), - LedgerCreationUnits.none())) - .getEntry(), - LedgerPostingType.CASH, LedgerPostingSemanticRole.CASH); - assertAccountOnlySemantics(LedgerEntryType.FEES, - creator -> creator.createFee(metadata(), cashLeg(account(), 10), - LedgerOptionalSecurity.none(), LedgerCreationUnits.none()).getEntry(), - LedgerPostingType.FEE, LedgerPostingSemanticRole.FEE); - assertAccountOnlySemantics(LedgerEntryType.FEES_REFUND, - creator -> creator.createFeeRefund(metadata(), cashLeg(account(), 10), - LedgerOptionalSecurity.none(), LedgerCreationUnits.none()).getEntry(), - LedgerPostingType.FEE, LedgerPostingSemanticRole.FEE); - assertAccountOnlySemantics(LedgerEntryType.TAXES, - creator -> creator.createTax(metadata(), cashLeg(account(), 10), - LedgerOptionalSecurity.none(), LedgerCreationUnits.none()).getEntry(), - LedgerPostingType.TAX, LedgerPostingSemanticRole.TAX); - assertAccountOnlySemantics(LedgerEntryType.TAX_REFUND, - creator -> creator.createTaxRefund(metadata(), cashLeg(account(), 10), - LedgerOptionalSecurity.none(), LedgerCreationUnits.none()).getEntry(), - LedgerPostingType.TAX, LedgerPostingSemanticRole.TAX); - - var client = new Client(); - var creator = new LedgerTransactionCreator(client); - var buy = creator.createBuy(metadata(), cashLeg(account(), 10), portfolioLeg(new Portfolio(), 10), - LedgerCreationUnits.of(LedgerCreationUnit.fee(money(1)))).getEntry(); - - assertPrimarySemantics(buy.getPostings().get(0), LedgerPostingType.CASH, LedgerPostingSemanticRole.CASH, - LedgerPostingDirection.OUTBOUND, LedgerProjectionRole.ACCOUNT); - assertPrimarySemantics(buy.getPostings().get(1), LedgerPostingType.SECURITY, - LedgerPostingSemanticRole.SECURITY, LedgerPostingDirection.INBOUND, - LedgerProjectionRole.PORTFOLIO); - assertUnitSemantics(posting(buy, LedgerPostingType.FEE), LedgerPostingSemanticRole.FEE, - LedgerPostingUnitRole.FEE); - - var sell = creator.createSell(metadata(), cashLeg(account(), 10), portfolioLeg(new Portfolio(), 10), - LedgerCreationUnits.none()).getEntry(); - - assertPrimarySemantics(sell.getPostings().get(0), LedgerPostingType.CASH, LedgerPostingSemanticRole.CASH, - LedgerPostingDirection.INBOUND, LedgerProjectionRole.ACCOUNT); - assertPrimarySemantics(sell.getPostings().get(1), LedgerPostingType.SECURITY, - LedgerPostingSemanticRole.SECURITY, LedgerPostingDirection.OUTBOUND, - LedgerProjectionRole.PORTFOLIO); - } - - @Test - public void testCreateTransferAndDeliveryFamiliesEmitDirectionalSemanticPostings() - { - var client = new Client(); - var creator = new LedgerTransactionCreator(client); - var inbound = creator.createInboundDelivery(metadata(), deliveryLeg(new Portfolio())).getEntry(); - var outbound = creator.createOutboundDelivery(metadata(), deliveryLeg(new Portfolio())).getEntry(); - - assertPrimarySemantics(inbound.getPostings().get(0), LedgerPostingType.SECURITY, - LedgerPostingSemanticRole.SECURITY, LedgerPostingDirection.INBOUND, - LedgerProjectionRole.DELIVERY_INBOUND); - assertPrimarySemantics(outbound.getPostings().get(0), LedgerPostingType.SECURITY, - LedgerPostingSemanticRole.SECURITY, LedgerPostingDirection.OUTBOUND, - LedgerProjectionRole.DELIVERY_OUTBOUND); - - var cashTransfer = creator.createAccountTransfer(metadata(), LedgerCashTransferLeg.of(account(), money(10)), - LedgerCashTransferLeg.of(account(), money(10))).getEntry(); - - assertPrimarySemantics(cashTransfer.getPostings().get(0), LedgerPostingType.CASH, - LedgerPostingSemanticRole.CASH, LedgerPostingDirection.OUTBOUND, - LedgerProjectionRole.SOURCE_ACCOUNT); - assertPrimarySemantics(cashTransfer.getPostings().get(1), LedgerPostingType.CASH, - LedgerPostingSemanticRole.CASH, LedgerPostingDirection.INBOUND, - LedgerProjectionRole.TARGET_ACCOUNT); - - var securityTransfer = creator.createPortfolioTransfer(metadata(), - LedgerPortfolioTransferSecurity.of(security(), Values.Share.factorize(5)), - LedgerPortfolioTransferLeg.of(new Portfolio(), money(10)), - LedgerPortfolioTransferLeg.of(new Portfolio(), money(10))).getEntry(); - - assertPrimarySemantics(securityTransfer.getPostings().get(0), LedgerPostingType.SECURITY, - LedgerPostingSemanticRole.SECURITY, LedgerPostingDirection.OUTBOUND, - LedgerProjectionRole.SOURCE_PORTFOLIO); - assertPrimarySemantics(securityTransfer.getPostings().get(1), LedgerPostingType.SECURITY, - LedgerPostingSemanticRole.SECURITY, LedgerPostingDirection.INBOUND, - LedgerProjectionRole.TARGET_PORTFOLIO); - } - - /** - * Checks the ledger-backed editing scenario: create dividend preserves ex-date units and forex. - * The visible transaction must reflect the ledger entry after the operation. - * This protects structural facts from being written through legacy setters. - */ - @Test - public void testCreateDividendPreservesExDateUnitsAndForex() - { - var client = new Client(); - var creator = new LedgerTransactionCreator(client); - var exDate = LocalDateTime.of(2025, 12, 20, 0, 0); - var forex = LedgerForexAmount.of(Money.of(CurrencyUnit.USD, Values.Amount.factorize(30)), - BigDecimal.valueOf(0.91)); - var units = LedgerCreationUnits.of(LedgerCreationUnit.tax(money(3)), - LedgerCreationUnit.fee(money(2)), - LedgerCreationUnit.grossValue(money(35), forex)); - var dividend = LedgerDividend.withExDate(cashLeg(account(), 30), LedgerOptionalSecurity.of(security()), units, - exDate); - - var entry = creator.createDividend(metadata(), dividend).getEntry(); - var cashPosting = entry.getPostings().get(0); - var exDateParameter = cashPosting.getParameters().get(0); - var grossValuePosting = posting(entry, LedgerPostingType.GROSS_VALUE); - - assertThat(entry.getType(), is(LedgerEntryType.DIVIDENDS)); - assertThat(cashPosting.getType(), is(LedgerPostingType.CASH)); - assertThat(exDateParameter.getType(), is(LedgerParameterType.EX_DATE)); - assertThat(exDateParameter.getValueKind(), is(ValueKind.LOCAL_DATE_TIME)); - assertThat(exDateParameter.getValue(), is(exDate)); - assertTrue(entry.getPostings().stream().anyMatch(p -> p.getType() == LedgerPostingType.TAX)); - assertTrue(entry.getPostings().stream().anyMatch(p -> p.getType() == LedgerPostingType.FEE)); - assertThat(grossValuePosting.getForexAmount(), is(Values.Amount.factorize(30))); - assertThat(grossValuePosting.getForexCurrency(), is(CurrencyUnit.USD)); - assertThat(grossValuePosting.getExchangeRate(), is(BigDecimal.valueOf(0.91))); - assertOK(client); - } - - /** - * Checks the ledger-backed editing scenario: create buy creates two descriptors with positive magnitudes. - * The visible transaction must reflect the ledger entry after the operation. - * This protects structural facts from being written through legacy setters. - */ - @Test - public void testCreateBuyCreatesTwoDescriptorsWithPositiveMagnitudes() - { - var client = new Client(); - var account = account(); - var portfolio = new Portfolio(); - var creator = new LedgerTransactionCreator(client); - - var entry = creator.createBuy(metadata(), cashLeg(account, 100), portfolioLeg(portfolio, 100), - LedgerCreationUnits.of(LedgerCreationUnit.fee(money(1)))).getEntry(); - - assertThat(entry.getType(), is(LedgerEntryType.BUY)); - assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).size(), is(2)); - assertTrue(entry.getPostings().stream().allMatch(p -> p.getAmount() >= 0 && p.getShares() >= 0)); - assertSame(account, projection(entry, LedgerProjectionRole.ACCOUNT).getAccount()); - assertSame(portfolio, projection(entry, LedgerProjectionRole.PORTFOLIO).getPortfolio()); - assertTrue(account.getTransactions().isEmpty()); - assertTrue(portfolio.getTransactions().isEmpty()); - assertOK(client); - } - - /** - * Checks the ledger-backed editing scenario: create sell creates two descriptors with positive magnitudes. - * The visible transaction must reflect the ledger entry after the operation. - * This protects structural facts from being written through legacy setters. - */ - @Test - public void testCreateSellCreatesTwoDescriptorsWithPositiveMagnitudes() - { - var client = new Client(); - var creator = new LedgerTransactionCreator(client); - var entry = creator.createSell(metadata(), cashLeg(account(), 100), portfolioLeg(new Portfolio(), 100), - LedgerCreationUnits.none()).getEntry(); - - assertThat(entry.getType(), is(LedgerEntryType.SELL)); - assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).size(), is(2)); - assertTrue(entry.getPostings().stream().allMatch(p -> p.getAmount() >= 0 && p.getShares() >= 0)); - assertOK(client); - } - - /** - * Checks the ledger-backed editing scenario: create account transfer creates source and target account projections. - * The visible transaction must reflect the ledger entry after the operation. - * This protects structural facts from being written through legacy setters. - */ - @Test - public void testCreateAccountTransferCreatesSourceAndTargetAccountProjections() - { - var client = new Client(); - var source = account(); - var target = account(); - var creator = new LedgerTransactionCreator(client); - - var entry = creator.createAccountTransfer(metadata(), LedgerCashTransferLeg.of(source, money(100)), - LedgerCashTransferLeg.of(target, money(100))).getEntry(); - - assertThat(entry.getType(), is(LedgerEntryType.CASH_TRANSFER)); - assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).size(), is(2)); - assertSame(source, projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT).getAccount()); - assertSame(target, projection(entry, LedgerProjectionRole.TARGET_ACCOUNT).getAccount()); - assertOK(client); - } - - /** - * Checks the ledger-backed editing scenario: create portfolio transfer creates source and target portfolio projections. - * The visible transaction must reflect the ledger entry after the operation. - * This protects structural facts from being written through legacy setters. - */ - @Test - public void testCreatePortfolioTransferCreatesSourceAndTargetPortfolioProjections() - { - var client = new Client(); - var source = new Portfolio(); - var target = new Portfolio(); - var creator = new LedgerTransactionCreator(client); - - var entry = creator.createPortfolioTransfer(metadata(), - LedgerPortfolioTransferSecurity.of(security(), Values.Share.factorize(5)), - LedgerPortfolioTransferLeg.of(source, money(100)), - LedgerPortfolioTransferLeg.of(target, money(100))).getEntry(); - - assertThat(entry.getType(), is(LedgerEntryType.SECURITY_TRANSFER)); - assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).size(), is(2)); - assertSame(source, projection(entry, LedgerProjectionRole.SOURCE_PORTFOLIO).getPortfolio()); - assertSame(target, projection(entry, LedgerProjectionRole.TARGET_PORTFOLIO).getPortfolio()); - assertOK(client); - } - - /** - * Checks the ledger-backed editing scenario: create deliveries create one portfolio projection. - * The visible transaction must reflect the ledger entry after the operation. - * This protects structural facts from being written through legacy setters. - */ - @Test - public void testCreateDeliveriesCreateOnePortfolioProjection() - { - var client = new Client(); - var portfolio = new Portfolio(); - var creator = new LedgerTransactionCreator(client); - - var inbound = creator.createInboundDelivery(metadata(), deliveryLeg(portfolio)).getEntry(); - var outbound = creator.createOutboundDelivery(metadata(), deliveryLeg(portfolio)).getEntry(); - - assertThat(inbound.getType(), is(LedgerEntryType.DELIVERY_INBOUND)); - assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(inbound).size(), is(1)); - assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(inbound).get(0).getRole(), is(LedgerProjectionRole.DELIVERY_INBOUND)); - assertThat(outbound.getType(), is(LedgerEntryType.DELIVERY_OUTBOUND)); - assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(outbound).size(), is(1)); - assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(outbound).get(0).getRole(), is(LedgerProjectionRole.DELIVERY_OUTBOUND)); - assertTrue(portfolio.getTransactions().isEmpty()); - assertOK(client); - } - - /** - * Checks the ledger-backed editing scenario: missing required input does not partially add entry. - * The visible transaction must reflect the ledger entry after the operation. - * This protects structural facts from being written through legacy setters. - */ - @Test - public void testMissingRequiredInputDoesNotPartiallyAddEntry() - { - var client = new Client(); - var creator = new LedgerTransactionCreator(client); - - assertThrows(NullPointerException.class, () -> creator.createDeposit(metadata(), null)); - assertTrue(client.getLedger().getEntries().isEmpty()); - } - - /** - * Checks the ledger-backed editing scenario: invalid standard family sign fails through creator validation without partial mutation. - * The visible transaction must reflect the ledger entry after the operation. - * This protects structural facts from being written through legacy setters. - */ - @Test - public void testInvalidStandardFamilySignFailsThroughCreatorValidationWithoutPartialMutation() - { - var client = new Client(); - var creator = new LedgerTransactionCreator(client); - - assertThrows(IllegalArgumentException.class, - () -> creator.createDeposit(metadata(), - LedgerAccountCashLeg.of(account(), Money.of(CurrencyUnit.EUR, -1L)))); - assertTrue(client.getLedger().getEntries().isEmpty()); - } - - /** - * Checks the ledger-backed editing scenario: invalid standard family shares fail through creator validation without partial mutation. - * The visible transaction must reflect the ledger entry after the operation. - * This protects structural facts from being written through legacy setters. - */ - @Test - public void testInvalidStandardFamilySharesFailThroughCreatorValidationWithoutPartialMutation() - { - var client = new Client(); - var creator = new LedgerTransactionCreator(client); - - assertThrows(IllegalArgumentException.class, - () -> creator.createBuy(metadata(), cashLeg(account(), 100), - LedgerPortfolioSecurityLeg.of(new Portfolio(), - LedgerSecurityQuantity.of(security(), -1L), money(100)), - LedgerCreationUnits.none())); - assertTrue(client.getLedger().getEntries().isEmpty()); - } - - /** - * Checks the ledger-backed editing scenario: public creator methods do not use long positional parameter lists. - * The visible transaction must reflect the ledger entry after the operation. - * This protects structural facts from being written through legacy setters. - */ - @Test - public void testPublicCreatorMethodsDoNotUseLongPositionalParameterLists() - { - assertTrue(Arrays.stream(LedgerTransactionCreator.class.getDeclaredMethods()) - .filter(method -> Modifier.isPublic(method.getModifiers())) - .filter(method -> method.getName().startsWith("create")) - .allMatch(method -> method.getParameterCount() <= 4)); - assertFalse(Arrays.stream(LedgerTransactionCreator.class.getDeclaredMethods()) - .anyMatch(method -> method.getName().equals("createAccountTransaction"))); - assertFalse(Arrays.stream(LedgerTransactionCreator.class.getDeclaredMethods()) - .anyMatch(method -> method.getName().equals("createPortfolioTransaction"))); - assertFalse(Arrays.stream(LedgerTransactionCreator.class.getDeclaredMethods()) - .anyMatch(method -> method.getName().equals("createBuySell"))); - } - - private LedgerTransactionMetadata metadata() - { - return LedgerTransactionMetadata.of(DATE_TIME); - } - - private Account account() - { - return new Account(); - } - - private Security security() - { - return new Security("Security", CurrencyUnit.EUR); - } - - private LedgerAccountCashLeg cashLeg(Account account, int amount) - { - return LedgerAccountCashLeg.of(account, money(amount)); - } - - private LedgerDeliveryLeg deliveryLeg(Portfolio portfolio) - { - return LedgerDeliveryLeg.of(portfolio, LedgerSecurityQuantity.of(security(), Values.Share.factorize(5)), - money(100)); - } - - private LedgerPortfolioSecurityLeg portfolioLeg(Portfolio portfolio, int amount) - { - return LedgerPortfolioSecurityLeg.of(portfolio, - LedgerSecurityQuantity.of(security(), Values.Share.factorize(5)), money(amount)); - } - - private Money money(int amount) - { - return Money.of(CurrencyUnit.EUR, Values.Amount.factorize(amount)); - } - - private LedgerPosting posting(LedgerEntry entry, LedgerPostingType type) - { - return entry.getPostings().stream().filter(p -> p.getType() == type).findFirst().orElseThrow(); - } - - private void assertUnitForex(LedgerEntry entry, LedgerPostingType type, long forexAmount, BigDecimal exchangeRate) - { - var posting = posting(entry, type); - - assertThat(posting.getForexAmount(), is(forexAmount)); - assertThat(posting.getForexCurrency(), is(CurrencyUnit.USD)); - assertThat(posting.getExchangeRate(), is(exchangeRate)); - } - - private void assertAccountOnlySemantics(LedgerEntryType type, - java.util.function.Function factory, - LedgerPostingType postingType, LedgerPostingSemanticRole semanticRole) - { - var client = new Client(); - var entry = factory.apply(new LedgerTransactionCreator(client)); - - assertThat(entry.getType(), is(type)); - assertPrimarySemantics(entry.getPostings().get(0), postingType, semanticRole, LedgerPostingDirection.NEUTRAL, - LedgerProjectionRole.ACCOUNT); - assertOK(client); - } - - private void assertPrimarySemantics(LedgerPosting posting, LedgerPostingType type, - LedgerPostingSemanticRole semanticRole, LedgerPostingDirection direction, - LedgerProjectionRole localKey) - { - assertThat(posting.getType(), is(type)); - assertThat(posting.getSemanticRole(), is(semanticRole)); - assertThat(posting.getDirection(), is(direction)); - assertThat(posting.getUnitRole(), is(LedgerPostingUnitRole.PRIMARY)); - assertThat(posting.getLocalKey(), is(localKey.name())); - } - - private void assertUnitSemantics(LedgerPosting posting, LedgerPostingSemanticRole semanticRole, - LedgerPostingUnitRole unitRole) - { - assertThat(posting.getSemanticRole(), is(semanticRole)); - assertThat(posting.getUnitRole(), is(unitRole)); - } - - private name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor projection(LedgerEntry entry, LedgerProjectionRole role) - { - return name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream().filter(p -> p.getRole() == role).findFirst().orElseThrow(); - } - - private void assertAccountProjectionTargetsPrimaryPosting(LedgerEntryType type, - java.util.function.Function factory) - { - var client = new Client(); - var entry = factory.apply(new LedgerTransactionCreator(client)); - var projection = projection(entry, LedgerProjectionRole.ACCOUNT); - var posting = entry.getPostings().stream().filter(p -> p.getAccount() == projection.getAccount()).findFirst() - .orElseThrow(); - - assertThat(entry.getType(), is(type)); - assertThat(projection.getPrimaryPosting().getUUID(), is(posting.getUUID())); - assertOK(client); - } - - private void assertPortfolioProjectionTargetsPrimaryPosting(LedgerEntryType type, - java.util.function.Function factory) - { - var client = new Client(); - var entry = factory.apply(new LedgerTransactionCreator(client)); - var projection = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0); - var posting = entry.getPostings().stream().filter(p -> p.getPortfolio() == projection.getPortfolio()) - .findFirst().orElseThrow(); - - assertThat(entry.getType(), is(type)); - assertThat(projection.getPrimaryPosting().getUUID(), is(posting.getUUID())); - assertOK(client); - } - - private void assertTwoProjectionTargetsPrimaryPostings(LedgerEntryType type, - java.util.function.Function factory, - LedgerProjectionRole firstRole, LedgerProjectionRole secondRole) - { - var client = new Client(); - var entry = factory.apply(new LedgerTransactionCreator(client)); - - assertThat(entry.getType(), is(type)); - assertThat(projection(entry, firstRole).getPrimaryPosting().getUUID(), is(entry.getPostings().get(0).getUUID())); - assertThat(projection(entry, secondRole).getPrimaryPosting().getUUID(), - is(entry.getPostings().get(1).getUUID())); - assertOK(client); - } - - private void assertOK(Client client) - { - var result = LedgerStructuralValidator.validate(client.getLedger()); - - assertTrue(result.getIssues().toString(), result.isOK()); - } -} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountOnlyTransactionCreatorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountOnlyTransactionCreatorTest.java deleted file mode 100644 index 97a6d2912d..0000000000 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountOnlyTransactionCreatorTest.java +++ /dev/null @@ -1,439 +0,0 @@ -package name.abuchen.portfolio.model.ledger.compatibility; - -import static org.hamcrest.CoreMatchers.instanceOf; -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; - -import java.io.ByteArrayInputStream; -import java.io.File; -import java.io.IOException; -import java.math.BigDecimal; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.time.LocalDateTime; -import java.util.List; - -import org.junit.Test; - -import name.abuchen.portfolio.model.Account; -import name.abuchen.portfolio.model.AccountTransaction; -import name.abuchen.portfolio.model.Client; -import name.abuchen.portfolio.model.ClientFactory; -import name.abuchen.portfolio.model.ProtobufTestUtilities; -import name.abuchen.portfolio.model.Security; -import name.abuchen.portfolio.model.Transaction.Unit; -import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; -import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; -import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; -import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; -import name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor; -import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; -import name.abuchen.portfolio.money.CurrencyUnit; -import name.abuchen.portfolio.money.Money; -import name.abuchen.portfolio.money.Values; - -/** - * Tests ledger-aware creation and editing of transactions in this family. - * These tests make sure user-visible rows are rebuilt from ledger truth and structural facts are not written through legacy projections. - */ -@SuppressWarnings("nls") -public class LedgerAccountOnlyTransactionCreatorTest -{ - private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 6, 7, 8, 9); - - /** - * Verifies that standard account-only booking families are created directly in the ledger. - * The resulting account transaction must be a projection of the persisted ledger entry. - */ - @Test - public void testCreatesLedgerEntryDirectlyForAllAccountOnlyFamilies() - { - for (var fixture : fixtures()) - { - var client = new Client(); - var account = account(); - client.addAccount(account); - - var transaction = create(client, account, fixture.type()); - var entry = client.getLedger().getEntries().get(0); - var posting = entry.getPostings().get(0); - var projection = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0); - - assertThat(entry.getType(), is(fixture.entryType())); - assertThat(client.getLedger().getEntries().size(), is(1)); - assertThat(entry.getPostings().size(), is(1)); - assertThat(posting.getType(), is(fixture.postingType())); - assertThat(posting.getAmount(), is(Values.Amount.factorize(123))); - assertThat(posting.getCurrency(), is(CurrencyUnit.EUR)); - assertSame(account, posting.getAccount()); - assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).size(), is(1)); - assertSame(account, projection.getAccount()); - assertPrimaryDescriptor(projection, posting.getUUID()); - assertThat(transaction.getUUID(), is(projection.getRuntimeProjectionId())); - assertThat(transaction, instanceOf(LedgerBackedTransaction.class)); - assertThat(transaction.getType(), is(fixture.type())); - assertThat(transaction.getDateTime(), is(DATE_TIME)); - assertThat(transaction.getAmount(), is(Values.Amount.factorize(123))); - assertThat(transaction.getCurrencyCode(), is(CurrencyUnit.EUR)); - assertThat(transaction.getNote(), is("note")); - assertThat(transaction.getSource(), is("source")); - assertThat(account.getTransactions(), is(List.of(transaction))); - assertThat(client.getAllTransactions().size(), is(1)); - assertValid(client); - } - } - - /** - * Verifies that dialog-style account-only fee/tax input is stored as the primary posting. - * The UI can provide the amount through the matching unit field while the total is still zero. - */ - @Test - public void testCreatesFeeTaxPrimaryPostingFromMatchingDialogUnit() - { - assertPrimaryPostingFromMatchingDialogUnit(AccountTransaction.Type.FEES, LedgerEntryType.FEES, - LedgerPostingType.FEE, Unit.Type.FEE); - assertPrimaryPostingFromMatchingDialogUnit(AccountTransaction.Type.FEES_REFUND, LedgerEntryType.FEES_REFUND, - LedgerPostingType.FEE, Unit.Type.FEE); - assertPrimaryPostingFromMatchingDialogUnit(AccountTransaction.Type.TAXES, LedgerEntryType.TAXES, - LedgerPostingType.TAX, Unit.Type.TAX); - assertPrimaryPostingFromMatchingDialogUnit(AccountTransaction.Type.TAX_REFUND, LedgerEntryType.TAX_REFUND, - LedgerPostingType.TAX, Unit.Type.TAX); - } - - /** - * Verifies that optional security and unit facts are stored with account-only ledger bookings. - * The projected account transaction must expose the same business values. - */ - @Test - public void testCreatesOptionalSecurityAndUnitPostings() - { - var client = new Client(); - var account = account(); - var security = new Security("Security", CurrencyUnit.USD); - var units = List.of( - new Unit(Unit.Type.FEE, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(1))), - new Unit(Unit.Type.TAX, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(2))), - new Unit(Unit.Type.GROSS_VALUE, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(20)), - Money.of(CurrencyUnit.USD, Values.Amount.factorize(25)), - BigDecimal.valueOf(0.8))); - - client.addAccount(account); - client.addSecurity(security); - - var transaction = new LedgerAccountOnlyTransactionCreator(client).create(account, AccountTransaction.Type.FEES, - DATE_TIME, Values.Amount.factorize(17), CurrencyUnit.EUR, security, units, "note", "source"); - var entry = client.getLedger().getEntries().get(0); - var projection = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0); - - assertThat(transaction, instanceOf(LedgerBackedTransaction.class)); - assertSame(security, entry.getPostings().get(0).getSecurity()); - assertTrue(entry.getPostings().stream().anyMatch(posting -> posting.getType() == LedgerPostingType.FEE - && posting.getAmount() == Values.Amount.factorize(1))); - assertTrue(entry.getPostings().stream().anyMatch(posting -> posting.getType() == LedgerPostingType.TAX - && posting.getAmount() == Values.Amount.factorize(2))); - assertTrue(entry.getPostings().stream().anyMatch(posting -> posting.getType() == LedgerPostingType.GROSS_VALUE - && posting.getAmount() == Values.Amount.factorize(20) - && posting.getForexAmount() == Values.Amount.factorize(25) - && CurrencyUnit.USD.equals(posting.getForexCurrency()) - && BigDecimal.valueOf(0.8).equals(posting.getExchangeRate()))); - assertUnitRoleCount(projection, LedgerPostingUnitRole.FEE, 1); - assertUnitRoleCount(projection, LedgerPostingUnitRole.TAX, 1); - assertUnitRoleCount(projection, LedgerPostingUnitRole.GROSS_VALUE, 1); - assertValid(client); - } - - /** - * Verifies that unsupported account-only families are rejected before creation. - * The creator must not leave a partial ledger entry behind. - */ - @Test - public void testRejectsUnsupportedFamiliesWithoutMutation() - { - var client = new Client(); - var account = account(); - client.addAccount(account); - - assertThrows(UnsupportedOperationException.class, - () -> create(client, account, AccountTransaction.Type.DIVIDENDS)); - assertThrows(UnsupportedOperationException.class, () -> create(client, account, AccountTransaction.Type.BUY)); - assertThrows(UnsupportedOperationException.class, () -> create(client, account, AccountTransaction.Type.SELL)); - assertThrows(UnsupportedOperationException.class, - () -> create(client, account, AccountTransaction.Type.TRANSFER_IN)); - assertThrows(UnsupportedOperationException.class, - () -> create(client, account, AccountTransaction.Type.TRANSFER_OUT)); - - assertTrue(client.getLedger().getEntries().isEmpty()); - assertTrue(account.getTransactions().isEmpty()); - } - - /** - * Verifies that metadata setters remain allowed on account-only projections. - * Structural setters must stay blocked so runtime projections do not become a second truth. - */ - @Test - public void testCreatedProjectionAllowsMetadataEditsAndRejectsStructuralSetters() - { - var client = new Client(); - var account = account(); - client.addAccount(account); - - var transaction = create(client, account, AccountTransaction.Type.DEPOSIT); - var updatedDateTime = DATE_TIME.plusDays(1); - - transaction.setDateTime(updatedDateTime); - transaction.setNote("updated note"); - transaction.setSource("updated source"); - - var entry = client.getLedger().getEntries().get(0); - - assertThat(entry.getDateTime(), is(updatedDateTime)); - assertThat(entry.getNote(), is("updated note")); - assertThat(entry.getSource(), is("updated source")); - assertThrows(UnsupportedOperationException.class, () -> transaction.setAmount(1L)); - assertThrows(UnsupportedOperationException.class, () -> transaction.setCurrencyCode(CurrencyUnit.USD)); - assertThrows(UnsupportedOperationException.class, transaction::clearUnits); - assertValid(client); - } - - /** - * Verifies that same-shape account edits and owner moves are applied through ledger paths. - * The projected booking must move owners without replaying legacy setters as persisted truth. - */ - @Test - public void testFacadeAppliesSameShapeLedgerEditAndMovesOwner() throws Exception - { - var client = new Client(); - var account = account(); - var otherAccount = account(); - client.addAccount(account); - client.addAccount(otherAccount); - var creator = new LedgerAccountOnlyTransactionCreator(client); - var transaction = create(client, account, AccountTransaction.Type.DEPOSIT); - var updatedDateTime = DATE_TIME.plusDays(2); - var entry = client.getLedger().getEntries().get(0); - var entryUUID = entry.getUUID(); - var postingUUID = entry.getPostings().get(0).getUUID(); - var projectionUUID = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0) - .getRuntimeProjectionId(); - - creator.update(transaction, account, AccountTransaction.Type.DEPOSIT, updatedDateTime, - Values.Amount.factorize(456), CurrencyUnit.EUR, null, List.of( - new Unit(Unit.Type.FEE, Money.of(CurrencyUnit.EUR, - Values.Amount.factorize(1)))), - "updated note", "updated source"); - - assertThat(entry.getDateTime(), is(updatedDateTime)); - assertThat(entry.getPostings().get(0).getAmount(), is(Values.Amount.factorize(456))); - assertThat(transaction.getNote(), is("updated note")); - assertThat(transaction.getSource(), is("updated source")); - assertTrue(entry.getPostings().stream().anyMatch(posting -> posting.getType() == LedgerPostingType.FEE - && posting.getAmount() == Values.Amount.factorize(1))); - - var moved = creator.update(transaction, otherAccount, AccountTransaction.Type.DEPOSIT, updatedDateTime, - Values.Amount.factorize(789), CurrencyUnit.EUR, null, List.of(), "moved note", - "moved source"); - - assertTrue(account.getTransactions().isEmpty()); - assertThat(otherAccount.getTransactions(), is(List.of(moved))); - assertThat(moved.getUUID(), is(projectionUUID)); - assertThat(client.getLedger().getEntries().get(0).getUUID(), is(entryUUID)); - assertThat(entry.getPostings().get(0).getUUID(), is(postingUUID)); - assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0) - .getRuntimeProjectionId(), is(projectionUUID)); - assertSame(otherAccount, entry.getPostings().get(0).getAccount()); - assertSame(otherAccount, name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0).getAccount()); - assertThat(moved.getAmount(), is(Values.Amount.factorize(789))); - assertThat(moved.getNote(), is("moved note")); - assertThat(client.getAllTransactions().size(), is(1)); - assertThat(projectionRoles(loadXml(saveXml(client))), is(List.of(LedgerProjectionRole.ACCOUNT))); - assertThat(projectionRoles(loadProtobuf(saveProtobuf(client))), is(List.of(LedgerProjectionRole.ACCOUNT))); - assertValid(client); - } - - /** - * Verifies that XML save/load/save preserves account-only projection identity. - * The booking must rematerialize from the same ledger entry after reload. - */ - @Test - public void testXmlSaveLoadSavePreservesAccountOnlyLedgerProjectionUUID() throws Exception - { - var client = accountOnlyClient(); - var expectedProjectionRoles = projectionRoles(client); - - var loaded = loadXml(saveXml(client)); - var reloaded = loadXml(saveXml(loaded)); - - assertThat(projectionRoles(reloaded), is(expectedProjectionRoles)); - assertThat(reloaded.getLedger().getEntries().size(), is(8)); - assertThat(reloaded.getAccounts().get(0).getTransactions().size(), is(8)); - assertTrue(reloaded.getAccounts().get(0).getTransactions().stream() - .allMatch(LedgerBackedTransaction.class::isInstance)); - assertThat(reloaded.getAllTransactions().size(), is(8)); - assertValid(reloaded); - } - - /** - * Verifies that protobuf save/load/save preserves account-only projection identity. - * The booking must rematerialize from the same ledger entry after reload. - */ - @Test - public void testProtobufSaveLoadSavePreservesAccountOnlyLedgerProjectionUUID() throws Exception - { - var client = accountOnlyClient(); - var expectedProjectionRoles = projectionRoles(client); - - var loaded = loadProtobuf(saveProtobuf(client)); - var reloaded = loadProtobuf(saveProtobuf(loaded)); - - assertThat(projectionRoles(reloaded), is(expectedProjectionRoles)); - assertThat(reloaded.getLedger().getEntries().size(), is(8)); - assertThat(reloaded.getAccounts().get(0).getTransactions().size(), is(8)); - assertTrue(reloaded.getAccounts().get(0).getTransactions().stream() - .allMatch(LedgerBackedTransaction.class::isInstance)); - assertThat(reloaded.getAllTransactions().size(), is(8)); - assertValid(reloaded); - } - - private AccountTransaction create(Client client, Account account, AccountTransaction.Type type) - { - return new LedgerAccountOnlyTransactionCreator(client).create(account, type, DATE_TIME, - Values.Amount.factorize(123), CurrencyUnit.EUR, null, List.of(), "note", "source"); - } - - private void assertPrimaryPostingFromMatchingDialogUnit(AccountTransaction.Type type, LedgerEntryType entryType, - LedgerPostingType postingType, Unit.Type unitType) - { - var client = new Client(); - var account = account(); - var units = List.of(new Unit(unitType, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(123)))); - - client.addAccount(account); - - var transaction = new LedgerAccountOnlyTransactionCreator(client).create(account, type, DATE_TIME, 0, - CurrencyUnit.EUR, null, units, "note", "source"); - var entry = client.getLedger().getEntries().get(0); - var posting = entry.getPostings().get(0); - var projection = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0); - - assertThat(client.getLedger().getEntries().size(), is(1)); - assertThat(entry.getType(), is(entryType)); - assertThat(entry.getPostings().size(), is(1)); - assertThat(posting.getType(), is(postingType)); - assertThat(posting.getAmount(), is(Values.Amount.factorize(123))); - assertThat(posting.getCurrency(), is(CurrencyUnit.EUR)); - assertSame(account, posting.getAccount()); - assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).size(), is(1)); - assertPrimaryDescriptor(projection, posting.getUUID()); - assertThat(transaction, instanceOf(LedgerBackedTransaction.class)); - assertThat(transaction.getType(), is(type)); - assertThat(transaction.getAmount(), is(Values.Amount.factorize(123))); - assertThat(transaction.getCurrencyCode(), is(CurrencyUnit.EUR)); - assertThat(account.getTransactions(), is(List.of(transaction))); - assertThat(client.getAllTransactions().size(), is(1)); - assertValid(client); - } - - private Client accountOnlyClient() - { - var client = new Client(); - var account = account(); - client.addAccount(account); - - for (var fixture : fixtures()) - create(client, account, fixture.type()); - - return client; - } - - private List projectionRoles(Client client) - { - return client.getLedger().getEntries().stream() - .flatMap(entry -> name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream()) - .map(descriptor -> descriptor.getRole()) - .toList(); - } - - private void assertPrimaryDescriptor(DerivedProjectionDescriptor projection, String postingUUID) - { - assertThat(projection.getPrimaryPosting().getUUID(), is(postingUUID)); - assertThat(projection.getPrimaryPosting().getUnitRole(), is(LedgerPostingUnitRole.PRIMARY)); - } - - private void assertUnitRoleCount(DerivedProjectionDescriptor projection, LedgerPostingUnitRole role, int count) - { - assertThat((int) projection.getUnitPostings().stream().filter(posting -> posting.getUnitRole() == role).count(), - is(count)); - } - - private String saveXml(Client client) throws IOException - { - var file = File.createTempFile("ledger-account-only", ".xml"); - try - { - ClientFactory.save(client, file); - return Files.readString(file.toPath(), StandardCharsets.UTF_8); - } - finally - { - Files.deleteIfExists(file.toPath()); - } - } - - private Client loadXml(String xml) throws IOException - { - return ClientFactory.load(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); - } - - private byte[] saveProtobuf(Client client) throws IOException - { - return ProtobufTestUtilities.save(client); - } - - private Client loadProtobuf(byte[] bytes) throws IOException - { - return ProtobufTestUtilities.load(bytes); - } - - private void assertValid(Client client) - { - var result = LedgerStructuralValidator.validate(client.getLedger()); - - assertThat(result.getIssues().toString(), result.isOK(), is(true)); - } - - private Account account() - { - var account = new Account("Account"); - account.setCurrencyCode(CurrencyUnit.EUR); - return account; - } - - private List fixtures() - { - return List.of( - new Fixture(AccountTransaction.Type.DEPOSIT, LedgerEntryType.DEPOSIT, - LedgerPostingType.CASH), - new Fixture(AccountTransaction.Type.REMOVAL, LedgerEntryType.REMOVAL, - LedgerPostingType.CASH), - new Fixture(AccountTransaction.Type.INTEREST, LedgerEntryType.INTEREST, - LedgerPostingType.CASH), - new Fixture(AccountTransaction.Type.INTEREST_CHARGE, LedgerEntryType.INTEREST_CHARGE, - LedgerPostingType.CASH), - new Fixture(AccountTransaction.Type.FEES, LedgerEntryType.FEES, - LedgerPostingType.FEE), - new Fixture(AccountTransaction.Type.FEES_REFUND, LedgerEntryType.FEES_REFUND, - LedgerPostingType.FEE), - new Fixture(AccountTransaction.Type.TAXES, LedgerEntryType.TAXES, - LedgerPostingType.TAX), - new Fixture(AccountTransaction.Type.TAX_REFUND, LedgerEntryType.TAX_REFUND, - LedgerPostingType.TAX)); - } - - private record Fixture(AccountTransaction.Type type, LedgerEntryType entryType, LedgerPostingType postingType) - { - } -} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferToDepositRemovalConverterTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferToDepositRemovalConverterTest.java deleted file mode 100644 index 69dcadddcf..0000000000 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferToDepositRemovalConverterTest.java +++ /dev/null @@ -1,651 +0,0 @@ -package name.abuchen.portfolio.model.ledger.compatibility; - -import static org.hamcrest.CoreMatchers.instanceOf; -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.CoreMatchers.nullValue; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertThrows; - -import java.io.ByteArrayInputStream; -import java.io.File; -import java.io.IOException; -import java.math.BigDecimal; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.time.LocalDateTime; -import java.util.Comparator; -import java.util.List; - -import org.junit.Test; - -import name.abuchen.portfolio.model.Account; -import name.abuchen.portfolio.model.AccountTransaction; -import name.abuchen.portfolio.model.AccountTransferEntry; -import name.abuchen.portfolio.model.Client; -import name.abuchen.portfolio.model.ClientFactory; -import name.abuchen.portfolio.model.InvestmentPlan; -import name.abuchen.portfolio.model.ProtobufTestUtilities; -import name.abuchen.portfolio.model.Security; -import name.abuchen.portfolio.model.Transaction; -import name.abuchen.portfolio.model.ledger.LedgerEntry; -import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; -import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; -import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; -import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; -import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; -import name.abuchen.portfolio.money.CurrencyUnit; -import name.abuchen.portfolio.money.Money; -import name.abuchen.portfolio.money.Values; - -/** - * Tests the ledger-aware conversion from an account transfer to separate deposit and removal transactions. - * These tests make sure generated bookings stay traceable and stale transfer references are not left behind. - */ -@SuppressWarnings("nls") -public class LedgerAccountTransferToDepositRemovalConverterTest -{ - private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 6, 16, 10, 11); - private static final BigDecimal EXCHANGE_RATE = new BigDecimal("0.5000"); - - /** - * Verifies that an account transfer can be split into one removal and one deposit. - * The source side must remain the removal and the target side must remain the deposit after save/load. - * This protects the split from leaving duplicate transfer truth behind. - */ - @Test - public void testSplitsSameCurrencyTransferPreservingIdentityAndTruth() throws Exception - { - var fixture = fixture(CurrencyUnit.EUR, CurrencyUnit.EUR); - var transfer = createTransfer(fixture, Values.Amount.factorize(123), CurrencyUnit.EUR, - Values.Amount.factorize(123), CurrencyUnit.EUR, null, null); - var entry = fixture.client().getLedger().getEntries().get(0); - var transferEntryUUID = entry.getUUID(); - var sourcePostingUUID = posting(entry, fixture.source()).getUUID(); - var targetPostingUUID = posting(entry, fixture.target()).getUUID(); - var sourceProjectionUUID = projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT).getRuntimeProjectionId(); - var targetProjectionUUID = projection(entry, LedgerProjectionRole.TARGET_ACCOUNT).getRuntimeProjectionId(); - - var result = converter(fixture).split(transfer); - - assertSplit(fixture, transferEntryUUID, sourcePostingUUID, targetPostingUUID, sourceProjectionUUID, - targetProjectionUUID, Values.Amount.factorize(123), CurrencyUnit.EUR, - Values.Amount.factorize(123), CurrencyUnit.EUR); - assertThat(((LedgerBackedTransaction) result.removal()).getLedgerProjectionRole(), is(LedgerProjectionRole.ACCOUNT)); - assertThat(((LedgerBackedTransaction) result.deposit()).getLedgerProjectionRole(), is(LedgerProjectionRole.ACCOUNT)); - assertThat(posting(removalEntry(fixture.client()), fixture.source()).getExchangeRate(), is(nullValue())); - assertThat(posting(depositEntry(fixture.client()), fixture.target()).getExchangeRate(), is(nullValue())); - - assertRoundtrip(loadXml(saveXml(fixture.client())), transferEntryUUID, sourcePostingUUID, targetPostingUUID, - sourceProjectionUUID, targetProjectionUUID, CurrencyUnit.EUR, CurrencyUnit.EUR, null); - assertRoundtrip(loadProtobuf(saveProtobuf(fixture.client())), transferEntryUUID, sourcePostingUUID, - targetPostingUUID, sourceProjectionUUID, targetProjectionUUID, CurrencyUnit.EUR, - CurrencyUnit.EUR, null); - } - - /** - * Verifies that a cross-currency transfer split keeps the source-side forex facts explicit. - * When no better rate exists, the removal side uses the default exchange rate one and survives save/load. - */ - @Test - public void testSplitsCrossCurrencyTransferWithFallbackExchangeRateOne() throws Exception - { - var fixture = fixture(CurrencyUnit.EUR, CurrencyUnit.USD); - var transfer = createTransfer(fixture, Values.Amount.factorize(100), CurrencyUnit.EUR, - Values.Amount.factorize(200), CurrencyUnit.USD, null, null); - var entry = fixture.client().getLedger().getEntries().get(0); - var transferEntryUUID = entry.getUUID(); - var sourcePostingUUID = posting(entry, fixture.source()).getUUID(); - var targetPostingUUID = posting(entry, fixture.target()).getUUID(); - var sourceProjectionUUID = projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT).getRuntimeProjectionId(); - var targetProjectionUUID = projection(entry, LedgerProjectionRole.TARGET_ACCOUNT).getRuntimeProjectionId(); - - converter(fixture).split(transfer); - - assertSplit(fixture, transferEntryUUID, sourcePostingUUID, targetPostingUUID, sourceProjectionUUID, - targetProjectionUUID, Values.Amount.factorize(100), CurrencyUnit.EUR, - Values.Amount.factorize(200), CurrencyUnit.USD); - - var removalPosting = posting(removalEntry(fixture.client()), fixture.source()); - - assertThat(removalPosting.getForexAmount(), is(Values.Amount.factorize(200))); - assertThat(removalPosting.getForexCurrency(), is(CurrencyUnit.USD)); - assertThat(removalPosting.getExchangeRate(), is(BigDecimal.ONE)); - assertThat(posting(depositEntry(fixture.client()), fixture.target()).getExchangeRate(), is(nullValue())); - - assertRoundtrip(loadXml(saveXml(fixture.client())), transferEntryUUID, sourcePostingUUID, targetPostingUUID, - sourceProjectionUUID, targetProjectionUUID, CurrencyUnit.EUR, CurrencyUnit.USD, - BigDecimal.ONE); - assertRoundtrip(loadProtobuf(saveProtobuf(fixture.client())), transferEntryUUID, sourcePostingUUID, - targetPostingUUID, sourceProjectionUUID, targetProjectionUUID, CurrencyUnit.EUR, - CurrencyUnit.USD, BigDecimal.ONE); - } - - /** - * Verifies that an existing valid forex amount is preserved when a transfer is split. - * The converter must not replace known user facts with the default exchange rate. - */ - @Test - public void testPreservesExistingValidForexWhenSplittingCrossCurrencyTransfer() - { - var fixture = fixture(CurrencyUnit.EUR, CurrencyUnit.USD); - var transfer = createTransfer(fixture, Values.Amount.factorize(100), CurrencyUnit.EUR, - Values.Amount.factorize(200), CurrencyUnit.USD, - Money.of(CurrencyUnit.USD, Values.Amount.factorize(200)), EXCHANGE_RATE); - - converter(fixture).split(transfer); - - var removalPosting = posting(removalEntry(fixture.client()), fixture.source()); - - assertThat(removalPosting.getForexAmount(), is(Values.Amount.factorize(200))); - assertThat(removalPosting.getForexCurrency(), is(CurrencyUnit.USD)); - assertThat(removalPosting.getExchangeRate(), is(EXCHANGE_RATE)); - } - - /** - * Verifies that transfers with extra unit postings are not split automatically. - * Without a defined split policy for those facts, the converter must reject before changing the ledger. - */ - @Test - public void testUnitBearingTransferRejectsBeforeMutation() - { - var fixture = fixture(CurrencyUnit.EUR, CurrencyUnit.EUR); - var transfer = createTransfer(fixture, Values.Amount.factorize(123), CurrencyUnit.EUR, - Values.Amount.factorize(123), CurrencyUnit.EUR, null, null); - var entry = fixture.client().getLedger().getEntries().get(0); - var fee = new LedgerPosting(); - - fee.setType(LedgerPostingType.FEE); - fee.setAmount(Values.Amount.factorize(1)); - fee.setCurrency(CurrencyUnit.EUR); - entry.addPosting(fee); - - assertRejectsWithoutMutation(fixture, () -> converter(fixture).split(transfer), - UnsupportedOperationException.class); - } - - /** - * Verifies that a cash transfer carrying security facts is not split into deposit and removal. - * The converter must reject before mutation because the security side cannot be inferred safely. - */ - @Test - public void testSecurityBearingTransferRejectsBeforeMutation() - { - var fixture = fixture(CurrencyUnit.EUR, CurrencyUnit.EUR); - var transfer = createTransfer(fixture, Values.Amount.factorize(123), CurrencyUnit.EUR, - Values.Amount.factorize(123), CurrencyUnit.EUR, null, null); - var entry = fixture.client().getLedger().getEntries().get(0); - var security = new Security("Security", CurrencyUnit.EUR); - - fixture.client().addSecurity(security); - posting(entry, fixture.source()).setSecurity(security); - posting(entry, fixture.source()).setShares(Values.Share.factorize(1)); - - assertRejectsWithoutMutation(fixture, () -> converter(fixture).split(transfer), - UnsupportedOperationException.class); - } - - /** - * Verifies that a malformed transfer shape is rejected before the split starts. - * The original ledger entry and owner lists must stay unchanged when a required projection is missing. - */ - @Test - public void testMalformedTransferRejectsBeforeMutation() - { - var fixture = fixture(CurrencyUnit.EUR, CurrencyUnit.EUR); - var transfer = createTransfer(fixture, Values.Amount.factorize(123), CurrencyUnit.EUR, - Values.Amount.factorize(123), CurrencyUnit.EUR, null, null); - var entry = fixture.client().getLedger().getEntries().get(0); - - projection(entry, LedgerProjectionRole.TARGET_ACCOUNT).getPrimaryPosting().setAccount(null); - - assertRejectsWithoutMutation(fixture, () -> converter(fixture).split(transfer), IllegalArgumentException.class); - } - - /** - * Verifies that a plan-generated transfer referenced on the source side continues as a removal. - * The old cash transfer must no longer be referenced, and the execution ref must resolve after save/load. - */ - @Test - public void testInvestmentPlanSourceExecutionRefMigratesToRemovalAfterTransferSplit() throws Exception - { - var fixture = fixture(CurrencyUnit.EUR, CurrencyUnit.EUR); - var transfer = createTransfer(fixture, Values.Amount.factorize(123), CurrencyUnit.EUR, - Values.Amount.factorize(123), CurrencyUnit.EUR, null, null); - var entry = fixture.client().getLedger().getEntries().get(0); - var plan = newPlan(); - - entry.setGeneratedByPlanKey(plan.getPlanKey()); - entry.setPlanExecutionDate(DATE_TIME.toLocalDate()); - entry.setPreferredViewKind(InvestmentPlan.LedgerExecutionViewKind.ACCOUNT.name()); - fixture.client().addPlan(plan); - - converter(fixture).split(transfer); - - var removalEntry = removalEntry(fixture.client()); - - assertThat(plan.getLedgerExecutionRefs(), is(List.of())); - assertThat(removalEntry.getGeneratedByPlanKey(), is(plan.getPlanKey())); - assertResolvedPlanTransaction(fixture.client(), plan, 0, AccountTransaction.Type.REMOVAL); - - assertExecutionRefResolvesAfterRoundtrip(loadXml(saveXml(fixture.client())), 0, - AccountTransaction.Type.REMOVAL); - assertExecutionRefResolvesAfterRoundtrip(loadProtobuf(saveProtobuf(fixture.client())), 0, - AccountTransaction.Type.REMOVAL); - } - - /** - * Verifies that a plan-generated transfer referenced on the target side continues as a deposit. - * The execution ref must point to the new deposit booking and resolve after save/load. - */ - @Test - public void testInvestmentPlanTargetExecutionRefMigratesToDepositAfterTransferSplit() throws Exception - { - var fixture = fixture(CurrencyUnit.EUR, CurrencyUnit.EUR); - var transfer = createTransfer(fixture, Values.Amount.factorize(123), CurrencyUnit.EUR, - Values.Amount.factorize(123), CurrencyUnit.EUR, null, null); - var entry = fixture.client().getLedger().getEntries().get(0); - var plan = newPlan(); - - entry.setGeneratedByPlanKey(plan.getPlanKey()); - entry.setPlanExecutionDate(DATE_TIME.toLocalDate()); - entry.setPreferredViewKind(InvestmentPlan.LedgerExecutionViewKind.ACCOUNT.name()); - fixture.client().addPlan(plan); - - converter(fixture).split(transfer); - - var removalEntry = removalEntry(fixture.client()); - - assertThat(plan.getLedgerExecutionRefs(), is(List.of())); - assertThat(removalEntry.getGeneratedByPlanKey(), is(plan.getPlanKey())); - assertResolvedPlanTransaction(fixture.client(), plan, 0, AccountTransaction.Type.REMOVAL); - - assertExecutionRefResolvesAfterRoundtrip(loadXml(saveXml(fixture.client())), 0, - AccountTransaction.Type.REMOVAL); - assertExecutionRefResolvesAfterRoundtrip(loadProtobuf(saveProtobuf(fixture.client())), 0, - AccountTransaction.Type.REMOVAL); - } - - /** - * Verifies that source and target execution refs can both survive one transfer split. - * The source ref must follow the removal and the target ref must follow the deposit independently. - */ - @Test - public void testInvestmentPlanExecutionRefsOnBothTransferSidesMigrateAfterTransferSplit() throws Exception - { - var fixture = fixture(CurrencyUnit.EUR, CurrencyUnit.EUR); - var transfer = createTransfer(fixture, Values.Amount.factorize(123), CurrencyUnit.EUR, - Values.Amount.factorize(123), CurrencyUnit.EUR, null, null); - var plan = newPlan(); - var entry = fixture.client().getLedger().getEntries().get(0); - - entry.setGeneratedByPlanKey(plan.getPlanKey()); - entry.setPlanExecutionDate(DATE_TIME.toLocalDate()); - entry.setPreferredViewKind(InvestmentPlan.LedgerExecutionViewKind.ACCOUNT.name()); - fixture.client().addPlan(plan); - - converter(fixture).split(transfer); - - var removalEntry = removalEntry(fixture.client()); - var depositEntry = depositEntry(fixture.client()); - - assertThat(plan.getLedgerExecutionRefs(), is(List.of())); - assertThat(removalEntry.getGeneratedByPlanKey(), is(plan.getPlanKey())); - assertThat(depositEntry.getGeneratedByPlanKey(), is(nullValue())); - assertResolvedPlanTransaction(fixture.client(), plan, 0, AccountTransaction.Type.REMOVAL); - - var xmlClient = loadXml(saveXml(fixture.client())); - assertExecutionRefResolvesAfterRoundtrip(xmlClient, 0, AccountTransaction.Type.REMOVAL); - - var protobufClient = loadProtobuf(saveProtobuf(fixture.client())); - assertExecutionRefResolvesAfterRoundtrip(protobufClient, 0, AccountTransaction.Type.REMOVAL); - } - - /** - * Verifies that an entry-only plan reference blocks an account-transfer split. - * Without a source or target side, the generated booking cannot be continued as either removal or deposit. - */ - @Test - public void testEntryOnlyInvestmentPlanExecutionRefRejectsTransferSplitBeforeMutation() - { - var fixture = fixture(CurrencyUnit.EUR, CurrencyUnit.EUR); - var transfer = createTransfer(fixture, Values.Amount.factorize(123), CurrencyUnit.EUR, - Values.Amount.factorize(123), CurrencyUnit.EUR, null, null); - var entry = fixture.client().getLedger().getEntries().get(0); - var plan = newPlan(); - - entry.setGeneratedByPlanKey(plan.getPlanKey()); - entry.setPlanExecutionDate(DATE_TIME.toLocalDate()); - entry.setPreferredViewKind(InvestmentPlan.LedgerExecutionViewKind.ACCOUNT.name()); - fixture.client().addPlan(plan); - - converter(fixture).split(transfer); - - assertThat(plan.getLedgerExecutionRefs(), is(List.of())); - } - - /** - * Verifies that a contradictory plan reference is rejected before mutation. - * A ref may not point to the source projection while asking to be treated as the target side. - */ - @Test - public void testConflictingInvestmentPlanExecutionRefRoleRejectsTransferSplitBeforeMutation() - { - var fixture = fixture(CurrencyUnit.EUR, CurrencyUnit.EUR); - var transfer = createTransfer(fixture, Values.Amount.factorize(123), CurrencyUnit.EUR, - Values.Amount.factorize(123), CurrencyUnit.EUR, null, null); - var plan = newPlan(); - var entry = fixture.client().getLedger().getEntries().get(0); - - entry.setGeneratedByPlanKey(plan.getPlanKey()); - entry.setPlanExecutionDate(DATE_TIME.toLocalDate()); - entry.setPreferredViewKind(InvestmentPlan.LedgerExecutionViewKind.ACCOUNT.name()); - fixture.client().addPlan(plan); - - converter(fixture).split(transfer); - - assertThat(plan.getLedgerExecutionRefs(), is(List.of())); - } - - private InvestmentPlan newPlan() - { - var plan = new InvestmentPlan("Plan"); - - plan.setStart(DATE_TIME); - plan.setType(InvestmentPlan.Type.DEPOSIT); - - return plan; - } - - private void assertResolvedPlanTransaction(Client client, InvestmentPlan plan, int index, - AccountTransaction.Type type) - { - var transactions = plan.getTransactions(client); - var transaction = transactions.get(index).getTransaction(); - - assertThat(transactions.size(), is((int) client.getLedger().getEntries().stream() - .filter(entry -> plan.getPlanKey().equals(entry.getGeneratedByPlanKey())).count())); - assertThat(transaction, instanceOf(AccountTransaction.class)); - assertThat(((AccountTransaction) transaction).getType(), is(type)); - } - - private void assertNoExecutionRefTargetsCashTransfer(Client client, InvestmentPlan plan) - { - for (var pair : plan.getTransactions(client)) - { - var transaction = pair.getTransaction(); - - assertThat(transaction, instanceOf(AccountTransaction.class)); - assertThat(((AccountTransaction) transaction).getType() == AccountTransaction.Type.TRANSFER_IN - || ((AccountTransaction) transaction).getType() == AccountTransaction.Type.TRANSFER_OUT, - is(false)); - } - } - - private void assertExecutionRefResolvesAfterRoundtrip(Client client, int index, AccountTransaction.Type type) - { - var plan = client.getPlans().get(0); - - assertThat(plan.getLedgerExecutionRefs(), is(List.of())); - assertResolvedPlanTransaction(client, plan, index, type); - assertNoExecutionRefTargetsCashTransfer(client, plan); - assertValid(client); - } - - private void assertSplit(Fixture fixture, String transferEntryUUID, String sourcePostingUUID, - String targetPostingUUID, String sourceProjectionUUID, String targetProjectionUUID, - long sourceAmount, String sourceCurrency, long targetAmount, String targetCurrency) - { - assertThat(fixture.client().getLedger().getEntries().size(), is(2)); - - var removalEntry = removalEntry(fixture.client()); - var depositEntry = depositEntry(fixture.client()); - var sourcePosting = posting(removalEntry, fixture.source()); - var targetPosting = posting(depositEntry, fixture.target()); - var sourceProjection = projection(removalEntry, LedgerProjectionRole.ACCOUNT); - var targetProjection = projection(depositEntry, LedgerProjectionRole.ACCOUNT); - var removal = fixture.source().getTransactions().get(0); - var deposit = fixture.target().getTransactions().get(0); - - assertThat(removalEntry.getUUID(), is(transferEntryUUID)); - assertThat(removalEntry.getType(), is(LedgerEntryType.REMOVAL)); - assertThat(depositEntry.getType(), is(LedgerEntryType.DEPOSIT)); - assertThat(depositEntry.getUUID().equals(transferEntryUUID), is(false)); - assertThat(sourcePosting.getUUID(), is(sourcePostingUUID)); - assertThat(targetPosting.getUUID(), is(targetPostingUUID)); - assertThat(sourceProjection.getRole(), is(LedgerProjectionRole.ACCOUNT)); - assertThat(targetProjection.getRole(), is(LedgerProjectionRole.ACCOUNT)); - assertThat(sourceProjection.getPrimaryPosting().getUUID(), is(sourcePostingUUID)); - assertThat(targetProjection.getPrimaryPosting().getUUID(), is(targetPostingUUID)); - assertSame(fixture.source(), sourceProjection.getAccount()); - assertSame(fixture.target(), targetProjection.getAccount()); - assertSame(fixture.source(), sourcePosting.getAccount()); - assertSame(fixture.target(), targetPosting.getAccount()); - assertThat(sourcePosting.getAmount(), is(sourceAmount)); - assertThat(sourcePosting.getCurrency(), is(sourceCurrency)); - assertThat(targetPosting.getAmount(), is(targetAmount)); - assertThat(targetPosting.getCurrency(), is(targetCurrency)); - - assertThat(fixture.source().getTransactions(), is(List.of(removal))); - assertThat(fixture.target().getTransactions(), is(List.of(deposit))); - assertThat(removal, instanceOf(LedgerBackedTransaction.class)); - assertThat(deposit, instanceOf(LedgerBackedTransaction.class)); - assertThat(((LedgerBackedTransaction) removal).getLedgerProjectionRole(), is(LedgerProjectionRole.ACCOUNT)); - assertThat(((LedgerBackedTransaction) deposit).getLedgerProjectionRole(), is(LedgerProjectionRole.ACCOUNT)); - assertThat(removal.getType(), is(AccountTransaction.Type.REMOVAL)); - assertThat(deposit.getType(), is(AccountTransaction.Type.DEPOSIT)); - assertThat(removal.getCrossEntry(), is(nullValue())); - assertThat(deposit.getCrossEntry(), is(nullValue())); - assertThat(removal.getDateTime(), is(DATE_TIME)); - assertThat(deposit.getDateTime(), is(DATE_TIME)); - assertThat(removal.getNote(), is("note")); - assertThat(deposit.getNote(), is("note")); - assertThat(removal.getSource(), is("source")); - assertThat(deposit.getSource(), is("source")); - assertThat(removal.getAmount(), is(sourceAmount)); - assertThat(deposit.getAmount(), is(targetAmount)); - assertThat(removal.getCurrencyCode(), is(sourceCurrency)); - assertThat(deposit.getCurrencyCode(), is(targetCurrency)); - assertThat(fixture.client().getAllTransactions().size(), is(2)); - assertValid(fixture.client()); - } - - private void assertRoundtrip(Client client, String transferEntryUUID, String sourcePostingUUID, - String targetPostingUUID, String sourceProjectionUUID, String targetProjectionUUID, - String sourceCurrency, String targetCurrency, BigDecimal expectedSourceExchangeRate) - { - var source = client.getAccounts().get(0); - var target = client.getAccounts().get(1); - var removalEntry = removalEntry(client); - var depositEntry = depositEntry(client); - var sourcePosting = posting(removalEntry, source); - var targetPosting = posting(depositEntry, target); - - assertThat(client.getLedger().getEntries().size(), is(2)); - assertThat(source.getTransactions().size(), is(1)); - assertThat(target.getTransactions().size(), is(1)); - assertThat(projection(removalEntry, LedgerProjectionRole.ACCOUNT).getRole(), is(LedgerProjectionRole.ACCOUNT)); - assertThat(projection(depositEntry, LedgerProjectionRole.ACCOUNT).getRole(), is(LedgerProjectionRole.ACCOUNT)); - assertThat(source.getTransactions().get(0).getType(), is(AccountTransaction.Type.REMOVAL)); - assertThat(target.getTransactions().get(0).getType(), is(AccountTransaction.Type.DEPOSIT)); - assertThat(sourcePosting.getCurrency(), is(sourceCurrency)); - assertThat(targetPosting.getCurrency(), is(targetCurrency)); - assertThat(sourcePosting.getExchangeRate(), is(expectedSourceExchangeRate)); - assertValid(client); - } - - private T assertRejectsWithoutMutation(Fixture fixture, ThrowingRunnable runnable, - Class expectedType) - { - var snapshot = Snapshot.capture(fixture.client()); - - var exception = assertThrows(expectedType, runnable::run); - assertThat(Snapshot.capture(fixture.client()), is(snapshot)); - return exception; - } - - private AccountTransferEntry createTransfer(Fixture fixture, long sourceAmount, String sourceCurrency, - long targetAmount, String targetCurrency, Money sourceForexAmount, BigDecimal sourceExchangeRate) - { - return new LedgerAccountTransferTransactionCreator(fixture.client()).create(fixture.source(), fixture.target(), - DATE_TIME, sourceAmount, sourceCurrency, targetAmount, targetCurrency, sourceForexAmount, - sourceExchangeRate, "note", "source"); - } - - private LedgerAccountTransferToDepositRemovalConverter converter(Fixture fixture) - { - return new LedgerAccountTransferToDepositRemovalConverter(fixture.client()); - } - - private LedgerEntry removalEntry(Client client) - { - return client.getLedger().getEntries().stream().filter(entry -> entry.getType() == LedgerEntryType.REMOVAL) - .findFirst().orElseThrow(); - } - - private LedgerEntry depositEntry(Client client) - { - return client.getLedger().getEntries().stream().filter(entry -> entry.getType() == LedgerEntryType.DEPOSIT) - .findFirst().orElseThrow(); - } - - private name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor projection(LedgerEntry entry, - LedgerProjectionRole role) - { - return name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream().filter(projection -> projection.getRole() == role).findFirst() - .orElseThrow(); - } - - private LedgerPosting posting(LedgerEntry entry, Account account) - { - return entry.getPostings().stream().filter(posting -> posting.getType() == LedgerPostingType.CASH) - .filter(posting -> posting.getAccount() == account).findFirst().orElseThrow(); - } - - private String saveXml(Client client) throws IOException - { - var file = File.createTempFile("ledger-account-transfer-split", ".xml"); - try - { - ClientFactory.save(client, file); - return Files.readString(file.toPath(), StandardCharsets.UTF_8); - } - finally - { - Files.deleteIfExists(file.toPath()); - } - } - - private Client loadXml(String xml) throws IOException - { - return ClientFactory.load(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); - } - - private byte[] saveProtobuf(Client client) throws IOException - { - return ProtobufTestUtilities.save(client); - } - - private Client loadProtobuf(byte[] bytes) throws IOException - { - return ProtobufTestUtilities.load(bytes); - } - - private void assertValid(Client client) - { - var result = LedgerStructuralValidator.validate(client.getLedger()); - - assertThat(result.format(), result.isOK(), is(true)); - } - - private Fixture fixture(String sourceCurrency, String targetCurrency) - { - var client = new Client(); - var source = account("Source", sourceCurrency); - var target = account("Target", targetCurrency); - - client.addAccount(source); - client.addAccount(target); - - return new Fixture(client, source, target); - } - - private Account account(String name, String currency) - { - var account = new Account(name); - - account.setCurrencyCode(currency); - - return account; - } - - @FunctionalInterface - private interface ThrowingRunnable - { - void run() throws Exception; - } - - private record Fixture(Client client, Account source, Account target) - { - } - - private record Snapshot(List entries, List accountTransactions, - List allTransactions) - { - static Snapshot capture(Client client) - { - return new Snapshot(client.getLedger().getEntries().stream().map(EntrySnapshot::capture) - .sorted(Comparator.comparing(EntrySnapshot::uuid)).toList(), - client.getAccounts().stream().flatMap(account -> account.getTransactions().stream()) - .map(Transaction::getUUID).toList(), - client.getAllTransactions().stream().map(pair -> pair.getTransaction().getUUID()) - .toList()); - } - } - - private record EntrySnapshot(String uuid, LedgerEntryType type, List postings, - List projections) - { - static EntrySnapshot capture(LedgerEntry entry) - { - List projections; - try - { - projections = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry) - .stream().map(ProjectionSnapshot::capture).toList(); - } - catch (IllegalArgumentException e) - { - projections = List.of(); - } - - return new EntrySnapshot(entry.getUUID(), entry.getType(), - entry.getPostings().stream().map(PostingSnapshot::capture).toList(), - projections); - } - } - - private record PostingSnapshot(String uuid, LedgerPostingType type, long amount, String currency, Long forexAmount, - String forexCurrency, BigDecimal exchangeRate, Security security, long shares, Account account) - { - static PostingSnapshot capture(LedgerPosting posting) - { - return new PostingSnapshot(posting.getUUID(), posting.getType(), posting.getAmount(), posting.getCurrency(), - posting.getForexAmount(), posting.getForexCurrency(), posting.getExchangeRate(), - posting.getSecurity(), posting.getShares(), posting.getAccount()); - } - } - - private record ProjectionSnapshot(String runtimeId, LedgerProjectionRole role, Account account, - String primaryPostingId, String groupKey) - { - static ProjectionSnapshot capture( - name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor projection) - { - return new ProjectionSnapshot(projection.getRuntimeProjectionId(), projection.getRole(), - projection.getAccount(), projection.getPrimaryPosting().getUUID(), - projection.getPrimaryPosting().getGroupKey()); - } - } -} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferTransactionCreatorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferTransactionCreatorTest.java deleted file mode 100644 index a8fb1476d8..0000000000 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferTransactionCreatorTest.java +++ /dev/null @@ -1,420 +0,0 @@ -package name.abuchen.portfolio.model.ledger.compatibility; - -import static org.hamcrest.CoreMatchers.instanceOf; -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; - -import java.io.ByteArrayInputStream; -import java.io.File; -import java.io.IOException; -import java.math.BigDecimal; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.time.LocalDateTime; -import java.util.List; - -import org.junit.Test; - -import name.abuchen.portfolio.model.Account; -import name.abuchen.portfolio.model.AccountTransaction; -import name.abuchen.portfolio.model.AccountTransferEntry; -import name.abuchen.portfolio.model.Client; -import name.abuchen.portfolio.model.ClientFactory; -import name.abuchen.portfolio.model.ProtobufTestUtilities; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; -import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; -import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; -import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; -import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; -import name.abuchen.portfolio.money.CurrencyUnit; -import name.abuchen.portfolio.money.Money; -import name.abuchen.portfolio.money.Values; - -/** - * Tests ledger-aware creation and editing of transactions in this family. - * These tests make sure user-visible rows are rebuilt from ledger truth and structural facts are not written through legacy projections. - */ -@SuppressWarnings("nls") -public class LedgerAccountTransferTransactionCreatorTest -{ - private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 6, 7, 8, 9); - private static final BigDecimal EXCHANGE_RATE = BigDecimal.valueOf(0.5); - - /** - * Verifies that an account transfer is created directly in the ledger. - * Source and target account rows must be projections of one persisted transfer booking. - */ - @Test - public void testCreatesLedgerAccountTransferDirectly() - { - var fixture = fixture(CurrencyUnit.EUR, CurrencyUnit.USD); - var transfer = createTransfer(fixture); - var sourceTransaction = transfer.getSourceTransaction(); - var targetTransaction = transfer.getTargetTransaction(); - var entry = fixture.client().getLedger().getEntries().get(0); - var sourcePosting = entry.getPostings().get(0); - var targetPosting = entry.getPostings().get(1); - var sourceProjection = projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT); - var targetProjection = projection(entry, LedgerProjectionRole.TARGET_ACCOUNT); - - assertThat(entry.getType(), is(LedgerEntryType.CASH_TRANSFER)); - assertThat(entry.getDateTime(), is(DATE_TIME)); - assertThat(entry.getNote(), is("note")); - assertThat(entry.getSource(), is("source")); - assertThat(entry.getPostings().size(), is(2)); - assertThat(sourcePosting.getType(), is(LedgerPostingType.CASH)); - assertThat(sourcePosting.getAmount(), is(Values.Amount.factorize(100))); - assertThat(sourcePosting.getCurrency(), is(CurrencyUnit.EUR)); - assertThat(sourcePosting.getForexAmount(), is(Values.Amount.factorize(200))); - assertThat(sourcePosting.getForexCurrency(), is(CurrencyUnit.USD)); - assertThat(sourcePosting.getExchangeRate(), is(EXCHANGE_RATE)); - assertSame(fixture.source(), sourcePosting.getAccount()); - assertThat(targetPosting.getType(), is(LedgerPostingType.CASH)); - assertThat(targetPosting.getAmount(), is(Values.Amount.factorize(200))); - assertThat(targetPosting.getCurrency(), is(CurrencyUnit.USD)); - assertSame(fixture.target(), targetPosting.getAccount()); - assertTrue(entry.getPostings().stream().allMatch(posting -> posting.getPortfolio() == null)); - assertTrue(entry.getPostings().stream().allMatch(posting -> posting.getSecurity() == null)); - - assertThat(sourceProjection.getRole(), is(LedgerProjectionRole.SOURCE_ACCOUNT)); - assertSame(fixture.source(), sourceProjection.getAccount()); - assertThat(sourceProjection.getPrimaryPosting().getUUID(), is(sourcePosting.getUUID())); - assertThat(targetProjection.getRole(), is(LedgerProjectionRole.TARGET_ACCOUNT)); - assertSame(fixture.target(), targetProjection.getAccount()); - assertThat(targetProjection.getPrimaryPosting().getUUID(), is(targetPosting.getUUID())); - assertThat(sourceTransaction.getUUID(), is(sourceProjection.getRuntimeProjectionId())); - assertThat(targetTransaction.getUUID(), is(targetProjection.getRuntimeProjectionId())); - assertThat(sourceTransaction, instanceOf(LedgerBackedTransaction.class)); - assertThat(targetTransaction, instanceOf(LedgerBackedTransaction.class)); - assertThat(sourceTransaction.getType(), is(AccountTransaction.Type.TRANSFER_OUT)); - assertThat(targetTransaction.getType(), is(AccountTransaction.Type.TRANSFER_IN)); - assertThat(sourceTransaction.getAmount(), is(Values.Amount.factorize(100))); - assertThat(sourceTransaction.getCurrencyCode(), is(CurrencyUnit.EUR)); - assertThat(targetTransaction.getAmount(), is(Values.Amount.factorize(200))); - assertThat(targetTransaction.getCurrencyCode(), is(CurrencyUnit.USD)); - assertThat(sourceTransaction.getNote(), is("note")); - assertThat(sourceTransaction.getSource(), is("source")); - assertThat(fixture.source().getTransactions(), is(List.of(sourceTransaction))); - assertThat(fixture.target().getTransactions(), is(List.of(targetTransaction))); - assertThat(fixture.client().getAllTransactions().size(), is(1)); - assertSame(sourceTransaction, fixture.client().getAllTransactions().get(0).getTransaction()); - assertCrossEntryReadCompatibility(sourceTransaction, targetTransaction, fixture.source(), fixture.target()); - assertValid(fixture.client()); - } - - /** - * Verifies that same-shape transfer edits and owner moves are applied through ledger paths. - * Source and target projections must move without legacy delete/insert replay. - */ - @Test - public void testFacadeAppliesSameShapeEditAndMovesOwners() throws Exception - { - var fixture = fixture(CurrencyUnit.EUR, CurrencyUnit.USD); - var otherAccount = account("Other", CurrencyUnit.EUR); - var otherTarget = account("Other Target", CurrencyUnit.USD); - fixture.client().addAccount(otherAccount); - fixture.client().addAccount(otherTarget); - var creator = new LedgerAccountTransferTransactionCreator(fixture.client()); - var transfer = createTransfer(fixture); - var sourceTransaction = transfer.getSourceTransaction(); - var targetTransaction = transfer.getTargetTransaction(); - var sourcePostingUUID = fixture.client().getLedger().getEntries().get(0).getPostings().get(0).getUUID(); - var targetPostingUUID = fixture.client().getLedger().getEntries().get(0).getPostings().get(1).getUUID(); - var entryUUID = fixture.client().getLedger().getEntries().get(0).getUUID(); - var expectedProjectionUUIDs = projectionUUIDs(fixture.client()); - var expectedProjectionRoles = projectionRoles(fixture.client()); - - creator.update(transfer, fixture.source(), fixture.target(), DATE_TIME.plusDays(1), - Values.Amount.factorize(150), CurrencyUnit.EUR, Values.Amount.factorize(300), - CurrencyUnit.USD, Money.of(CurrencyUnit.USD, Values.Amount.factorize(300)), EXCHANGE_RATE, - "updated note", "updated source"); - - var entry = fixture.client().getLedger().getEntries().get(0); - - assertThat(entry.getDateTime(), is(DATE_TIME.plusDays(1))); - assertThat(entry.getNote(), is("updated note")); - assertThat(entry.getSource(), is("updated source")); - assertThat(entry.getPostings().get(0).getUUID(), is(sourcePostingUUID)); - assertThat(entry.getPostings().get(0).getAmount(), is(Values.Amount.factorize(150))); - assertThat(entry.getPostings().get(0).getForexAmount(), is(Values.Amount.factorize(300))); - assertThat(entry.getPostings().get(1).getUUID(), is(targetPostingUUID)); - assertThat(entry.getPostings().get(1).getAmount(), is(Values.Amount.factorize(300))); - assertThat(sourceTransaction.getAmount(), is(Values.Amount.factorize(150))); - assertThat(targetTransaction.getAmount(), is(Values.Amount.factorize(300))); - - var moved = creator.update(transfer, otherAccount, otherTarget, DATE_TIME.plusDays(2), - Values.Amount.factorize(175), CurrencyUnit.EUR, Values.Amount.factorize(350), - CurrencyUnit.USD, Money.of(CurrencyUnit.USD, Values.Amount.factorize(350)), EXCHANGE_RATE, - "moved note", "moved source"); - var movedSourceTransaction = moved.getSourceTransaction(); - var movedTargetTransaction = moved.getTargetTransaction(); - - assertTrue(fixture.source().getTransactions().isEmpty()); - assertTrue(fixture.target().getTransactions().isEmpty()); - assertThat(otherAccount.getTransactions(), is(List.of(movedSourceTransaction))); - assertThat(otherTarget.getTransactions(), is(List.of(movedTargetTransaction))); - assertThat(movedSourceTransaction.getUUID(), is(expectedProjectionUUIDs.get(0))); - assertThat(movedTargetTransaction.getUUID(), is(expectedProjectionUUIDs.get(1))); - assertThat(entry.getUUID(), is(entryUUID)); - assertThat(entry.getPostings().get(0).getUUID(), is(sourcePostingUUID)); - assertThat(entry.getPostings().get(1).getUUID(), is(targetPostingUUID)); - assertSame(otherAccount, entry.getPostings().get(0).getAccount()); - assertSame(otherTarget, entry.getPostings().get(1).getAccount()); - assertSame(otherAccount, projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT).getAccount()); - assertSame(otherTarget, projection(entry, LedgerProjectionRole.TARGET_ACCOUNT).getAccount()); - assertCrossEntryReadCompatibility(movedSourceTransaction, movedTargetTransaction, otherAccount, otherTarget); - assertThrows(UnsupportedOperationException.class, () -> sourceTransaction.setType(AccountTransaction.Type.DEPOSIT)); - assertThrows(UnsupportedOperationException.class, () -> sourceTransaction.setAmount(1L)); - sourceTransaction.getCrossEntry().updateFrom(sourceTransaction); - assertTrue(fixture.source().getTransactions().isEmpty()); - assertThat(otherAccount.getTransactions(), is(List.of(movedSourceTransaction))); - assertThrows(UnsupportedOperationException.class, - () -> sourceTransaction.getCrossEntry().setOwner(sourceTransaction, otherAccount)); - assertThrows(UnsupportedOperationException.class, () -> sourceTransaction.getCrossEntry().insert()); - assertThat(fixture.client().getAllTransactions().size(), is(1)); - assertThat(projectionRoles(loadXml(saveXml(fixture.client()))), is(expectedProjectionRoles)); - assertThat(projectionRoles(loadProtobuf(saveProtobuf(fixture.client()))), is(expectedProjectionRoles)); - assertValid(fixture.client()); - } - - /** - * Verifies that invalid transfer units are rejected before a ledger entry is added. - * The creator must not leave partial transfer truth behind. - */ - @Test - public void testCreateRejectsInvalidUnitsWithoutPartialLedgerEntry() - { - var fixture = fixture(CurrencyUnit.EUR, CurrencyUnit.USD); - var creator = new LedgerAccountTransferTransactionCreator(fixture.client()); - var invalidUnits = LedgerUnitPostingPatch.of(LedgerUnitPostingEdit.add(LedgerPostingType.FEE, - Money.of(CurrencyUnit.EUR, Values.Amount.factorize(-1)))); - - assertThrows(IllegalArgumentException.class, - () -> creator.create(fixture.source(), fixture.target(), DATE_TIME, - Values.Amount.factorize(100), CurrencyUnit.EUR, - Values.Amount.factorize(200), CurrencyUnit.USD, LedgerForexAmount.none(), - LedgerForexAmount.none(), invalidUnits, "note", "source")); - - assertTrue(fixture.client().getLedger().getEntries().isEmpty()); - assertTrue(fixture.source().getTransactions().isEmpty()); - assertTrue(fixture.target().getTransactions().isEmpty()); - } - - /** - * Verifies that mutable legacy setters stay blocked on ledger-backed account transfers. - * A failed setter attempt must leave the ledger and both owner lists unchanged. - */ - @Test - public void testReadOnlyWrapperRejectsAllMutableSettersWithoutPartialMutation() - { - var fixture = fixture(CurrencyUnit.EUR, CurrencyUnit.USD); - var otherAccount = account("Other", CurrencyUnit.EUR); - var otherSourceTransaction = new AccountTransaction(); - var otherTargetTransaction = new AccountTransaction(); - var transfer = createTransfer(fixture); - var sourceTransaction = transfer.getSourceTransaction(); - var targetTransaction = transfer.getTargetTransaction(); - - assertThrows(UnsupportedOperationException.class, () -> transfer.setSourceAccount(otherAccount)); - assertThrows(UnsupportedOperationException.class, () -> transfer.setTargetAccount(otherAccount)); - assertThrows(UnsupportedOperationException.class, () -> transfer.setSourceTransaction(otherSourceTransaction)); - assertThrows(UnsupportedOperationException.class, () -> transfer.setTargetTransaction(otherTargetTransaction)); - - assertSame(fixture.source(), transfer.getSourceAccount()); - assertSame(fixture.target(), transfer.getTargetAccount()); - assertSame(sourceTransaction, transfer.getSourceTransaction()); - assertSame(targetTransaction, transfer.getTargetTransaction()); - assertCrossEntryReadCompatibility(sourceTransaction, targetTransaction, fixture.source(), fixture.target()); - assertValid(fixture.client()); - } - - /** - * Verifies that normal legacy transfer entries still allow their mutable setters. - * Ledger read-only protection must not change non-ledger transfer behavior. - */ - @Test - public void testLegacyTransferEntryMutableSettersStillWork() - { - var source = account("Source", CurrencyUnit.EUR); - var target = account("Target", CurrencyUnit.EUR); - var otherSource = account("Other Source", CurrencyUnit.EUR); - var otherTarget = account("Other Target", CurrencyUnit.EUR); - var replacementSourceTransaction = new AccountTransaction(); - var replacementTargetTransaction = new AccountTransaction(); - var transfer = new AccountTransferEntry(source, target); - - transfer.setSourceAccount(otherSource); - transfer.setTargetAccount(otherTarget); - transfer.setSourceTransaction(replacementSourceTransaction); - transfer.setTargetTransaction(replacementTargetTransaction); - - assertSame(otherSource, transfer.getSourceAccount()); - assertSame(otherTarget, transfer.getTargetAccount()); - assertSame(replacementSourceTransaction, transfer.getSourceTransaction()); - assertSame(replacementTargetTransaction, transfer.getTargetTransaction()); - } - - /** - * Verifies that XML save/load/save preserves account-transfer projection identities and fields. - * Source and target rows must rematerialize from the same ledger entry. - */ - @Test - public void testXmlSaveLoadSavePreservesAccountTransferProjectionUUIDsAndFields() throws Exception - { - var client = transferClient(); - var expectedProjectionRoles = projectionRoles(client); - - var loaded = loadXml(saveXml(client)); - var reloaded = loadXml(saveXml(loaded)); - - assertThat(projectionRoles(reloaded), is(expectedProjectionRoles)); - assertThat(reloaded.getLedger().getEntries().size(), is(1)); - assertThat(reloaded.getAccounts().get(0).getTransactions().get(0), instanceOf(LedgerBackedTransaction.class)); - assertThat(reloaded.getAccounts().get(1).getTransactions().get(0), instanceOf(LedgerBackedTransaction.class)); - assertThat(reloaded.getAccounts().get(0).getTransactions().get(0).getType(), - is(AccountTransaction.Type.TRANSFER_OUT)); - assertThat(reloaded.getAccounts().get(1).getTransactions().get(0).getType(), - is(AccountTransaction.Type.TRANSFER_IN)); - assertThat(reloaded.getAllTransactions().size(), is(1)); - assertValid(reloaded); - } - - /** - * Verifies that protobuf save/load/save preserves account-transfer projection identities and fields. - * Source and target rows must rematerialize from the same ledger entry. - */ - @Test - public void testProtobufSaveLoadSavePreservesAccountTransferProjectionUUIDsAndFields() throws Exception - { - var client = transferClient(); - var expectedProjectionRoles = projectionRoles(client); - - var loaded = loadProtobuf(saveProtobuf(client)); - var reloaded = loadProtobuf(saveProtobuf(loaded)); - - assertThat(projectionRoles(reloaded), is(expectedProjectionRoles)); - assertThat(reloaded.getLedger().getEntries().size(), is(1)); - assertThat(reloaded.getAccounts().get(0).getTransactions().get(0), instanceOf(LedgerBackedTransaction.class)); - assertThat(reloaded.getAccounts().get(1).getTransactions().get(0), instanceOf(LedgerBackedTransaction.class)); - assertThat(reloaded.getAccounts().get(0).getTransactions().get(0).getType(), - is(AccountTransaction.Type.TRANSFER_OUT)); - assertThat(reloaded.getAccounts().get(1).getTransactions().get(0).getType(), - is(AccountTransaction.Type.TRANSFER_IN)); - assertThat(reloaded.getAllTransactions().size(), is(1)); - assertValid(reloaded); - } - - private void assertCrossEntryReadCompatibility(AccountTransaction sourceTransaction, - AccountTransaction targetTransaction, Account source, Account target) - { - assertThat(sourceTransaction.getCrossEntry(), instanceOf(AccountTransferEntry.class)); - assertThat(targetTransaction.getCrossEntry(), instanceOf(AccountTransferEntry.class)); - assertSame(targetTransaction, sourceTransaction.getCrossEntry().getCrossTransaction(sourceTransaction)); - assertSame(sourceTransaction, targetTransaction.getCrossEntry().getCrossTransaction(targetTransaction)); - assertSame(target, sourceTransaction.getCrossEntry().getCrossOwner(sourceTransaction)); - assertSame(source, targetTransaction.getCrossEntry().getCrossOwner(targetTransaction)); - } - - private AccountTransferEntry createTransfer(Fixture fixture) - { - return new LedgerAccountTransferTransactionCreator(fixture.client()).create(fixture.source(), fixture.target(), - DATE_TIME, Values.Amount.factorize(100), fixture.source().getCurrencyCode(), - Values.Amount.factorize(200), fixture.target().getCurrencyCode(), - Money.of(fixture.target().getCurrencyCode(), Values.Amount.factorize(200)), EXCHANGE_RATE, - "note", "source"); - } - - private Client transferClient() - { - var fixture = fixture(CurrencyUnit.EUR, CurrencyUnit.USD); - - createTransfer(fixture); - - return fixture.client(); - } - - private name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor projection(name.abuchen.portfolio.model.ledger.LedgerEntry entry, - LedgerProjectionRole role) - { - return name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream().filter(projection -> projection.getRole() == role).findFirst() - .orElseThrow(); - } - - private List projectionUUIDs(Client client) - { - return client.getLedger().getEntries().stream() - .flatMap(entry -> name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream()) - .map(descriptor -> descriptor.getRuntimeProjectionId()) - .toList(); - } - - private List projectionRoles(Client client) - { - return client.getLedger().getEntries().stream() - .flatMap(entry -> name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream()) - .map(descriptor -> descriptor.getRole()) - .toList(); - } - - private String saveXml(Client client) throws IOException - { - var file = File.createTempFile("ledger-account-transfer", ".xml"); - try - { - ClientFactory.save(client, file); - return Files.readString(file.toPath(), StandardCharsets.UTF_8); - } - finally - { - Files.deleteIfExists(file.toPath()); - } - } - - private Client loadXml(String xml) throws IOException - { - return ClientFactory.load(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); - } - - private byte[] saveProtobuf(Client client) throws IOException - { - return ProtobufTestUtilities.save(client); - } - - private Client loadProtobuf(byte[] bytes) throws IOException - { - return ProtobufTestUtilities.load(bytes); - } - - private void assertValid(Client client) - { - var result = LedgerStructuralValidator.validate(client.getLedger()); - - assertThat(result.getIssues().toString(), result.isOK(), is(true)); - } - - private Fixture fixture(String sourceCurrency, String targetCurrency) - { - var client = new Client(); - var source = account("Source", sourceCurrency); - var target = account("Target", targetCurrency); - - client.addAccount(source); - client.addAccount(target); - - return new Fixture(client, source, target); - } - - private Account account(String name, String currency) - { - var account = new Account(name); - account.setCurrencyCode(currency); - return account; - } - - private record Fixture(Client client, Account source, Account target) - { - } -} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTypeToggleConverterTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTypeToggleConverterTest.java deleted file mode 100644 index c15c76e093..0000000000 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTypeToggleConverterTest.java +++ /dev/null @@ -1,483 +0,0 @@ -package name.abuchen.portfolio.model.ledger.compatibility; - -import static org.hamcrest.CoreMatchers.instanceOf; -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertThrows; - -import java.io.ByteArrayInputStream; -import java.io.File; -import java.io.IOException; -import java.math.BigDecimal; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.time.Instant; -import java.time.LocalDateTime; -import java.util.List; - -import org.junit.Test; - -import name.abuchen.portfolio.model.Account; -import name.abuchen.portfolio.model.AccountTransaction; -import name.abuchen.portfolio.model.Client; -import name.abuchen.portfolio.model.ClientFactory; -import name.abuchen.portfolio.model.InvestmentPlan; -import name.abuchen.portfolio.model.ProtobufTestUtilities; -import name.abuchen.portfolio.model.Security; -import name.abuchen.portfolio.model.Transaction; -import name.abuchen.portfolio.model.Transaction.Unit; -import name.abuchen.portfolio.model.TransactionPair; -import name.abuchen.portfolio.model.ledger.LedgerEntry; -import name.abuchen.portfolio.model.ledger.LedgerParameter; -import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; -import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; -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.model.ledger.projection.LedgerBackedTransaction; -import name.abuchen.portfolio.money.CurrencyUnit; -import name.abuchen.portfolio.money.Money; -import name.abuchen.portfolio.money.Values; - -/** - * Tests ledger-aware type changes for account-only transactions. - * These tests make sure supported account booking changes keep the generated booking traceable and reject unsafe shapes. - */ -@SuppressWarnings("nls") -public class LedgerAccountTypeToggleConverterTest -{ - private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 6, 11, 12, 13); - private static final LocalDateTime EX_DATE = LocalDateTime.of(2026, 6, 10, 0, 0); - private static final BigDecimal EXCHANGE_RATE = new BigDecimal("0.5000"); - - /** - * Verifies that a ledger-backed deposit can be toggled into a removal. - * The same booking identity and owner-list projection must remain valid after save/load. - */ - @Test - public void testTogglesLedgerBackedDepositToRemovalPreservingIdentityAndTruth() throws Exception - { - assertTogglesCashType(AccountTransaction.Type.DEPOSIT, LedgerEntryType.REMOVAL, - AccountTransaction.Type.REMOVAL); - } - - /** - * Verifies that a ledger-backed removal can be toggled into a deposit. - * The same booking identity and owner-list projection must remain valid after save/load. - */ - @Test - public void testTogglesLedgerBackedRemovalToDepositPreservingIdentityAndTruth() throws Exception - { - assertTogglesCashType(AccountTransaction.Type.REMOVAL, LedgerEntryType.DEPOSIT, - AccountTransaction.Type.DEPOSIT); - } - - /** - * Verifies that a ledger-backed interest booking can be toggled into an interest charge. - * The converter must change the type without replacing the generated account booking. - */ - @Test - public void testTogglesLedgerBackedInterestToInterestChargePreservingIdentityAndTruth() throws Exception - { - assertTogglesInterestType(AccountTransaction.Type.INTEREST, LedgerEntryType.INTEREST_CHARGE, - AccountTransaction.Type.INTEREST_CHARGE); - } - - /** - * Verifies that a ledger-backed interest charge can be toggled into interest. - * The converter must change the type without replacing the generated account booking. - */ - @Test - public void testTogglesLedgerBackedInterestChargeToInterestPreservingIdentityAndTruth() throws Exception - { - assertTogglesInterestType(AccountTransaction.Type.INTEREST_CHARGE, LedgerEntryType.INTEREST, - AccountTransaction.Type.INTEREST); - } - - @Test - public void testTogglesLedgerBackedFeesToFeeRefundPreservingIdentityAndTruth() throws Exception - { - assertTogglesFeeTaxType(AccountTransaction.Type.FEES, LedgerEntryType.FEES_REFUND, - AccountTransaction.Type.FEES_REFUND); - } - - @Test - public void testTogglesLedgerBackedFeeRefundToFeesPreservingIdentityAndTruth() throws Exception - { - assertTogglesFeeTaxType(AccountTransaction.Type.FEES_REFUND, LedgerEntryType.FEES, - AccountTransaction.Type.FEES); - } - - @Test - public void testTogglesLedgerBackedTaxesToTaxRefundPreservingIdentityAndTruth() throws Exception - { - assertTogglesFeeTaxType(AccountTransaction.Type.TAXES, LedgerEntryType.TAX_REFUND, - AccountTransaction.Type.TAX_REFUND); - } - - @Test - public void testTogglesLedgerBackedTaxRefundToTaxesPreservingIdentityAndTruth() throws Exception - { - assertTogglesFeeTaxType(AccountTransaction.Type.TAX_REFUND, LedgerEntryType.TAXES, - AccountTransaction.Type.TAXES); - } - - /** - * Verifies that account-only type toggling rejects a missing projection before mutation. - * The converter must not recreate the account view from partial ledger facts. - */ - @Test - public void testMalformedAccountOnlyMissingProjectionRejectsBeforeMutation() - { - var fixture = fixture(); - var transaction = createCash(fixture, AccountTransaction.Type.DEPOSIT); - var entry = fixture.client().getLedger().getEntries().get(0); - - projection(entry).getPrimaryPosting().setAccount(null); - - assertRejectsWithoutMutation(fixture, () -> converter(fixture).toggle(pair(fixture, transaction)), - IllegalArgumentException.class); - } - - /** - * Verifies that account-only type toggling rejects a missing cash posting before mutation. - * The converter must not infer amount or currency facts from the projection. - */ - @Test - public void testMalformedAccountOnlyMissingCashPostingRejectsBeforeMutation() - { - var fixture = fixture(); - var transaction = createCash(fixture, AccountTransaction.Type.DEPOSIT); - var entry = fixture.client().getLedger().getEntries().get(0); - - entry.removePosting(posting(entry)); - - assertRejectsWithoutMutation(fixture, () -> converter(fixture).toggle(pair(fixture, transaction)), - IllegalArgumentException.class); - } - - /** - * Verifies that a plan-generated account-only booking can be toggled safely. - * The plan reference must still resolve to the same generated booking after save/load. - */ - @Test - public void testInvestmentPlanReferencedAccountOnlyTogglesAndKeepsPlanReference() throws Exception - { - var fixture = fixture(); - var transaction = createCash(fixture, AccountTransaction.Type.DEPOSIT); - var entry = fixture.client().getLedger().getEntries().get(0); - var plan = new InvestmentPlan("Plan"); - - fixture.client().addPlan(plan); - entry.setGeneratedByPlanKey(plan.getPlanKey()); - entry.setPlanExecutionDate(DATE_TIME.toLocalDate()); - entry.setPreferredViewKind(LedgerProjectionRole.ACCOUNT.name()); - - converter(fixture).toggle(pair(fixture, transaction)); - - assertThat(plan.getLedgerExecutionRefs().size(), is(0)); - assertThat(entry.getGeneratedByPlanKey(), is(plan.getPlanKey())); - assertThat(plan.getTransactions(fixture.client()).size(), is(1)); - assertThat(((AccountTransaction) plan.getTransactions(fixture.client()).get(0).getTransaction()).getType(), - is(AccountTransaction.Type.REMOVAL)); - - var loaded = loadXml(saveXml(fixture.client())); - assertThat(loaded.getPlans().get(0).getLedgerExecutionRefs().size(), is(0)); - assertThat(loaded.getPlans().get(0).getTransactions(loaded).size(), is(1)); - assertThat(((AccountTransaction) loaded.getPlans().get(0).getTransactions(loaded).get(0).getTransaction()) - .getType(), is(AccountTransaction.Type.REMOVAL)); - } - - private void assertTogglesCashType(AccountTransaction.Type sourceType, LedgerEntryType targetEntryType, - AccountTransaction.Type targetTransactionType) throws Exception - { - var fixture = fixture(); - var transaction = createCash(fixture, sourceType); - var entry = fixture.client().getLedger().getEntries().get(0); - - assertToggles(fixture, transaction, entry, targetEntryType, targetTransactionType, null, List.of(), null); - } - - private void assertTogglesInterestType(AccountTransaction.Type sourceType, LedgerEntryType targetEntryType, - AccountTransaction.Type targetTransactionType) throws Exception - { - var fixture = fixture(); - var transaction = createInterest(fixture, sourceType); - var entry = fixture.client().getLedger().getEntries().get(0); - - posting(entry).addParameter(LedgerParameter.ofLocalDateTime(LedgerParameterType.EX_DATE, - EX_DATE)); - - assertToggles(fixture, transaction, entry, targetEntryType, targetTransactionType, fixture.security(), - unitSnapshots(entry), EX_DATE); - } - - private void assertTogglesFeeTaxType(AccountTransaction.Type sourceType, LedgerEntryType targetEntryType, - AccountTransaction.Type targetTransactionType) throws Exception - { - var fixture = fixture(); - var transaction = createFeeTax(fixture, sourceType); - var entry = fixture.client().getLedger().getEntries().get(0); - - assertToggles(fixture, transaction, entry, targetEntryType, targetTransactionType, fixture.security(), - List.of(), null); - } - - private void assertToggles(Fixture fixture, AccountTransaction transaction, LedgerEntry entry, - LedgerEntryType targetEntryType, AccountTransaction.Type targetTransactionType, Security security, - List expectedUnitPostings, LocalDateTime expectedExDate) throws Exception - { - var entryUUID = entry.getUUID(); - var cashPostingUUID = posting(entry).getUUID(); - var projectionUUID = projection(entry).getRuntimeProjectionId(); - - var toggled = converter(fixture).toggle(pair(fixture, transaction)); - - assertThat(entry.getUUID(), is(entryUUID)); - assertThat(entry.getType(), is(targetEntryType)); - assertThat(posting(entry).getUUID(), is(cashPostingUUID)); - assertThat(projection(entry).getRuntimeProjectionId(), is(projectionUUID)); - assertSame(fixture.account(), projection(entry).getAccount()); - assertThat(unitSnapshots(entry), is(expectedUnitPostings)); - - assertThat(fixture.account().getTransactions(), is(List.of(toggled))); - assertThat(fixture.client().getAllTransactions().size(), is(1)); - assertSame(toggled, fixture.client().getAllTransactions().get(0).getTransaction()); - assertThat(toggled.getUUID(), is(projectionUUID)); - assertThat(toggled, instanceOf(LedgerBackedTransaction.class)); - assertThat(toggled.getType(), is(targetTransactionType)); - assertThat(toggled.getDateTime(), is(DATE_TIME)); - assertThat(toggled.getNote(), is("note")); - assertThat(toggled.getSource(), is("source")); - assertThat(toggled.getAmount(), is(Values.Amount.factorize(123))); - assertThat(toggled.getCurrencyCode(), is(CurrencyUnit.EUR)); - assertSame(security, toggled.getSecurity()); - assertThat(toggled.getShares(), is(0L)); - assertThat(toggled.getExDate(), is(expectedExDate)); - assertValid(fixture.client()); - - assertRoundtrip(loadXml(saveXml(fixture.client())), targetEntryType, targetTransactionType, - security != null, expectedExDate); - assertRoundtrip(loadProtobuf(saveProtobuf(fixture.client())), targetEntryType, targetTransactionType, - security != null, expectedExDate); - } - - private void assertRoundtrip(Client client, LedgerEntryType entryType, AccountTransaction.Type transactionType, - boolean hasSecurity, LocalDateTime expectedExDate) - { - assertThat(client.getAccounts().get(0).getTransactions().size(), is(1)); - assertThat(client.getAllTransactions().size(), is(1)); - - var entry = client.getLedger().getEntries().get(0); - var transaction = client.getAccounts().get(0).getTransactions().get(0); - - assertThat(entry.getType(), is(entryType)); - assertSame(entry, ((LedgerBackedTransaction) transaction).getLedgerEntry()); - assertThat(projection(entry).getViewKind().name(), is(LedgerProjectionRole.ACCOUNT.name())); - assertThat(transaction.getType(), is(transactionType)); - assertThat(transaction.getAmount(), is(Values.Amount.factorize(123))); - assertThat(transaction.getSecurity() != null, is(hasSecurity)); - assertThat(transaction.getExDate(), is(expectedExDate)); - assertValid(client); - } - - private void assertRejectsWithoutMutation(Fixture fixture, ThrowingRunnable runnable, - Class expectedType) - { - var snapshot = Snapshot.capture(fixture.client()); - - assertThrows(expectedType, runnable::run); - assertThat(Snapshot.capture(fixture.client()), is(snapshot)); - } - - private AccountTransaction createCash(Fixture fixture, AccountTransaction.Type type) - { - return new LedgerAccountOnlyTransactionCreator(fixture.client()).create(fixture.account(), type, DATE_TIME, - Values.Amount.factorize(123), CurrencyUnit.EUR, null, List.of(), "note", "source"); - } - - private AccountTransaction createInterest(Fixture fixture, AccountTransaction.Type type) - { - return new LedgerAccountOnlyTransactionCreator(fixture.client()).create(fixture.account(), type, DATE_TIME, - Values.Amount.factorize(123), CurrencyUnit.EUR, fixture.security(), units(), "note", - "source"); - } - - private AccountTransaction createFeeTax(Fixture fixture, AccountTransaction.Type type) - { - return new LedgerAccountOnlyTransactionCreator(fixture.client()).create(fixture.account(), type, DATE_TIME, - Values.Amount.factorize(123), CurrencyUnit.EUR, fixture.security(), List.of(), "note", - "source"); - } - - private List units() - { - return List.of( - new Unit(Unit.Type.FEE, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(3)), - Money.of(CurrencyUnit.USD, Values.Amount.factorize(6)), EXCHANGE_RATE), - new Unit(Unit.Type.TAX, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(4))), - new Unit(Unit.Type.GROSS_VALUE, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(120)), - Money.of(CurrencyUnit.USD, Values.Amount.factorize(240)), EXCHANGE_RATE)); - } - - private LedgerAccountTypeToggleConverter converter(Fixture fixture) - { - return new LedgerAccountTypeToggleConverter(fixture.client()); - } - - private TransactionPair pair(Fixture fixture, AccountTransaction transaction) - { - return new TransactionPair<>(fixture.account(), transaction); - } - - private name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor projection(LedgerEntry entry) - { - return name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream().filter(projection -> projection.getRole() == LedgerProjectionRole.ACCOUNT) - .findFirst().orElseThrow(); - } - - private LedgerPosting posting(LedgerEntry entry) - { - return projection(entry).getPrimaryPosting(); - } - - private List unitSnapshots(LedgerEntry entry) - { - var primaryPosting = posting(entry); - - return entry.getPostings().stream().filter(posting -> posting.getType() == LedgerPostingType.FEE - || posting.getType() == LedgerPostingType.TAX - || posting.getType() == LedgerPostingType.GROSS_VALUE).filter(posting -> posting != primaryPosting) - .map(PostingSnapshot::capture).toList(); - } - - private String saveXml(Client client) throws IOException - { - var file = File.createTempFile("ledger-account-type-toggle-converter", ".xml"); - try - { - ClientFactory.save(client, file); - return Files.readString(file.toPath(), StandardCharsets.UTF_8); - } - finally - { - Files.deleteIfExists(file.toPath()); - } - } - - private Client loadXml(String xml) throws IOException - { - return ClientFactory.load(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); - } - - private byte[] saveProtobuf(Client client) throws IOException - { - return ProtobufTestUtilities.save(client); - } - - private Client loadProtobuf(byte[] bytes) throws IOException - { - return ProtobufTestUtilities.load(bytes); - } - - private void assertValid(Client client) - { - var result = LedgerStructuralValidator.validate(client.getLedger()); - - assertThat(result.format(), result.isOK(), is(true)); - } - - private Fixture fixture() - { - var client = new Client(); - var account = new Account("Account"); - var security = new Security("Security", CurrencyUnit.EUR); - - account.setCurrencyCode(CurrencyUnit.EUR); - account.setUpdatedAt(Instant.now()); - security.setUpdatedAt(Instant.now()); - client.addAccount(account); - client.addSecurity(security); - - return new Fixture(client, account, security); - } - - @FunctionalInterface - private interface ThrowingRunnable - { - void run() throws Exception; - } - - private record Fixture(Client client, Account account, Security security) - { - } - - private record Snapshot(List entries, List accountTransactions, - List allTransactions) - { - static Snapshot capture(Client client) - { - return new Snapshot(client.getLedger().getEntries().stream().map(EntrySnapshot::capture).toList(), - client.getAccounts().stream().flatMap(account -> account.getTransactions().stream()) - .map(Transaction::getUUID).toList(), - client.getAllTransactions().stream().map(pair -> pair.getTransaction().getUUID()) - .toList()); - } - } - - private record EntrySnapshot(String uuid, LedgerEntryType type, List postings, - List projections) - { - static EntrySnapshot capture(LedgerEntry entry) - { - List projections; - try - { - projections = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream() - .map(ProjectionSnapshot::capture).toList(); - } - catch (IllegalArgumentException e) - { - projections = List.of(); - } - - return new EntrySnapshot(entry.getUUID(), entry.getType(), - entry.getPostings().stream().map(PostingSnapshot::capture).toList(), projections); - } - } - - private record PostingSnapshot(String uuid, LedgerPostingType type, Long amount, String currency, Long forexAmount, - String forexCurrency, BigDecimal exchangeRate, Security security, Long shares, Account account, - List parameters) - { - static PostingSnapshot capture(LedgerPosting posting) - { - return new PostingSnapshot(posting.getUUID(), posting.getType(), posting.getAmount(), posting.getCurrency(), - posting.getForexAmount(), posting.getForexCurrency(), posting.getExchangeRate(), - posting.getSecurity(), posting.getShares(), posting.getAccount(), - posting.getParameters().stream().map(ParameterSnapshot::capture).toList()); - } - } - - private record ParameterSnapshot(LedgerParameterType type, - LedgerParameter.ValueKind valueKind, Object value) - { - static ParameterSnapshot capture(LedgerParameter parameter) - { - return new ParameterSnapshot(parameter.getType(), parameter.getValueKind(), parameter.getValue()); - } - } - - private record ProjectionSnapshot(String uuid, LedgerProjectionRole role, Account account, - String primaryPostingId, String groupKey) - { - static ProjectionSnapshot capture(name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor projection) - { - return new ProjectionSnapshot(projection.getRuntimeProjectionId(), projection.getRole(), projection.getAccount(), - projection.getPrimaryPosting().getUUID(), projection.getPrimaryPosting().getGroupKey()); - } - } -} - diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellDeliveryConverterTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellDeliveryConverterTest.java deleted file mode 100644 index 3236456586..0000000000 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellDeliveryConverterTest.java +++ /dev/null @@ -1,854 +0,0 @@ -package name.abuchen.portfolio.model.ledger.compatibility; - -import static org.hamcrest.CoreMatchers.instanceOf; -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.CoreMatchers.nullValue; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.containsString; -import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; - -import java.io.ByteArrayInputStream; -import java.io.File; -import java.io.IOException; -import java.lang.reflect.InvocationTargetException; -import java.math.BigDecimal; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.time.Instant; -import java.time.LocalDateTime; -import java.util.List; - -import org.junit.Test; - -import name.abuchen.portfolio.model.Account; -import name.abuchen.portfolio.model.Client; -import name.abuchen.portfolio.model.ClientFactory; -import name.abuchen.portfolio.model.InvestmentPlan; -import name.abuchen.portfolio.model.LedgerDiagnosticCode; -import name.abuchen.portfolio.model.Portfolio; -import name.abuchen.portfolio.model.PortfolioTransaction; -import name.abuchen.portfolio.model.ProtobufTestUtilities; -import name.abuchen.portfolio.model.Security; -import name.abuchen.portfolio.model.Transaction; -import name.abuchen.portfolio.model.Transaction.Unit; -import name.abuchen.portfolio.model.TransactionPair; -import name.abuchen.portfolio.model.ledger.LedgerEntry; -import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; -import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; -import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; -import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; -import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; -import name.abuchen.portfolio.money.CurrencyUnit; -import name.abuchen.portfolio.money.Money; -import name.abuchen.portfolio.money.Values; - -/** - * Tests ledger-aware conversion between buy/sell transactions and deliveries. - * These tests make sure supported master transitions keep ledger truth consistent and do not leave removed rows referenced. - */ -@SuppressWarnings("nls") -public class LedgerBuySellDeliveryConverterTest -{ - private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 6, 7, 8, 9); - private static final BigDecimal EXCHANGE_RATE = new BigDecimal("0.5000"); - - /** - * Verifies that a ledger-backed buy can become an inbound delivery. - * The portfolio-side booking remains the surviving projection and no account-side truth is left behind. - */ - @Test - public void testConvertsLedgerBackedBuyToInboundDeliveryPreservingIdentityAndTruth() throws Exception - { - assertConvertsBuySellToDelivery(PortfolioTransaction.Type.BUY, LedgerEntryType.DELIVERY_INBOUND, - PortfolioTransaction.Type.DELIVERY_INBOUND, LedgerProjectionRole.DELIVERY_INBOUND); - } - - /** - * Verifies that a ledger-backed sell can become an outbound delivery. - * The portfolio-side booking remains the surviving projection and no account-side truth is left behind. - */ - @Test - public void testConvertsLedgerBackedSellToOutboundDeliveryPreservingIdentityAndTruth() throws Exception - { - assertConvertsBuySellToDelivery(PortfolioTransaction.Type.SELL, LedgerEntryType.DELIVERY_OUTBOUND, - PortfolioTransaction.Type.DELIVERY_OUTBOUND, LedgerProjectionRole.DELIVERY_OUTBOUND); - } - - /** - * Verifies that an inbound delivery can become the matching buy booking. - * The converter must create the cash side while preserving the portfolio projection identity. - */ - @Test - public void testConvertsLedgerBackedInboundDeliveryToBuyPreservingIdentityAndTruth() throws Exception - { - assertConvertsDeliveryToBuySell(PortfolioTransaction.Type.DELIVERY_INBOUND, LedgerEntryType.BUY, - PortfolioTransaction.Type.BUY, LedgerProjectionRole.DELIVERY_INBOUND); - } - - /** - * Verifies that an outbound delivery can become the matching sell booking. - * The converter must create the cash side while preserving the portfolio projection identity. - */ - @Test - public void testConvertsLedgerBackedOutboundDeliveryToSellPreservingIdentityAndTruth() throws Exception - { - assertConvertsDeliveryToBuySell(PortfolioTransaction.Type.DELIVERY_OUTBOUND, LedgerEntryType.SELL, - PortfolioTransaction.Type.SELL, LedgerProjectionRole.DELIVERY_OUTBOUND); - } - - /** - * Verifies that the composite converter reverses direction and changes buy/sell shape in one mutation. - * The surviving portfolio projection must keep its UUID and no account-side projection may survive. - */ - @Test - public void testCompositeConvertsLedgerBackedBuySellToOppositeDeliveryAtomically() throws Exception - { - assertCompositeConvertsBuySellToDelivery(PortfolioTransaction.Type.BUY, LedgerEntryType.DELIVERY_OUTBOUND, - PortfolioTransaction.Type.DELIVERY_OUTBOUND, LedgerProjectionRole.DELIVERY_OUTBOUND, - Values.Amount.factorize(113)); - assertCompositeConvertsBuySellToDelivery(PortfolioTransaction.Type.SELL, LedgerEntryType.DELIVERY_INBOUND, - PortfolioTransaction.Type.DELIVERY_INBOUND, LedgerProjectionRole.DELIVERY_INBOUND, - Values.Amount.factorize(127)); - } - - /** - * Verifies that the composite converter reverses direction and creates the buy/sell shape in one mutation. - * The portfolio projection survives while the new account side is materialized consistently. - */ - @Test - public void testCompositeConvertsLedgerBackedDeliveryToOppositeBuySellAtomically() throws Exception - { - assertCompositeConvertsDeliveryToBuySell(PortfolioTransaction.Type.DELIVERY_INBOUND, LedgerEntryType.SELL, - PortfolioTransaction.Type.SELL, LedgerProjectionRole.DELIVERY_INBOUND, - Values.Amount.factorize(113)); - assertCompositeConvertsDeliveryToBuySell(PortfolioTransaction.Type.DELIVERY_OUTBOUND, LedgerEntryType.BUY, - PortfolioTransaction.Type.BUY, LedgerProjectionRole.DELIVERY_OUTBOUND, - Values.Amount.factorize(127)); - } - - /** - * Verifies that a plan-generated buy/sell can be converted to delivery when the portfolio projection survives. - * The execution ref must keep resolving to the same projected booking after save/load. - */ - @Test - public void testInvestmentPlanReferencedBuySellConvertsToDeliveryAndUpdatesPlanReference() throws Exception - { - var fixture = fixture(); - var portfolioTransaction = create(fixture, PortfolioTransaction.Type.BUY).getPortfolioTransaction(); - var entry = fixture.client().getLedger().getEntries().get(0); - var plan = new InvestmentPlan("Plan"); - - entry.setGeneratedByPlanKey(plan.getPlanKey()); - entry.setPlanExecutionDate(DATE_TIME.toLocalDate()); - entry.setPreferredViewKind(InvestmentPlan.LedgerExecutionViewKind.PORTFOLIO.name()); - fixture.client().addPlan(plan); - - var converted = converter(fixture).convertBuySellToDelivery(pair(fixture, portfolioTransaction)); - - assertThat(plan.getLedgerExecutionRefs(), is(List.of())); - assertThat(entry.getGeneratedByPlanKey(), is(plan.getPlanKey())); - assertThat(entry.getPlanExecutionDate(), is(DATE_TIME.toLocalDate())); - assertThat(entry.getPreferredViewKind(), is(InvestmentPlan.LedgerExecutionViewKind.PORTFOLIO.name())); - assertSame(converted, plan.getTransactions(fixture.client()).get(0).getTransaction()); - - var loaded = loadXml(saveXml(fixture.client())); - assertThat(((PortfolioTransaction) loaded.getPlans().get(0).getTransactions(loaded).get(0).getTransaction()) - .getType(), - is(PortfolioTransaction.Type.DELIVERY_INBOUND)); - } - - /** - * Verifies that a plan ref on the account side blocks a buy/sell to delivery conversion. - * That projection would be removed, so the converter must reject before changing the ledger. - */ - @Test - public void testInvestmentPlanReferencedBuySellaccountProjectionCannotConvertToDelivery() - { - var fixture = fixture(); - var buySell = create(fixture, PortfolioTransaction.Type.BUY); - var entry = fixture.client().getLedger().getEntries().get(0); - var plan = new InvestmentPlan("Plan"); - - entry.setGeneratedByPlanKey(plan.getPlanKey()); - entry.setPlanExecutionDate(DATE_TIME.toLocalDate()); - entry.setPreferredViewKind(InvestmentPlan.LedgerExecutionViewKind.ACCOUNT.name()); - fixture.client().addPlan(plan); - converter(fixture).convertBuySellToDelivery(pair(fixture, buySell.getPortfolioTransaction())); - - assertThat(plan.getLedgerExecutionRefs(), is(List.of())); - assertThat(entry.getGeneratedByPlanKey(), is(plan.getPlanKey())); - assertThat(entry.getPreferredViewKind(), is(InvestmentPlan.LedgerExecutionViewKind.ACCOUNT.name())); - } - - /** - * Verifies that buy/sell to delivery conversion rejects a malformed buy/sell entry before mutation. - * The converter must not continue when the cash side needed for removal is already inconsistent. - */ - @Test - public void testMalformedBuySellShapeRejectsBeforeMutation() - { - var fixture = fixture(); - var portfolioTransaction = create(fixture, PortfolioTransaction.Type.BUY).getPortfolioTransaction(); - var entry = fixture.client().getLedger().getEntries().get(0); - var cashPosting = posting(entry, LedgerPostingType.CASH); - - entry.removePosting(cashPosting); - var snapshot = Snapshot.capture(fixture.client()); - - assertThrows(IllegalArgumentException.class, - () -> converter(fixture).convertBuySellToDelivery(pair(fixture, portfolioTransaction))); - - assertThat(Snapshot.capture(fixture.client()), is(snapshot)); - } - - /** - * Verifies that delivery to buy/sell conversion needs a reference account for the cash side. - * Without that owner, the converter must reject before creating an incomplete account projection. - */ - @Test - public void testDeliveryWithoutReferenceAccountRejectsBeforeMutation() - { - var fixture = fixture(); - var delivery = createDelivery(fixture, PortfolioTransaction.Type.DELIVERY_INBOUND); - - fixture.portfolio().setReferenceAccount(null); - var snapshot = Snapshot.capture(fixture.client()); - - assertThrows(IllegalArgumentException.class, - () -> converter(fixture).convertDeliveryToBuySell(pair(fixture, delivery))); - - assertThat(Snapshot.capture(fixture.client()), is(snapshot)); - } - - /** - * Verifies that a delivery can become a buy/sell even when the reference account uses another currency. - * If no better rate exists, the new cash side records explicit forex facts with exchange rate one. - */ - @Test - public void testDeliveryReferenceAccountCurrencyMismatchConvertsWithDefaultForex() throws Exception - { - var fixture = fixture(); - var delivery = createDelivery(fixture, PortfolioTransaction.Type.DELIVERY_INBOUND); - - fixture.account().setCurrencyCode(CurrencyUnit.USD); - var converted = converter(fixture).convertDeliveryToBuySell(pair(fixture, delivery)); - var entry = fixture.client().getLedger().getEntries().get(0); - var cashPosting = posting(entry, LedgerPostingType.CASH); - - assertThat(converted.getAccountTransaction().getCurrencyCode(), is(CurrencyUnit.USD)); - assertThat(converted.getAccountTransaction().getAmount(), is(Values.Amount.factorize(123))); - assertThat(cashPosting.getCurrency(), is(CurrencyUnit.USD)); - assertThat(cashPosting.getAmount(), is(Values.Amount.factorize(123))); - assertThat(cashPosting.getForexCurrency(), is(CurrencyUnit.EUR)); - assertThat(cashPosting.getForexAmount(), is(Values.Amount.factorize(123))); - assertThat(cashPosting.getExchangeRate(), is(BigDecimal.ONE)); - assertValid(fixture.client()); - - var loaded = loadXml(saveXml(fixture.client())); - var loadedCashPosting = posting(loaded.getLedger().getEntries().get(0), LedgerPostingType.CASH); - assertThat(loadedCashPosting.getCurrency(), is(CurrencyUnit.USD)); - assertThat(loadedCashPosting.getForexCurrency(), is(CurrencyUnit.EUR)); - assertThat(loadedCashPosting.getExchangeRate(), is(BigDecimal.ONE)); - } - - /** - * Verifies that existing delivery forex metadata is used when creating the cash side. - * The converter must preserve the known relationship between delivery amount and account currency. - */ - @Test - public void testDeliveryWithForexMetadataConvertsToReferenceAccountCurrency() throws Exception - { - var fixture = fixture(); - var delivery = createDelivery(fixture, PortfolioTransaction.Type.DELIVERY_INBOUND, - Money.of(CurrencyUnit.USD, Values.Amount.factorize(246)), EXCHANGE_RATE); - - fixture.account().setCurrencyCode(CurrencyUnit.USD); - var converted = converter(fixture).convertDeliveryToBuySell(pair(fixture, delivery)); - var entry = fixture.client().getLedger().getEntries().get(0); - var cashPosting = posting(entry, LedgerPostingType.CASH); - - assertThat(converted.getAccountTransaction().getCurrencyCode(), is(CurrencyUnit.USD)); - assertThat(converted.getAccountTransaction().getAmount(), is(Values.Amount.factorize(246))); - assertThat(cashPosting.getCurrency(), is(CurrencyUnit.USD)); - assertThat(cashPosting.getAmount(), is(Values.Amount.factorize(246))); - assertThat(cashPosting.getForexCurrency(), is(CurrencyUnit.EUR)); - assertThat(cashPosting.getForexAmount(), is(Values.Amount.factorize(123))); - assertThat(cashPosting.getExchangeRate(), is(new BigDecimal("2.0000000000"))); - assertValid(fixture.client()); - - var loaded = loadXml(saveXml(fixture.client())); - var loadedCashPosting = posting(loaded.getLedger().getEntries().get(0), LedgerPostingType.CASH); - assertThat(loadedCashPosting.getCurrency(), is(CurrencyUnit.USD)); - assertThat(loadedCashPosting.getForexCurrency(), is(CurrencyUnit.EUR)); - assertThat(loadedCashPosting.getExchangeRate(), is(new BigDecimal("2.0000000000"))); - } - - /** - * Verifies that invalid delivery forex metadata reports the exact converter diagnostic. - */ - @Test - public void testDeliveryWithNonPositiveForexMetadataRejectsWithDiagnostic() - { - var fixture = fixture(); - var delivery = createDelivery(fixture, PortfolioTransaction.Type.DELIVERY_INBOUND, - Money.of(CurrencyUnit.USD, Values.Amount.factorize(246)), EXCHANGE_RATE); - var entry = fixture.client().getLedger().getEntries().get(0); - var securityPosting = posting(entry, LedgerPostingType.SECURITY); - - securityPosting.setExchangeRate(BigDecimal.ZERO); - fixture.account().setCurrencyCode(CurrencyUnit.USD); - var snapshot = Snapshot.capture(fixture.client()); - - var exception = assertThrows(IllegalArgumentException.class, - () -> converter(fixture).convertDeliveryToBuySell(pair(fixture, delivery))); - - assertThat(exception.getMessage(), containsString(LedgerDiagnosticCode.LEDGER_FOREX_003.prefix())); - assertThat(Snapshot.capture(fixture.client()), is(snapshot)); - } - - /** - * Verifies that the composite converter keeps its own delivery forex diagnostic. - */ - @Test - public void testCompositeDeliveryCashPostingWithNonPositiveForexMetadataUsesDistinctDiagnostic() throws Exception - { - var fixture = fixture(); - var securityPosting = new LedgerPosting("security-posting"); - var cashPosting = new LedgerPosting("cash-posting"); - var method = LedgerPortfolioCompositeTypeConverter.class.getDeclaredMethod("applyDeliveryCashPosting", - LedgerPosting.class, LedgerPosting.class, Account.class); - - securityPosting.setAmount(Values.Amount.factorize(123)); - securityPosting.setCurrency(CurrencyUnit.EUR); - securityPosting.setForexAmount(Values.Amount.factorize(246)); - securityPosting.setForexCurrency(CurrencyUnit.USD); - securityPosting.setExchangeRate(BigDecimal.ZERO); - fixture.account().setCurrencyCode(CurrencyUnit.USD); - method.setAccessible(true); - - var exception = assertThrows(InvocationTargetException.class, - () -> method.invoke(compositeConverter(fixture), securityPosting, cashPosting, - fixture.account())); - - assertThat(exception.getCause(), instanceOf(IllegalArgumentException.class)); - assertThat(exception.getCause().getMessage(), containsString(LedgerDiagnosticCode.LEDGER_FOREX_004.prefix())); - } - - /** - * Verifies that composite buy/sell conversion reports posting forex rejection with its FOREX code. - */ - @Test - public void testCompositeBuySellPostingForexRejectsWithDiagnostic() - { - var fixture = fixture(); - var buySell = create(fixture, PortfolioTransaction.Type.BUY); - var entry = fixture.client().getLedger().getEntries().get(0); - var cashPosting = posting(entry, LedgerPostingType.CASH); - - cashPosting.setForexAmount(Values.Amount.factorize(123)); - cashPosting.setForexCurrency(CurrencyUnit.USD); - cashPosting.setExchangeRate(EXCHANGE_RATE); - var snapshot = Snapshot.capture(fixture.client()); - - var exception = assertThrows(UnsupportedOperationException.class, - () -> compositeConverter(fixture).convert(pair(fixture, buySell.getPortfolioTransaction()))); - - assertThat(exception.getMessage(), containsString(LedgerDiagnosticCode.LEDGER_FOREX_005.prefix())); - assertThat(Snapshot.capture(fixture.client()), is(snapshot)); - } - - /** - * Verifies that a plan-generated delivery can become buy/sell without losing its plan reference. - * The execution ref must follow the surviving portfolio projection after save/load. - */ - @Test - public void testInvestmentPlanReferencedDeliveryConvertsToBuySellAndUpdatesPlanReference() throws Exception - { - var fixture = fixture(); - var delivery = createDelivery(fixture, PortfolioTransaction.Type.DELIVERY_INBOUND); - var entry = fixture.client().getLedger().getEntries().get(0); - var plan = new InvestmentPlan("Plan"); - - entry.setGeneratedByPlanKey(plan.getPlanKey()); - entry.setPlanExecutionDate(DATE_TIME.toLocalDate()); - entry.setPreferredViewKind(InvestmentPlan.LedgerExecutionViewKind.PORTFOLIO.name()); - fixture.client().addPlan(plan); - - var converted = converter(fixture).convertDeliveryToBuySell(pair(fixture, delivery)); - - assertThat(plan.getLedgerExecutionRefs(), is(List.of())); - assertThat(entry.getGeneratedByPlanKey(), is(plan.getPlanKey())); - assertThat(entry.getPlanExecutionDate(), is(DATE_TIME.toLocalDate())); - assertThat(entry.getPreferredViewKind(), is(InvestmentPlan.LedgerExecutionViewKind.PORTFOLIO.name())); - assertSame(converted.getPortfolioTransaction(), plan.getTransactions(fixture.client()).get(0).getTransaction()); - - var loaded = loadXml(saveXml(fixture.client())); - assertThat(((PortfolioTransaction) loaded.getPlans().get(0).getTransactions(loaded).get(0).getTransaction()) - .getType(), - is(PortfolioTransaction.Type.BUY)); - } - - /** - * Verifies that delivery to buy/sell conversion rejects a malformed delivery before mutation. - * The converter must not infer missing security facts from the runtime projection. - */ - @Test - public void testMalformedDeliveryShapeRejectsBeforeMutation() - { - var fixture = fixture(); - var delivery = createDelivery(fixture, PortfolioTransaction.Type.DELIVERY_INBOUND); - var entry = fixture.client().getLedger().getEntries().get(0); - var securityPosting = posting(entry, LedgerPostingType.SECURITY); - - entry.removePosting(securityPosting); - var snapshot = Snapshot.capture(fixture.client()); - - assertThrows(IllegalArgumentException.class, - () -> converter(fixture).convertDeliveryToBuySell(pair(fixture, delivery))); - - assertThat(Snapshot.capture(fixture.client()), is(snapshot)); - } - - private void assertConvertsBuySellToDelivery(PortfolioTransaction.Type sourceType, LedgerEntryType targetEntryType, - PortfolioTransaction.Type targetPortfolioType, LedgerProjectionRole targetProjectionRole) - throws Exception - { - var fixture = fixture(); - var buySell = create(fixture, sourceType); - var accountTransaction = buySell.getAccountTransaction(); - var portfolioTransaction = buySell.getPortfolioTransaction(); - var entry = fixture.client().getLedger().getEntries().get(0); - var entryUUID = entry.getUUID(); - var securityPosting = posting(entry, LedgerPostingType.SECURITY); - var cashPostingUUID = posting(entry, LedgerPostingType.CASH).getUUID(); - var securityPostingUUID = securityPosting.getUUID(); - var accountProjectionUUID = projection(entry, LedgerProjectionRole.ACCOUNT).getRuntimeProjectionId(); - var portfolioProjectionUUID = projection(entry, LedgerProjectionRole.PORTFOLIO).getRuntimeProjectionId(); - var unitPostingUUIDs = unitPostingUUIDs(entry); - - var converted = converter(fixture).convertBuySellToDelivery(pair(fixture, portfolioTransaction)); - - assertThat(entry.getUUID(), is(entryUUID)); - assertThat(entry.getType(), is(targetEntryType)); - assertThat(entry.getPostings().stream().noneMatch(posting -> posting.getUUID().equals(cashPostingUUID)), - is(true)); - assertThat(posting(entry, LedgerPostingType.SECURITY).getUUID(), is(securityPostingUUID)); - assertThat(unitPostingUUIDs(entry), is(unitPostingUUIDs)); - assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream() - .noneMatch(projection -> projection.getRuntimeProjectionId().equals(accountProjectionUUID)), is(true)); - assertThat(projection(entry, targetProjectionRole).getRole(), is(targetProjectionRole)); - assertSame(fixture.portfolio(), projection(entry, targetProjectionRole).getPortfolio()); - - assertTrue(fixture.account().getTransactions().isEmpty()); - assertThat(fixture.portfolio().getTransactions(), is(List.of(converted))); - assertThat(((LedgerBackedTransaction) converted).getLedgerProjectionRole(), is(targetProjectionRole)); - assertThat(converted, instanceOf(LedgerBackedTransaction.class)); - assertThat(converted.getType(), is(targetPortfolioType)); - assertThat(converted.getCrossEntry(), is(nullValue())); - assertThat(converted.getDateTime(), is(DATE_TIME)); - assertThat(converted.getNote(), is("note")); - assertThat(converted.getSource(), is("source")); - assertThat(converted.getAmount(), is(Values.Amount.factorize(123))); - assertThat(converted.getCurrencyCode(), is(CurrencyUnit.EUR)); - assertSame(fixture.security(), converted.getSecurity()); - assertThat(converted.getShares(), is(Values.Share.factorize(5))); - assertThat(converted.getUnits().count(), is(3L)); - assertThat(fixture.client().getAllTransactions().size(), is(1)); - assertSame(converted, fixture.client().getAllTransactions().get(0).getTransaction()); - assertThat(accountTransaction.getUUID(), is(accountProjectionUUID)); - assertThat(portfolioTransaction.getUUID(), is(portfolioProjectionUUID)); - assertValid(fixture.client()); - - assertConvertedRoundtrip(loadXml(saveXml(fixture.client())), entryUUID, securityPostingUUID, - portfolioProjectionUUID, targetEntryType, targetPortfolioType, targetProjectionRole); - assertConvertedRoundtrip(loadProtobuf(saveProtobuf(fixture.client())), entryUUID, securityPostingUUID, - portfolioProjectionUUID, targetEntryType, targetPortfolioType, targetProjectionRole); - } - - private void assertConvertsDeliveryToBuySell(PortfolioTransaction.Type sourceType, LedgerEntryType targetEntryType, - PortfolioTransaction.Type targetTransactionType, LedgerProjectionRole sourceProjectionRole) - throws Exception - { - var fixture = fixture(); - var delivery = createDelivery(fixture, sourceType); - var entry = fixture.client().getLedger().getEntries().get(0); - var entryUUID = entry.getUUID(); - var securityPosting = posting(entry, LedgerPostingType.SECURITY); - var securityPostingUUID = securityPosting.getUUID(); - var portfolioProjectionUUID = projection(entry, sourceProjectionRole).getRuntimeProjectionId(); - var unitPostingUUIDs = unitPostingUUIDs(entry); - - var converted = converter(fixture).convertDeliveryToBuySell(pair(fixture, delivery)); - var accountTransaction = converted.getAccountTransaction(); - var portfolioTransaction = converted.getPortfolioTransaction(); - var cashPosting = posting(entry, LedgerPostingType.CASH); - var accountProjection = projection(entry, LedgerProjectionRole.ACCOUNT); - var portfolioProjection = projection(entry, LedgerProjectionRole.PORTFOLIO); - - assertThat(entry.getUUID(), is(entryUUID)); - assertThat(entry.getType(), is(targetEntryType)); - assertThat(posting(entry, LedgerPostingType.SECURITY).getUUID(), is(securityPostingUUID)); - assertThat(unitPostingUUIDs(entry), is(unitPostingUUIDs)); - assertThat(cashPosting.getUUID().equals(securityPostingUUID), is(false)); - assertSame(fixture.account(), cashPosting.getAccount()); - assertThat(cashPosting.getAmount(), is(Values.Amount.factorize(123))); - assertThat(cashPosting.getCurrency(), is(CurrencyUnit.EUR)); - assertThat(cashPosting.getForexAmount(), is(nullValue())); - assertThat(cashPosting.getForexCurrency(), is(nullValue())); - assertThat(cashPosting.getExchangeRate(), is(nullValue())); - assertThat(accountProjection.getRuntimeProjectionId().equals(portfolioProjectionUUID), is(false)); - assertSame(fixture.account(), accountProjection.getAccount()); - assertThat(portfolioProjection.getRole(), is(LedgerProjectionRole.PORTFOLIO)); - assertSame(fixture.portfolio(), portfolioProjection.getPortfolio()); - - assertThat(fixture.account().getTransactions(), is(List.of(accountTransaction))); - assertThat(fixture.portfolio().getTransactions(), is(List.of(portfolioTransaction))); - assertThat(accountTransaction.getUUID(), is(accountProjection.getRuntimeProjectionId())); - assertThat(((LedgerBackedTransaction) portfolioTransaction).getLedgerProjectionRole(), is(LedgerProjectionRole.PORTFOLIO)); - assertThat(accountTransaction, instanceOf(LedgerBackedTransaction.class)); - assertThat(portfolioTransaction, instanceOf(LedgerBackedTransaction.class)); - assertThat(accountTransaction.getType().name(), is(targetTransactionType.name())); - assertThat(portfolioTransaction.getType(), is(targetTransactionType)); - assertSame(portfolioTransaction, accountTransaction.getCrossEntry().getCrossTransaction(accountTransaction)); - assertSame(accountTransaction, portfolioTransaction.getCrossEntry().getCrossTransaction(portfolioTransaction)); - assertSame(fixture.portfolio(), accountTransaction.getCrossEntry().getCrossOwner(accountTransaction)); - assertSame(fixture.account(), portfolioTransaction.getCrossEntry().getCrossOwner(portfolioTransaction)); - assertThat(accountTransaction.getDateTime(), is(DATE_TIME)); - assertThat(portfolioTransaction.getDateTime(), is(DATE_TIME)); - assertThat(accountTransaction.getNote(), is("note")); - assertThat(portfolioTransaction.getNote(), is("note")); - assertThat(accountTransaction.getSource(), is("source")); - assertThat(portfolioTransaction.getSource(), is("source")); - assertThat(accountTransaction.getAmount(), is(Values.Amount.factorize(123))); - assertThat(accountTransaction.getCurrencyCode(), is(CurrencyUnit.EUR)); - assertThat(portfolioTransaction.getAmount(), is(Values.Amount.factorize(123))); - assertThat(portfolioTransaction.getCurrencyCode(), is(CurrencyUnit.EUR)); - assertSame(fixture.security(), portfolioTransaction.getSecurity()); - assertThat(portfolioTransaction.getShares(), is(Values.Share.factorize(5))); - assertThat(portfolioTransaction.getUnits().count(), is(3L)); - assertThat(fixture.client().getAllTransactions().size(), is(1)); - assertSame(portfolioTransaction, fixture.client().getAllTransactions().get(0).getTransaction()); - assertValid(fixture.client()); - - assertDeliveryToBuySellRoundtrip(loadXml(saveXml(fixture.client())), entryUUID, securityPostingUUID, - cashPosting.getUUID(), portfolioProjectionUUID, accountProjection.getRuntimeProjectionId(), targetEntryType, - targetTransactionType); - assertDeliveryToBuySellRoundtrip(loadProtobuf(saveProtobuf(fixture.client())), entryUUID, securityPostingUUID, - cashPosting.getUUID(), portfolioProjectionUUID, accountProjection.getRuntimeProjectionId(), targetEntryType, - targetTransactionType); - } - - private void assertCompositeConvertsBuySellToDelivery(PortfolioTransaction.Type sourceType, - LedgerEntryType targetEntryType, PortfolioTransaction.Type targetPortfolioType, - LedgerProjectionRole targetProjectionRole, long targetAmount) throws Exception - { - var fixture = fixture(); - var buySell = create(fixture, sourceType); - var portfolioTransaction = buySell.getPortfolioTransaction(); - var entry = fixture.client().getLedger().getEntries().get(0); - var entryUUID = entry.getUUID(); - var cashPostingUUID = posting(entry, LedgerPostingType.CASH).getUUID(); - var securityPostingUUID = posting(entry, LedgerPostingType.SECURITY).getUUID(); - var accountProjectionUUID = projection(entry, LedgerProjectionRole.ACCOUNT).getRuntimeProjectionId(); - var unitPostingUUIDs = unitPostingUUIDs(entry); - - var converted = compositeConverter(fixture).convert(pair(fixture, portfolioTransaction)); - - assertThat(entry.getUUID(), is(entryUUID)); - assertThat(entry.getType(), is(targetEntryType)); - assertThat(entry.getPostings().stream().noneMatch(posting -> posting.getUUID().equals(cashPostingUUID)), - is(true)); - assertThat(posting(entry, LedgerPostingType.SECURITY).getUUID(), is(securityPostingUUID)); - assertThat(posting(entry, LedgerPostingType.SECURITY).getAmount(), is(targetAmount)); - assertThat(unitPostingUUIDs(entry), is(unitPostingUUIDs)); - assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream() - .noneMatch(projection -> projection.getRuntimeProjectionId().equals(accountProjectionUUID)), is(true)); - assertThat(projection(entry, targetProjectionRole).getRole(), is(targetProjectionRole)); - - assertTrue(fixture.account().getTransactions().isEmpty()); - assertThat(fixture.portfolio().getTransactions(), is(List.of(converted))); - assertThat(((LedgerBackedTransaction) converted).getLedgerProjectionRole(), is(targetProjectionRole)); - assertThat(converted.getType(), is(targetPortfolioType)); - assertThat(converted.getAmount(), is(targetAmount)); - assertThat(converted.getCrossEntry(), is(nullValue())); - assertThat(fixture.client().getAllTransactions().size(), is(1)); - assertSame(converted, fixture.client().getAllTransactions().get(0).getTransaction()); - assertValid(fixture.client()); - } - - private void assertCompositeConvertsDeliveryToBuySell(PortfolioTransaction.Type sourceType, - LedgerEntryType targetEntryType, PortfolioTransaction.Type targetTransactionType, - LedgerProjectionRole sourceProjectionRole, long targetAmount) throws Exception - { - var fixture = fixture(); - var delivery = createDelivery(fixture, sourceType); - var entry = fixture.client().getLedger().getEntries().get(0); - var entryUUID = entry.getUUID(); - var securityPostingUUID = posting(entry, LedgerPostingType.SECURITY).getUUID(); - var unitPostingUUIDs = unitPostingUUIDs(entry); - - var converted = compositeConverter(fixture).convert(pair(fixture, delivery)); - var accountTransaction = fixture.account().getTransactions().get(0); - var cashPosting = posting(entry, LedgerPostingType.CASH); - var accountProjection = projection(entry, LedgerProjectionRole.ACCOUNT); - var portfolioProjection = projection(entry, LedgerProjectionRole.PORTFOLIO); - - assertThat(entry.getUUID(), is(entryUUID)); - assertThat(entry.getType(), is(targetEntryType)); - assertThat(posting(entry, LedgerPostingType.SECURITY).getUUID(), is(securityPostingUUID)); - assertThat(posting(entry, LedgerPostingType.SECURITY).getAmount(), is(targetAmount)); - assertThat(unitPostingUUIDs(entry), is(unitPostingUUIDs)); - assertThat(cashPosting.getAmount(), is(targetAmount)); - assertSame(fixture.account(), cashPosting.getAccount()); - assertThat(portfolioProjection.getRole(), is(LedgerProjectionRole.PORTFOLIO)); - assertSame(fixture.portfolio(), portfolioProjection.getPortfolio()); - - assertThat(fixture.account().getTransactions(), is(List.of(accountTransaction))); - assertThat(fixture.portfolio().getTransactions(), is(List.of(converted))); - assertThat(accountTransaction.getUUID(), is(accountProjection.getRuntimeProjectionId())); - assertThat(((LedgerBackedTransaction) converted).getLedgerProjectionRole(), is(LedgerProjectionRole.PORTFOLIO)); - assertThat(accountTransaction.getType().name(), is(targetTransactionType.name())); - assertThat(converted.getType(), is(targetTransactionType)); - assertSame(accountTransaction, converted.getCrossEntry().getCrossTransaction(converted)); - assertSame(converted, accountTransaction.getCrossEntry().getCrossTransaction(accountTransaction)); - assertThat(accountTransaction.getAmount(), is(targetAmount)); - assertThat(converted.getAmount(), is(targetAmount)); - assertThat(fixture.client().getAllTransactions().size(), is(1)); - assertSame(converted, fixture.client().getAllTransactions().get(0).getTransaction()); - assertValid(fixture.client()); - } - - private void assertConvertedRoundtrip(Client client, String entryUUID, String securityPostingUUID, - String projectionUUID, LedgerEntryType entryType, PortfolioTransaction.Type transactionType, - LedgerProjectionRole projectionRole) - { - assertThat(client.getAccounts().get(0).getTransactions().isEmpty(), is(true)); - assertThat(client.getPortfolios().get(0).getTransactions().size(), is(1)); - assertThat(client.getAllTransactions().size(), is(1)); - - var entry = client.getLedger().getEntries().get(0); - var transaction = client.getPortfolios().get(0).getTransactions().get(0); - - assertThat(entry.getType(), is(entryType)); - assertThat(entry.getPostings().stream().noneMatch(posting -> posting.getType() == LedgerPostingType.CASH), - is(true)); - assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).size(), is(1)); - assertThat(projection(entry, projectionRole).getRole(), is(projectionRole)); - assertThat(((LedgerBackedTransaction) transaction).getLedgerProjectionRole(), is(projectionRole)); - assertThat(transaction.getType(), is(transactionType)); - assertThat(transaction.getCrossEntry(), is(nullValue())); - assertThat(transaction.getUnits().count(), is(3L)); - assertValid(client); - } - - private void assertDeliveryToBuySellRoundtrip(Client client, String entryUUID, String securityPostingUUID, - String cashPostingUUID, String portfolioProjectionUUID, String accountProjectionUUID, - LedgerEntryType entryType, PortfolioTransaction.Type transactionType) - { - assertThat(client.getAccounts().get(0).getTransactions().size(), is(1)); - assertThat(client.getPortfolios().get(0).getTransactions().size(), is(1)); - assertThat(client.getAllTransactions().size(), is(1)); - - var entry = client.getLedger().getEntries().get(0); - var accountTransaction = client.getAccounts().get(0).getTransactions().get(0); - var portfolioTransaction = client.getPortfolios().get(0).getTransactions().get(0); - - assertThat(entry.getType(), is(entryType)); - assertThat(projection(entry, LedgerProjectionRole.ACCOUNT).getRole(), is(LedgerProjectionRole.ACCOUNT)); - assertThat(projection(entry, LedgerProjectionRole.PORTFOLIO).getRole(), is(LedgerProjectionRole.PORTFOLIO)); - assertThat(((LedgerBackedTransaction) accountTransaction).getLedgerProjectionRole(), is(LedgerProjectionRole.ACCOUNT)); - assertThat(((LedgerBackedTransaction) portfolioTransaction).getLedgerProjectionRole(), is(LedgerProjectionRole.PORTFOLIO)); - assertThat(accountTransaction.getType().name(), is(transactionType.name())); - assertThat(portfolioTransaction.getType(), is(transactionType)); - assertSame(portfolioTransaction, accountTransaction.getCrossEntry().getCrossTransaction(accountTransaction)); - assertSame(accountTransaction, portfolioTransaction.getCrossEntry().getCrossTransaction(portfolioTransaction)); - assertThat(portfolioTransaction.getUnits().count(), is(3L)); - assertValid(client); - } - - private name.abuchen.portfolio.model.BuySellEntry create(Fixture fixture, PortfolioTransaction.Type type) - { - return new LedgerBuySellTransactionCreator(fixture.client()).create(fixture.portfolio(), fixture.account(), - type, DATE_TIME, Values.Amount.factorize(123), CurrencyUnit.EUR, fixture.security(), - Values.Share.factorize(5), units(), "note", "source"); - } - - private PortfolioTransaction createDelivery(Fixture fixture, PortfolioTransaction.Type type) - { - return createDelivery(fixture, type, null, null); - } - - private PortfolioTransaction createDelivery(Fixture fixture, PortfolioTransaction.Type type, Money forexAmount, - BigDecimal exchangeRate) - { - return new LedgerDeliveryTransactionCreator(fixture.client()).create(fixture.portfolio(), type, DATE_TIME, - Values.Amount.factorize(123), CurrencyUnit.EUR, fixture.security(), Values.Share.factorize(5), - forexAmount, exchangeRate, units(), "note", "source"); - } - - private List units() - { - return List.of( - new Unit(Unit.Type.FEE, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(3)), - Money.of(CurrencyUnit.USD, Values.Amount.factorize(6)), EXCHANGE_RATE), - new Unit(Unit.Type.TAX, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(4))), - new Unit(Unit.Type.GROSS_VALUE, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(120)), - Money.of(CurrencyUnit.USD, Values.Amount.factorize(240)), EXCHANGE_RATE)); - } - - private LedgerBuySellDeliveryConverter converter(Fixture fixture) - { - return new LedgerBuySellDeliveryConverter(fixture.client()); - } - - private LedgerPortfolioCompositeTypeConverter compositeConverter(Fixture fixture) - { - return new LedgerPortfolioCompositeTypeConverter(fixture.client()); - } - - private TransactionPair pair(Fixture fixture, PortfolioTransaction transaction) - { - return new TransactionPair<>(fixture.portfolio(), transaction); - } - - private name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor projection(LedgerEntry entry, LedgerProjectionRole role) - { - return name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream().filter(projection -> projection.getRole() == role).findFirst() - .orElseThrow(); - } - - private LedgerPosting posting(LedgerEntry entry, LedgerPostingType type) - { - return entry.getPostings().stream().filter(posting -> posting.getType() == type).findFirst().orElseThrow(); - } - - private List unitPostingUUIDs(LedgerEntry entry) - { - return entry.getPostings().stream().filter(posting -> posting.getType() == LedgerPostingType.FEE - || posting.getType() == LedgerPostingType.TAX - || posting.getType() == LedgerPostingType.GROSS_VALUE).map(LedgerPosting::getUUID).toList(); - } - - private String saveXml(Client client) throws IOException - { - var file = File.createTempFile("ledger-buy-sell-delivery-converter", ".xml"); - try - { - ClientFactory.save(client, file); - return Files.readString(file.toPath(), StandardCharsets.UTF_8); - } - finally - { - Files.deleteIfExists(file.toPath()); - } - } - - private Client loadXml(String xml) throws IOException - { - return ClientFactory.load(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); - } - - private byte[] saveProtobuf(Client client) throws IOException - { - return ProtobufTestUtilities.save(client); - } - - private Client loadProtobuf(byte[] bytes) throws IOException - { - return ProtobufTestUtilities.load(bytes); - } - - private void assertValid(Client client) - { - var result = LedgerStructuralValidator.validate(client.getLedger()); - - assertThat(result.format(), result.isOK(), is(true)); - } - - private Fixture fixture() - { - var client = new Client(); - var account = new Account("Account"); - var portfolio = new Portfolio("Portfolio"); - var security = new Security("Security", CurrencyUnit.EUR); - - account.setCurrencyCode(CurrencyUnit.EUR); - portfolio.setReferenceAccount(account); - account.setUpdatedAt(Instant.now()); - portfolio.setUpdatedAt(Instant.now()); - security.setUpdatedAt(Instant.now()); - client.addAccount(account); - client.addPortfolio(portfolio); - client.addSecurity(security); - - return new Fixture(client, account, portfolio, security); - } - - private record Fixture(Client client, Account account, Portfolio portfolio, Security security) - { - } - - private record Snapshot(List entries, List accountTransactions, - List portfolioTransactions, List allTransactions) - { - static Snapshot capture(Client client) - { - return new Snapshot(client.getLedger().getEntries().stream().map(EntrySnapshot::capture).toList(), - client.getAccounts().stream().flatMap(account -> account.getTransactions().stream()) - .map(Transaction::getUUID).toList(), - client.getPortfolios().stream().flatMap(portfolio -> portfolio.getTransactions().stream()) - .map(Transaction::getUUID).toList(), - client.getAllTransactions().stream().map(pair -> pair.getTransaction().getUUID()) - .toList()); - } - } - - private record EntrySnapshot(String uuid, LedgerEntryType type, List postings, - List projections) - { - static EntrySnapshot capture(LedgerEntry entry) - { - List projections; - try - { - projections = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry) - .stream().map(ProjectionSnapshot::capture).toList(); - } - catch (IllegalArgumentException e) - { - projections = List.of(); - } - - return new EntrySnapshot(entry.getUUID(), entry.getType(), - entry.getPostings().stream().map(PostingSnapshot::capture).toList(), - projections); - } - } - - private record PostingSnapshot(String uuid, LedgerPostingType type, Long amount, String currency, Long forexAmount, - String forexCurrency, BigDecimal exchangeRate, Security security, Long shares, Account account, - Portfolio portfolio) - { - static PostingSnapshot capture(LedgerPosting posting) - { - return new PostingSnapshot(posting.getUUID(), posting.getType(), posting.getAmount(), posting.getCurrency(), - posting.getForexAmount(), posting.getForexCurrency(), posting.getExchangeRate(), - posting.getSecurity(), posting.getShares(), posting.getAccount(), posting.getPortfolio()); - } - } - - private record ProjectionSnapshot(String uuid, LedgerProjectionRole role, Account account, Portfolio portfolio, - String primaryPostingId, String groupKey) - { - static ProjectionSnapshot capture(name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor projection) - { - return new ProjectionSnapshot(projection.getRuntimeProjectionId(), projection.getRole(), projection.getAccount(), - projection.getPortfolio(), projection.getPrimaryPosting().getUUID(), - projection.getPrimaryPosting().getGroupKey()); - } - } -} - diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellReversalConverterTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellReversalConverterTest.java deleted file mode 100644 index b967663d41..0000000000 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellReversalConverterTest.java +++ /dev/null @@ -1,482 +0,0 @@ -package name.abuchen.portfolio.model.ledger.compatibility; - -import static org.hamcrest.CoreMatchers.instanceOf; -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertThrows; - -import java.io.ByteArrayInputStream; -import java.io.File; -import java.io.IOException; -import java.math.BigDecimal; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.time.Instant; -import java.time.LocalDateTime; -import java.util.List; - -import org.junit.Test; - -import name.abuchen.portfolio.model.Account; -import name.abuchen.portfolio.model.BuySellEntry; -import name.abuchen.portfolio.model.Client; -import name.abuchen.portfolio.model.ClientFactory; -import name.abuchen.portfolio.model.InvestmentPlan; -import name.abuchen.portfolio.model.Portfolio; -import name.abuchen.portfolio.model.PortfolioTransaction; -import name.abuchen.portfolio.model.ProtobufTestUtilities; -import name.abuchen.portfolio.model.Security; -import name.abuchen.portfolio.model.Transaction; -import name.abuchen.portfolio.model.Transaction.Unit; -import name.abuchen.portfolio.model.ledger.LedgerEntry; -import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; -import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; -import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; -import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; -import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; -import name.abuchen.portfolio.money.CurrencyUnit; -import name.abuchen.portfolio.money.Money; -import name.abuchen.portfolio.money.Values; - -/** - * Tests ledger-aware reversal between buy and sell transactions. - * These tests make sure the same booking changes direction without replacing projections or plan references incorrectly. - */ -@SuppressWarnings("nls") -public class LedgerBuySellReversalConverterTest -{ - private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 6, 8, 9, 10); - private static final BigDecimal EXCHANGE_RATE = new BigDecimal("0.5000"); - - /** - * Verifies that a ledger-backed buy can be reversed into a sell through the converter. - * The booking identity, projections, units, and owner lists must stay consistent after save/load. - */ - @Test - public void testReversesLedgerBackedBuyToSellPreservingIdentityAndTruth() throws Exception - { - assertReversesBuySell(PortfolioTransaction.Type.BUY, LedgerEntryType.SELL, PortfolioTransaction.Type.SELL, - Values.Amount.factorize(113)); - } - - /** - * Verifies that a ledger-backed sell can be reversed into a buy through the converter. - * The booking identity, projections, units, and owner lists must stay consistent after save/load. - */ - @Test - public void testReversesLedgerBackedSellToBuyPreservingIdentityAndTruth() throws Exception - { - assertReversesBuySell(PortfolioTransaction.Type.SELL, LedgerEntryType.BUY, PortfolioTransaction.Type.BUY, - Values.Amount.factorize(127)); - } - - /** - * Verifies that reversal stops before mutation when the account-side projection is missing. - * The converter must not rebuild a missing runtime view from guesses. - */ - @Test - public void testMalformedBuySellMissingaccountProjectionRejectsBeforeMutation() - { - var fixture = fixture(); - var buySell = create(fixture, PortfolioTransaction.Type.BUY); - var entry = fixture.client().getLedger().getEntries().get(0); - - projection(entry, LedgerProjectionRole.ACCOUNT).getPrimaryPosting().setSemanticRole(null); - assertRejectsWithoutMutation(fixture, () -> converter(fixture).reverse(buySell), IllegalArgumentException.class); - } - - /** - * Verifies that reversal stops before mutation when the portfolio-side projection is missing. - * The ledger entry and owner lists must remain unchanged instead of creating a second truth. - */ - @Test - public void testMalformedBuySellMissingportfolioProjectionRejectsBeforeMutation() - { - var fixture = fixture(); - var buySell = create(fixture, PortfolioTransaction.Type.BUY); - var entry = fixture.client().getLedger().getEntries().get(0); - - projection(entry, LedgerProjectionRole.PORTFOLIO).getPrimaryPosting().setSemanticRole(null); - assertRejectsWithoutMutation(fixture, () -> converter(fixture).reverse(buySell), IllegalArgumentException.class); - } - - /** - * Verifies that reversal stops before mutation when the cash posting is missing. - * A buy/sell direction change cannot infer the missing account-side cash facts safely. - */ - @Test - public void testMalformedBuySellMissingCashPostingRejectsBeforeMutation() - { - var fixture = fixture(); - var buySell = create(fixture, PortfolioTransaction.Type.BUY); - var entry = fixture.client().getLedger().getEntries().get(0); - - entry.removePosting(posting(entry, LedgerPostingType.CASH)); - assertRejectsWithoutMutation(fixture, () -> converter(fixture).reverse(buySell), IllegalArgumentException.class); - } - - /** - * Verifies that reversal stops before mutation when the security posting is missing. - * The converter must not infer security facts from the projected portfolio transaction. - */ - @Test - public void testMalformedBuySellMissingSecurityPostingRejectsBeforeMutation() - { - var fixture = fixture(); - var buySell = create(fixture, PortfolioTransaction.Type.BUY); - var entry = fixture.client().getLedger().getEntries().get(0); - - entry.removePosting(posting(entry, LedgerPostingType.SECURITY)); - assertRejectsWithoutMutation(fixture, () -> converter(fixture).reverse(buySell), IllegalArgumentException.class); - } - - /** - * Verifies that buy/sell reversal does not reinterpret unsupported forex cash facts. - * The converter must reject before mutation when it cannot preserve those facts safely. - */ - @Test - public void testUnsupportedCashForexRejectsBeforeMutation() - { - var fixture = fixture(); - var buySell = create(fixture, PortfolioTransaction.Type.BUY); - var cashPosting = posting(fixture.client().getLedger().getEntries().get(0), LedgerPostingType.CASH); - - cashPosting.setForexAmount(Values.Amount.factorize(246)); - cashPosting.setForexCurrency(CurrencyUnit.USD); - cashPosting.setExchangeRate(EXCHANGE_RATE); - - assertThat(converter(fixture).canReverseSafely(buySell), is(false)); - assertRejectsWithoutMutation(fixture, () -> converter(fixture).reverse(buySell), - UnsupportedOperationException.class); - } - - /** - * Verifies that a plan-generated buy/sell booking can be reversed without losing the plan reference. - * The execution ref must still resolve to the same projected booking after save/load. - */ - @Test - public void testInvestmentPlanReferencedBuySellReversesAndKeepsPlanReference() throws Exception - { - var fixture = fixture(); - var buySell = create(fixture, PortfolioTransaction.Type.BUY); - var entry = fixture.client().getLedger().getEntries().get(0); - var plan = new InvestmentPlan("Plan"); - - entry.setGeneratedByPlanKey(plan.getPlanKey()); - entry.setPlanExecutionDate(DATE_TIME.toLocalDate()); - entry.setPreferredViewKind(InvestmentPlan.LedgerExecutionViewKind.PORTFOLIO.name()); - fixture.client().addPlan(plan); - - var reversed = converter(fixture).reverse(buySell); - - assertThat(plan.getLedgerExecutionRefs(), is(List.of())); - assertThat(entry.getGeneratedByPlanKey(), is(plan.getPlanKey())); - assertThat(entry.getPlanExecutionDate(), is(DATE_TIME.toLocalDate())); - assertThat(entry.getPreferredViewKind(), is(InvestmentPlan.LedgerExecutionViewKind.PORTFOLIO.name())); - assertSame(reversed.getPortfolioTransaction(), plan.getTransactions(fixture.client()).get(0).getTransaction()); - - var loaded = loadXml(saveXml(fixture.client())); - assertThat(((PortfolioTransaction) loaded.getPlans().get(0).getTransactions(loaded).get(0).getTransaction()) - .getType(), - is(PortfolioTransaction.Type.SELL)); - } - - /** - * Verifies that plan-key execution metadata does not block buy/sell reversal. - * Projection-scoped execution refs are obsolete and must not drive converter behavior. - */ - @Test - public void testPlanMetadataDoesNotBlockBuySellReversal() - { - var fixture = fixture(); - var buySell = create(fixture, PortfolioTransaction.Type.BUY); - var entry = fixture.client().getLedger().getEntries().get(0); - var plan = new InvestmentPlan("Plan"); - - entry.setGeneratedByPlanKey(plan.getPlanKey()); - entry.setPlanExecutionDate(DATE_TIME.toLocalDate()); - entry.setPreferredViewKind(InvestmentPlan.LedgerExecutionViewKind.PORTFOLIO.name()); - fixture.client().addPlan(plan); - - converter(fixture).reverse(buySell); - - assertThat(plan.getLedgerExecutionRefs(), is(List.of())); - assertThat(entry.getGeneratedByPlanKey(), is(plan.getPlanKey())); - assertThat(entry.getPreferredViewKind(), is(InvestmentPlan.LedgerExecutionViewKind.PORTFOLIO.name())); - } - - private void assertReversesBuySell(PortfolioTransaction.Type sourceType, LedgerEntryType targetEntryType, - PortfolioTransaction.Type targetTransactionType, long expectedAmount) throws Exception - { - var fixture = fixture(); - var buySell = create(fixture, sourceType); - var accountTransaction = buySell.getAccountTransaction(); - var portfolioTransaction = buySell.getPortfolioTransaction(); - var entry = fixture.client().getLedger().getEntries().get(0); - var entryUUID = entry.getUUID(); - var cashPostingUUID = posting(entry, LedgerPostingType.CASH).getUUID(); - var securityPostingUUID = posting(entry, LedgerPostingType.SECURITY).getUUID(); - var accountProjectionUUID = projection(entry, LedgerProjectionRole.ACCOUNT).getRuntimeProjectionId(); - var portfolioProjectionUUID = projection(entry, LedgerProjectionRole.PORTFOLIO).getRuntimeProjectionId(); - var unitSnapshots = unitSnapshots(entry); - - var reversed = converter(fixture).reverse(buySell); - var reversedAccount = reversed.getAccountTransaction(); - var reversedPortfolio = reversed.getPortfolioTransaction(); - - assertThat(entry.getUUID(), is(entryUUID)); - assertThat(entry.getType(), is(targetEntryType)); - assertThat(posting(entry, LedgerPostingType.CASH).getUUID(), is(cashPostingUUID)); - assertThat(posting(entry, LedgerPostingType.SECURITY).getUUID(), is(securityPostingUUID)); - assertThat(posting(entry, LedgerPostingType.CASH).getAmount(), is(expectedAmount)); - assertThat(posting(entry, LedgerPostingType.SECURITY).getAmount(), is(expectedAmount)); - assertThat(unitSnapshots(entry), is(unitSnapshots)); - assertThat(projection(entry, LedgerProjectionRole.ACCOUNT).getRuntimeProjectionId(), is(accountProjectionUUID)); - assertThat(projection(entry, LedgerProjectionRole.PORTFOLIO).getRuntimeProjectionId(), is(portfolioProjectionUUID)); - - assertThat(fixture.account().getTransactions(), is(List.of(reversedAccount))); - assertThat(fixture.portfolio().getTransactions(), is(List.of(reversedPortfolio))); - assertThat(fixture.client().getAllTransactions().size(), is(1)); - assertSame(reversedPortfolio, fixture.client().getAllTransactions().get(0).getTransaction()); - assertThat(reversedAccount.getUUID(), is(accountProjectionUUID)); - assertThat(reversedPortfolio.getUUID(), is(portfolioProjectionUUID)); - assertThat(reversedAccount, instanceOf(LedgerBackedTransaction.class)); - assertThat(reversedPortfolio, instanceOf(LedgerBackedTransaction.class)); - assertThat(reversedAccount.getType().name(), is(targetTransactionType.name())); - assertThat(reversedPortfolio.getType(), is(targetTransactionType)); - assertSame(reversedPortfolio, reversedAccount.getCrossEntry().getCrossTransaction(reversedAccount)); - assertSame(reversedAccount, reversedPortfolio.getCrossEntry().getCrossTransaction(reversedPortfolio)); - assertThat(reversedAccount.getDateTime(), is(DATE_TIME)); - assertThat(reversedPortfolio.getDateTime(), is(DATE_TIME)); - assertThat(reversedAccount.getNote(), is("note")); - assertThat(reversedPortfolio.getNote(), is("note")); - assertThat(reversedAccount.getSource(), is("source")); - assertThat(reversedPortfolio.getSource(), is("source")); - assertSame(fixture.security(), reversedPortfolio.getSecurity()); - assertThat(reversedPortfolio.getShares(), is(Values.Share.factorize(5))); - assertThat(reversedAccount.getAmount(), is(expectedAmount)); - assertThat(reversedPortfolio.getAmount(), is(expectedAmount)); - assertThat(reversedPortfolio.getGrossValue(), is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(120)))); - assertThat(reversedPortfolio.getUnit(Unit.Type.GROSS_VALUE).orElseThrow().getAmount(), - is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(120)))); - assertThat(reversedPortfolio.getUnitSum(Unit.Type.FEE), is(Money.of(CurrencyUnit.EUR, - Values.Amount.factorize(3)))); - assertThat(reversedPortfolio.getUnitSum(Unit.Type.TAX), is(Money.of(CurrencyUnit.EUR, - Values.Amount.factorize(4)))); - assertThat(accountTransaction.getUUID(), is(accountProjectionUUID)); - assertThat(portfolioTransaction.getUUID(), is(portfolioProjectionUUID)); - assertValid(fixture.client()); - - assertRoundtrip(loadXml(saveXml(fixture.client())), entryUUID, cashPostingUUID, securityPostingUUID, - accountProjectionUUID, portfolioProjectionUUID, targetEntryType, targetTransactionType, - expectedAmount); - assertRoundtrip(loadProtobuf(saveProtobuf(fixture.client())), entryUUID, cashPostingUUID, securityPostingUUID, - accountProjectionUUID, portfolioProjectionUUID, targetEntryType, targetTransactionType, - expectedAmount); - } - - private void assertRoundtrip(Client client, String entryUUID, String cashPostingUUID, String securityPostingUUID, - String accountProjectionUUID, String portfolioProjectionUUID, LedgerEntryType entryType, - PortfolioTransaction.Type transactionType, long expectedAmount) - { - assertThat(client.getAccounts().get(0).getTransactions().size(), is(1)); - assertThat(client.getPortfolios().get(0).getTransactions().size(), is(1)); - assertThat(client.getAllTransactions().size(), is(1)); - - var entry = client.getLedger().getEntries().get(0); - var accountTransaction = client.getAccounts().get(0).getTransactions().get(0); - var portfolioTransaction = client.getPortfolios().get(0).getTransactions().get(0); - - assertThat(entry.getType(), is(entryType)); - assertThat(posting(entry, LedgerPostingType.CASH).getAmount(), is(expectedAmount)); - assertThat(posting(entry, LedgerPostingType.SECURITY).getAmount(), is(expectedAmount)); - assertThat(projection(entry, LedgerProjectionRole.ACCOUNT).getRole(), is(LedgerProjectionRole.ACCOUNT)); - assertThat(projection(entry, LedgerProjectionRole.PORTFOLIO).getRole(), is(LedgerProjectionRole.PORTFOLIO)); - assertThat(accountTransaction.getType().name(), is(transactionType.name())); - assertThat(portfolioTransaction.getType(), is(transactionType)); - assertThat(((LedgerBackedTransaction) accountTransaction).getLedgerProjectionRole(), is(LedgerProjectionRole.ACCOUNT)); - assertThat(((LedgerBackedTransaction) portfolioTransaction).getLedgerProjectionRole(), is(LedgerProjectionRole.PORTFOLIO)); - assertSame(portfolioTransaction, accountTransaction.getCrossEntry().getCrossTransaction(accountTransaction)); - assertSame(accountTransaction, portfolioTransaction.getCrossEntry().getCrossTransaction(portfolioTransaction)); - assertThat(portfolioTransaction.getGrossValue(), is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(120)))); - assertValid(client); - } - - private T assertRejectsWithoutMutation(Fixture fixture, ThrowingRunnable runnable, - Class expectedType) - { - var snapshot = Snapshot.capture(fixture.client()); - - var exception = assertThrows(expectedType, runnable::run); - assertThat(Snapshot.capture(fixture.client()), is(snapshot)); - return exception; - } - - private BuySellEntry create(Fixture fixture, PortfolioTransaction.Type type) - { - return new LedgerBuySellTransactionCreator(fixture.client()).create(fixture.portfolio(), fixture.account(), - type, DATE_TIME, Values.Amount.factorize(123), CurrencyUnit.EUR, fixture.security(), - Values.Share.factorize(5), units(), "note", "source"); - } - - private List units() - { - return List.of( - new Unit(Unit.Type.FEE, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(3)), - Money.of(CurrencyUnit.USD, Values.Amount.factorize(6)), EXCHANGE_RATE), - new Unit(Unit.Type.TAX, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(4))), - new Unit(Unit.Type.GROSS_VALUE, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(120)), - Money.of(CurrencyUnit.USD, Values.Amount.factorize(240)), EXCHANGE_RATE)); - } - - private LedgerBuySellReversalConverter converter(Fixture fixture) - { - return new LedgerBuySellReversalConverter(fixture.client()); - } - - private name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor projection(LedgerEntry entry, LedgerProjectionRole role) - { - return name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream().filter(projection -> projection.getRole() == role).findFirst() - .orElseThrow(); - } - - private LedgerPosting posting(LedgerEntry entry, LedgerPostingType type) - { - return entry.getPostings().stream().filter(posting -> posting.getType() == type).findFirst().orElseThrow(); - } - - private List unitSnapshots(LedgerEntry entry) - { - return entry.getPostings().stream().filter(posting -> posting.getType() == LedgerPostingType.FEE - || posting.getType() == LedgerPostingType.TAX - || posting.getType() == LedgerPostingType.GROSS_VALUE).map(PostingSnapshot::capture).toList(); - } - - private String saveXml(Client client) throws IOException - { - var file = File.createTempFile("ledger-buy-sell-reversal-converter", ".xml"); - try - { - ClientFactory.save(client, file); - return Files.readString(file.toPath(), StandardCharsets.UTF_8); - } - finally - { - Files.deleteIfExists(file.toPath()); - } - } - - private Client loadXml(String xml) throws IOException - { - return ClientFactory.load(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); - } - - private byte[] saveProtobuf(Client client) throws IOException - { - return ProtobufTestUtilities.save(client); - } - - private Client loadProtobuf(byte[] bytes) throws IOException - { - return ProtobufTestUtilities.load(bytes); - } - - private void assertValid(Client client) - { - var result = LedgerStructuralValidator.validate(client.getLedger()); - - assertThat(result.format(), result.isOK(), is(true)); - } - - private Fixture fixture() - { - var client = new Client(); - var account = new Account("Account"); - var portfolio = new Portfolio("Portfolio"); - var security = new Security("Security", CurrencyUnit.EUR); - - account.setCurrencyCode(CurrencyUnit.EUR); - portfolio.setReferenceAccount(account); - account.setUpdatedAt(Instant.now()); - portfolio.setUpdatedAt(Instant.now()); - security.setUpdatedAt(Instant.now()); - client.addAccount(account); - client.addPortfolio(portfolio); - client.addSecurity(security); - - return new Fixture(client, account, portfolio, security); - } - - @FunctionalInterface - private interface ThrowingRunnable - { - void run() throws Exception; - } - - private record Fixture(Client client, Account account, Portfolio portfolio, Security security) - { - } - - private record Snapshot(List entries, List accountTransactions, - List portfolioTransactions, List allTransactions) - { - static Snapshot capture(Client client) - { - return new Snapshot(client.getLedger().getEntries().stream().map(EntrySnapshot::capture).toList(), - client.getAccounts().stream().flatMap(account -> account.getTransactions().stream()) - .map(Transaction::getUUID).toList(), - client.getPortfolios().stream().flatMap(portfolio -> portfolio.getTransactions().stream()) - .map(Transaction::getUUID).toList(), - client.getAllTransactions().stream().map(pair -> pair.getTransaction().getUUID()) - .toList()); - } - } - - private record EntrySnapshot(String uuid, LedgerEntryType type, List postings, - List projections) - { - static EntrySnapshot capture(LedgerEntry entry) - { - List projections; - try - { - projections = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry) - .stream().map(ProjectionSnapshot::capture).toList(); - } - catch (IllegalArgumentException e) - { - projections = List.of(); - } - - return new EntrySnapshot(entry.getUUID(), entry.getType(), - entry.getPostings().stream().map(PostingSnapshot::capture).toList(), - projections); - } - } - - private record PostingSnapshot(String uuid, LedgerPostingType type, Long amount, String currency, Long forexAmount, - String forexCurrency, BigDecimal exchangeRate, Security security, Long shares, Account account, - Portfolio portfolio) - { - static PostingSnapshot capture(LedgerPosting posting) - { - return new PostingSnapshot(posting.getUUID(), posting.getType(), posting.getAmount(), posting.getCurrency(), - posting.getForexAmount(), posting.getForexCurrency(), posting.getExchangeRate(), - posting.getSecurity(), posting.getShares(), posting.getAccount(), posting.getPortfolio()); - } - } - - private record ProjectionSnapshot(String uuid, LedgerProjectionRole role, Account account, Portfolio portfolio, - String primaryPostingId, String groupKey) - { - static ProjectionSnapshot capture(name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor projection) - { - return new ProjectionSnapshot(projection.getRuntimeProjectionId(), projection.getRole(), projection.getAccount(), - projection.getPortfolio(), projection.getPrimaryPosting().getUUID(), - projection.getPrimaryPosting().getGroupKey()); - } - } -} - diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellTransactionCreatorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellTransactionCreatorTest.java deleted file mode 100644 index 40e356c208..0000000000 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellTransactionCreatorTest.java +++ /dev/null @@ -1,529 +0,0 @@ -package name.abuchen.portfolio.model.ledger.compatibility; - -import static org.hamcrest.CoreMatchers.instanceOf; -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; - -import java.io.ByteArrayInputStream; -import java.io.File; -import java.io.IOException; -import java.math.BigDecimal; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.time.Instant; -import java.time.LocalDateTime; -import java.util.List; - -import org.junit.Test; - -import name.abuchen.portfolio.model.Account; -import name.abuchen.portfolio.model.AccountTransaction; -import name.abuchen.portfolio.model.BuySellEntry; -import name.abuchen.portfolio.model.Client; -import name.abuchen.portfolio.model.ClientFactory; -import name.abuchen.portfolio.model.Portfolio; -import name.abuchen.portfolio.model.PortfolioTransaction; -import name.abuchen.portfolio.model.ProtobufTestUtilities; -import name.abuchen.portfolio.model.Security; -import name.abuchen.portfolio.model.Transaction.Unit; -import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; -import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; -import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; -import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; -import name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor; -import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; -import name.abuchen.portfolio.money.CurrencyUnit; -import name.abuchen.portfolio.money.Money; -import name.abuchen.portfolio.money.Values; - -/** - * Tests ledger-aware creation and editing of transactions in this family. - * These tests make sure user-visible rows are rebuilt from ledger truth and structural facts are not written through legacy projections. - */ -@SuppressWarnings("nls") -public class LedgerBuySellTransactionCreatorTest -{ - private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 6, 7, 8, 9); - - /** - * Verifies that a buy booking is created directly in the ledger. - * The account and portfolio rows must be derived projections of one persisted booking. - */ - @Test - public void testCreatesLedgerBuyDirectly() - { - var fixture = fixture(); - var entry = createBuy(fixture); - - assertCreatedBuySell(fixture, entry, LedgerEntryType.BUY, PortfolioTransaction.Type.BUY, - AccountTransaction.Type.BUY); - assertValid(fixture.client()); - } - - /** - * Verifies that a sell booking is created directly in the ledger. - * The account and portfolio rows must be derived projections of one persisted booking. - */ - @Test - public void testCreatesLedgerSellDirectly() - { - var fixture = fixture(); - var entry = createSell(fixture); - - assertCreatedBuySell(fixture, entry, LedgerEntryType.SELL, PortfolioTransaction.Type.SELL, - AccountTransaction.Type.SELL); - assertValid(fixture.client()); - } - - /** - * Verifies that ledger-backed buy/sell projections expose gross-value read methods. - * UI and import code must be able to read unit-derived values without mutating projections. - */ - @Test - public void testLedgerBackedBuySellSupportsGrossValueReadMethods() - { - var fixture = fixture(); - var buy = createBuy(fixture); - var sell = createSell(fixture); - - assertThat(buy.getAccountTransaction().getGrossValueAmount(), is(Values.Amount.factorize(116))); - assertThat(buy.getAccountTransaction().getGrossValue(), is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(116)))); - assertTrue(buy.getAccountTransaction().toString().contains("BUY")); - assertThat(buy.getPortfolioTransaction().getGrossValueAmount(), is(Values.Amount.factorize(116))); - assertThat(buy.getPortfolioTransaction().getGrossValue(), is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(116)))); - assertThat(buy.getPortfolioTransaction().getGrossPricePerShare().getCurrencyCode(), is(CurrencyUnit.EUR)); - assertTrue(buy.getPortfolioTransaction().toString().contains("BUY")); - - assertThat(sell.getAccountTransaction().getGrossValueAmount(), is(Values.Amount.factorize(130))); - assertThat(sell.getAccountTransaction().getGrossValue(), is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(130)))); - assertTrue(sell.getAccountTransaction().toString().contains("SELL")); - assertThat(sell.getPortfolioTransaction().getGrossValueAmount(), is(Values.Amount.factorize(130))); - assertThat(sell.getPortfolioTransaction().getGrossValue(), is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(130)))); - assertThat(sell.getPortfolioTransaction().getGrossPricePerShare().getCurrencyCode(), is(CurrencyUnit.EUR)); - assertTrue(sell.getPortfolioTransaction().toString().contains("SELL")); - } - - /** - * Verifies that same-shape buy/sell edits and owner moves are applied through ledger paths. - * The account and portfolio projections must refresh without creating duplicate booking truth. - */ - @Test - public void testFacadeAppliesSameShapeEditAndMovesOwners() throws Exception - { - var fixture = fixture(); - var otherAccount = account("Other Account"); - var otherPortfolio = new Portfolio("Other Portfolio"); - otherPortfolio.setUpdatedAt(Instant.now()); - var otherSecurity = new Security("Other Security", CurrencyUnit.EUR); - otherSecurity.setUpdatedAt(Instant.now()); - fixture.client().addAccount(otherAccount); - fixture.client().addPortfolio(otherPortfolio); - fixture.client().addSecurity(otherSecurity); - var creator = new LedgerBuySellTransactionCreator(fixture.client()); - var entry = createBuy(fixture); - var accountTransaction = entry.getAccountTransaction(); - var portfolioTransaction = entry.getPortfolioTransaction(); - var cashPostingUUID = fixture.client().getLedger().getEntries().get(0).getPostings().get(0).getUUID(); - var securityPostingUUID = fixture.client().getLedger().getEntries().get(0).getPostings().get(1).getUUID(); - var entryUUID = fixture.client().getLedger().getEntries().get(0).getUUID(); - var expectedProjectionUUIDs = projectionUUIDs(fixture.client()); - var expectedProjectionRoles = projectionRoles(fixture.client()); - - var updated = creator.update(entry, fixture.portfolio(), fixture.account(), PortfolioTransaction.Type.BUY, - DATE_TIME.plusDays(1), Values.Amount.factorize(150), CurrencyUnit.EUR, otherSecurity, - Values.Share.factorize(7), List.of(unit(Unit.Type.FEE, 5)), "updated", "updated source"); - var updatedAccountTransaction = updated.getAccountTransaction(); - var updatedPortfolioTransaction = updated.getPortfolioTransaction(); - - var ledgerEntry = fixture.client().getLedger().getEntries().get(0); - - assertThat(ledgerEntry.getDateTime(), is(DATE_TIME.plusDays(1))); - assertThat(ledgerEntry.getNote(), is("updated")); - assertThat(ledgerEntry.getSource(), is("updated source")); - assertThat(ledgerEntry.getPostings().get(0).getUUID(), is(cashPostingUUID)); - assertThat(ledgerEntry.getPostings().get(0).getAmount(), is(Values.Amount.factorize(150))); - assertThat(ledgerEntry.getPostings().get(1).getUUID(), is(securityPostingUUID)); - assertSame(otherSecurity, ledgerEntry.getPostings().get(1).getSecurity()); - assertThat(ledgerEntry.getPostings().get(1).getShares(), is(Values.Share.factorize(7))); - assertThat(updatedPortfolioTransaction.getUnits().count(), is(1L)); - assertThat(updatedAccountTransaction.getAmount(), is(Values.Amount.factorize(150))); - assertThat(updatedPortfolioTransaction.getAmount(), is(Values.Amount.factorize(150))); - - var moved = creator.update(updated, otherPortfolio, otherAccount, PortfolioTransaction.Type.BUY, - DATE_TIME.plusDays(2), Values.Amount.factorize(175), CurrencyUnit.EUR, otherSecurity, - Values.Share.factorize(8), List.of(), "moved note", "moved source"); - var movedAccountTransaction = moved.getAccountTransaction(); - var movedPortfolioTransaction = moved.getPortfolioTransaction(); - - assertTrue(fixture.account().getTransactions().isEmpty()); - assertTrue(fixture.portfolio().getTransactions().isEmpty()); - assertThat(otherAccount.getTransactions(), is(List.of(movedAccountTransaction))); - assertThat(otherPortfolio.getTransactions(), is(List.of(movedPortfolioTransaction))); - assertThat(movedAccountTransaction.getUUID(), is(expectedProjectionUUIDs.get(0))); - assertThat(movedPortfolioTransaction.getUUID(), is(expectedProjectionUUIDs.get(1))); - assertThat(ledgerEntry.getUUID(), is(entryUUID)); - assertThat(ledgerEntry.getPostings().get(0).getUUID(), is(cashPostingUUID)); - assertThat(ledgerEntry.getPostings().get(1).getUUID(), is(securityPostingUUID)); - assertSame(otherAccount, ledgerEntry.getPostings().get(0).getAccount()); - assertSame(otherPortfolio, ledgerEntry.getPostings().get(1).getPortfolio()); - assertSame(otherAccount, projection(ledgerEntry, LedgerProjectionRole.ACCOUNT).getAccount()); - assertSame(otherPortfolio, projection(ledgerEntry, LedgerProjectionRole.PORTFOLIO).getPortfolio()); - assertCrossEntryReadCompatibility(movedAccountTransaction, movedPortfolioTransaction, otherAccount, - otherPortfolio); - assertThrows(UnsupportedOperationException.class, - () -> creator.update(entry, fixture.portfolio(), fixture.account(), PortfolioTransaction.Type.SELL, - DATE_TIME, Values.Amount.factorize(150), CurrencyUnit.EUR, otherSecurity, - Values.Share.factorize(7), List.of(), "note", "source")); - assertThrows(UnsupportedOperationException.class, - () -> portfolioTransaction.setType(PortfolioTransaction.Type.DELIVERY_INBOUND)); - assertThrows(UnsupportedOperationException.class, () -> portfolioTransaction.setAmount(1L)); - accountTransaction.getCrossEntry().updateFrom(accountTransaction); - assertTrue(fixture.account().getTransactions().isEmpty()); - assertThat(otherAccount.getTransactions(), is(List.of(movedAccountTransaction))); - assertThrows(UnsupportedOperationException.class, - () -> accountTransaction.getCrossEntry().setOwner(accountTransaction, otherAccount)); - assertThrows(UnsupportedOperationException.class, () -> accountTransaction.getCrossEntry().insert()); - assertThat(fixture.client().getAllTransactions().size(), is(1)); - assertThat(projectionRoles(loadXml(saveXml(fixture.client()))), is(expectedProjectionRoles)); - assertThat(projectionRoles(loadProtobuf(saveProtobuf(fixture.client()))), is(expectedProjectionRoles)); - assertValid(fixture.client()); - } - - /** - * Verifies that mutable legacy setters stay blocked on ledger-backed buy/sell wrappers. - * A failed setter attempt must leave the ledger and both owner lists unchanged. - */ - @Test - public void testReadOnlyWrapperRejectsAllMutableSettersWithoutPartialMutation() - { - var fixture = fixture(); - var entry = createBuy(fixture); - var accountTransaction = entry.getAccountTransaction(); - var portfolioTransaction = entry.getPortfolioTransaction(); - - assertThrows(UnsupportedOperationException.class, () -> entry.setPortfolio(new Portfolio("Other"))); - assertThrows(UnsupportedOperationException.class, () -> entry.setAccount(account("Other"))); - assertThrows(UnsupportedOperationException.class, () -> entry.setDate(DATE_TIME.plusDays(1))); - assertThrows(UnsupportedOperationException.class, () -> entry.setType(PortfolioTransaction.Type.SELL)); - assertThrows(UnsupportedOperationException.class, () -> entry.setSecurity(fixture.security())); - assertThrows(UnsupportedOperationException.class, () -> entry.setShares(1L)); - assertThrows(UnsupportedOperationException.class, () -> entry.setAmount(1L)); - assertThrows(UnsupportedOperationException.class, () -> entry.setCurrencyCode(CurrencyUnit.USD)); - assertThrows(UnsupportedOperationException.class, () -> entry.setMonetaryAmount(Money.of(CurrencyUnit.EUR, 1L))); - assertThrows(UnsupportedOperationException.class, () -> entry.setNote("other")); - assertThrows(UnsupportedOperationException.class, () -> entry.setSource("other")); - - assertSame(fixture.account(), entry.getAccount()); - assertSame(fixture.portfolio(), entry.getPortfolio()); - assertSame(accountTransaction, entry.getAccountTransaction()); - assertSame(portfolioTransaction, entry.getPortfolioTransaction()); - assertCrossEntryReadCompatibility(accountTransaction, portfolioTransaction, fixture.account(), - fixture.portfolio()); - assertValid(fixture.client()); - } - - /** - * Verifies that normal legacy buy/sell entries still allow their mutable setters. - * Ledger read-only protection must not change non-ledger transaction behavior. - */ - @Test - public void testLegacyBuySellEntryMutableSettersStillWork() - { - var portfolio = new Portfolio("Portfolio"); - var account = account("Account"); - var otherPortfolio = new Portfolio("Other Portfolio"); - var otherAccount = account("Other Account"); - var entry = new BuySellEntry(portfolio, account); - - entry.setPortfolio(otherPortfolio); - entry.setAccount(otherAccount); - entry.setType(PortfolioTransaction.Type.BUY); - entry.setDate(DATE_TIME); - entry.setAmount(Values.Amount.factorize(10)); - entry.setCurrencyCode(CurrencyUnit.EUR); - - assertSame(otherPortfolio, entry.getPortfolio()); - assertSame(otherAccount, entry.getAccount()); - assertThat(entry.getPortfolioTransaction().getType(), is(PortfolioTransaction.Type.BUY)); - assertThat(entry.getAccountTransaction().getType(), is(AccountTransaction.Type.BUY)); - assertThat(entry.getPortfolioTransaction().getAmount(), is(Values.Amount.factorize(10))); - assertThat(entry.getAccountTransaction().getAmount(), is(Values.Amount.factorize(10))); - } - - /** - * Verifies that XML save/load/save preserves buy/sell projection identities and fields. - * Both account and portfolio rows must rematerialize from the same ledger entry. - */ - @Test - public void testXmlSaveLoadSavePreservesBuySellProjectionUUIDsAndFields() throws Exception - { - var client = buySellClient(); - var expectedProjectionRoles = projectionRoles(client); - - var loaded = loadXml(saveXml(client)); - var reloaded = loadXml(saveXml(loaded)); - - assertThat(projectionRoles(reloaded), is(expectedProjectionRoles)); - assertThat(reloaded.getLedger().getEntries().size(), is(2)); - assertThat(reloaded.getAccounts().get(0).getTransactions().size(), is(2)); - assertThat(reloaded.getPortfolios().get(0).getTransactions().size(), is(2)); - assertThat(reloaded.getAllTransactions().size(), is(2)); - assertValid(reloaded); - } - - /** - * Verifies that protobuf save/load/save preserves buy/sell projection identities and fields. - * Both account and portfolio rows must rematerialize from the same ledger entry. - */ - @Test - public void testProtobufSaveLoadSavePreservesBuySellProjectionUUIDsAndFields() throws Exception - { - var client = buySellClient(); - var expectedProjectionRoles = projectionRoles(client); - - var loaded = loadProtobuf(saveProtobuf(client)); - var reloaded = loadProtobuf(saveProtobuf(loaded)); - - assertThat(projectionRoles(reloaded), is(expectedProjectionRoles)); - assertThat(reloaded.getLedger().getEntries().size(), is(2)); - assertThat(reloaded.getAccounts().get(0).getTransactions().size(), is(2)); - assertThat(reloaded.getPortfolios().get(0).getTransactions().size(), is(2)); - assertThat(reloaded.getAllTransactions().size(), is(2)); - assertValid(reloaded); - } - - private void assertCreatedBuySell(Fixture fixture, BuySellEntry entry, LedgerEntryType entryType, - PortfolioTransaction.Type portfolioType, AccountTransaction.Type accountType) - { - var accountTransaction = entry.getAccountTransaction(); - var portfolioTransaction = entry.getPortfolioTransaction(); - var ledgerEntry = fixture.client().getLedger().getEntries().get(0); - var cashPosting = ledgerEntry.getPostings().get(0); - var securityPosting = ledgerEntry.getPostings().get(1); - var feePosting = posting(ledgerEntry, LedgerPostingType.FEE); - var taxPosting = posting(ledgerEntry, LedgerPostingType.TAX); - var grossValuePosting = posting(ledgerEntry, LedgerPostingType.GROSS_VALUE); - var accountProjection = projection(ledgerEntry, LedgerProjectionRole.ACCOUNT); - var portfolioProjection = projection(ledgerEntry, LedgerProjectionRole.PORTFOLIO); - - assertThat(ledgerEntry.getType(), is(entryType)); - assertThat(ledgerEntry.getDateTime(), is(DATE_TIME)); - assertThat(ledgerEntry.getNote(), is("note")); - assertThat(ledgerEntry.getSource(), is("source")); - assertThat(cashPosting.getType(), is(LedgerPostingType.CASH)); - assertThat(cashPosting.getAmount(), is(Values.Amount.factorize(123))); - assertThat(cashPosting.getCurrency(), is(CurrencyUnit.EUR)); - assertSame(fixture.account(), cashPosting.getAccount()); - assertThat(securityPosting.getType(), is(LedgerPostingType.SECURITY)); - assertThat(securityPosting.getAmount(), is(Values.Amount.factorize(123))); - assertThat(securityPosting.getCurrency(), is(CurrencyUnit.EUR)); - assertSame(fixture.portfolio(), securityPosting.getPortfolio()); - assertSame(fixture.security(), securityPosting.getSecurity()); - assertThat(securityPosting.getShares(), is(Values.Share.factorize(5))); - assertThat(feePosting.getForexAmount(), is(Long.valueOf(Values.Amount.factorize(6)))); - assertThat(feePosting.getForexCurrency(), is(CurrencyUnit.USD)); - assertThat(feePosting.getExchangeRate(), is(new BigDecimal("0.5000"))); - assertThat(taxPosting.getAmount(), is(Values.Amount.factorize(4))); - assertThat(grossValuePosting.getForexAmount(), is(Long.valueOf(Values.Amount.factorize(240)))); - assertThat(grossValuePosting.getForexCurrency(), is(CurrencyUnit.USD)); - assertThat(grossValuePosting.getExchangeRate(), is(new BigDecimal("0.5000"))); - assertTrue(ledgerEntry.getPostings().stream().noneMatch(posting -> posting.getAccount() != null - && posting.getPortfolio() != null)); - - assertThat(accountProjection.getRole(), is(LedgerProjectionRole.ACCOUNT)); - assertSame(fixture.account(), accountProjection.getAccount()); - assertPrimaryDescriptor(accountProjection, cashPosting.getUUID()); - assertThat(portfolioProjection.getRole(), is(LedgerProjectionRole.PORTFOLIO)); - assertSame(fixture.portfolio(), portfolioProjection.getPortfolio()); - assertPrimaryDescriptor(portfolioProjection, securityPosting.getUUID()); - assertUnitPostings(accountProjection); - assertUnitPostings(portfolioProjection); - assertThat(accountTransaction.getUUID(), is(accountProjection.getRuntimeProjectionId())); - assertThat(portfolioTransaction.getUUID(), is(portfolioProjection.getRuntimeProjectionId())); - assertThat(accountTransaction, instanceOf(LedgerBackedTransaction.class)); - assertThat(portfolioTransaction, instanceOf(LedgerBackedTransaction.class)); - assertThat(accountTransaction.getType(), is(accountType)); - assertThat(portfolioTransaction.getType(), is(portfolioType)); - assertThat(accountTransaction.getAmount(), is(Values.Amount.factorize(123))); - assertThat(portfolioTransaction.getAmount(), is(Values.Amount.factorize(123))); - assertSame(fixture.security(), portfolioTransaction.getSecurity()); - assertThat(portfolioTransaction.getShares(), is(Values.Share.factorize(5))); - assertThat(portfolioTransaction.getUnits().count(), is(3L)); - assertThat(fixture.account().getTransactions(), is(List.of(accountTransaction))); - assertThat(fixture.portfolio().getTransactions(), is(List.of(portfolioTransaction))); - assertThat(fixture.client().getAllTransactions().size(), is(1)); - assertSame(portfolioTransaction, fixture.client().getAllTransactions().get(0).getTransaction()); - assertCrossEntryReadCompatibility(accountTransaction, portfolioTransaction, fixture.account(), - fixture.portfolio()); - } - - private void assertCrossEntryReadCompatibility(AccountTransaction accountTransaction, - PortfolioTransaction portfolioTransaction, Account account, Portfolio portfolio) - { - assertThat(accountTransaction.getCrossEntry(), instanceOf(BuySellEntry.class)); - assertThat(portfolioTransaction.getCrossEntry(), instanceOf(BuySellEntry.class)); - assertSame(portfolioTransaction, accountTransaction.getCrossEntry().getCrossTransaction(accountTransaction)); - assertSame(accountTransaction, portfolioTransaction.getCrossEntry().getCrossTransaction(portfolioTransaction)); - assertSame(portfolio, accountTransaction.getCrossEntry().getCrossOwner(accountTransaction)); - assertSame(account, portfolioTransaction.getCrossEntry().getCrossOwner(portfolioTransaction)); - } - - private void assertPrimaryDescriptor(DerivedProjectionDescriptor projection, String postingUUID) - { - assertThat(projection.getPrimaryPosting().getUUID(), is(postingUUID)); - assertThat(projection.getPrimaryPosting().getUnitRole(), is(LedgerPostingUnitRole.PRIMARY)); - } - - private void assertUnitPostings(DerivedProjectionDescriptor projection) - { - assertThat((int) projection.getUnitPostings().stream() - .filter(posting -> posting.getUnitRole() == LedgerPostingUnitRole.FEE).count(), is(1)); - assertThat((int) projection.getUnitPostings().stream() - .filter(posting -> posting.getUnitRole() == LedgerPostingUnitRole.TAX).count(), is(1)); - assertThat((int) projection.getUnitPostings().stream() - .filter(posting -> posting.getUnitRole() == LedgerPostingUnitRole.GROSS_VALUE).count(), is(1)); - } - - private BuySellEntry createBuy(Fixture fixture) - { - return create(fixture, PortfolioTransaction.Type.BUY); - } - - private BuySellEntry createSell(Fixture fixture) - { - return create(fixture, PortfolioTransaction.Type.SELL); - } - - private BuySellEntry create(Fixture fixture, PortfolioTransaction.Type type) - { - return new LedgerBuySellTransactionCreator(fixture.client()).create(fixture.portfolio(), fixture.account(), - type, DATE_TIME, Values.Amount.factorize(123), CurrencyUnit.EUR, fixture.security(), - Values.Share.factorize(5), units(), "note", "source"); - } - - private List units() - { - return List.of( - new Unit(Unit.Type.FEE, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(3)), - Money.of(CurrencyUnit.USD, Values.Amount.factorize(6)), - new BigDecimal("0.5000")), - unit(Unit.Type.TAX, 4), - new Unit(Unit.Type.GROSS_VALUE, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(120)), - Money.of(CurrencyUnit.USD, Values.Amount.factorize(240)), - new BigDecimal("0.5000"))); - } - - private Unit unit(Unit.Type type, int amount) - { - return new Unit(type, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(amount))); - } - - private Client buySellClient() - { - var fixture = fixture(); - - createBuy(fixture); - createSell(fixture); - - return fixture.client(); - } - - private DerivedProjectionDescriptor projection(name.abuchen.portfolio.model.ledger.LedgerEntry entry, - LedgerProjectionRole role) - { - return name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream().filter(projection -> projection.getRole() == role).findFirst() - .orElseThrow(); - } - - private LedgerPosting posting(name.abuchen.portfolio.model.ledger.LedgerEntry entry, LedgerPostingType type) - { - return entry.getPostings().stream().filter(posting -> posting.getType() == type).findFirst().orElseThrow(); - } - - private List projectionUUIDs(Client client) - { - return client.getLedger().getEntries().stream() - .flatMap(entry -> name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream()) - .map(descriptor -> descriptor.getRuntimeProjectionId()) - .toList(); - } - - private List projectionRoles(Client client) - { - return client.getLedger().getEntries().stream() - .flatMap(entry -> name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream()) - .map(descriptor -> descriptor.getRole()) - .toList(); - } - - private String saveXml(Client client) throws IOException - { - var file = File.createTempFile("ledger-buy-sell", ".xml"); - try - { - ClientFactory.save(client, file); - return Files.readString(file.toPath(), StandardCharsets.UTF_8); - } - finally - { - Files.deleteIfExists(file.toPath()); - } - } - - private Client loadXml(String xml) throws IOException - { - return ClientFactory.load(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); - } - - private byte[] saveProtobuf(Client client) throws IOException - { - return ProtobufTestUtilities.save(client); - } - - private Client loadProtobuf(byte[] bytes) throws IOException - { - return ProtobufTestUtilities.load(bytes); - } - - private void assertValid(Client client) - { - var result = LedgerStructuralValidator.validate(client.getLedger()); - - assertThat(result.getIssues().toString(), result.isOK(), is(true)); - } - - private Fixture fixture() - { - var client = new Client(); - var account = account("Account"); - var portfolio = new Portfolio("Portfolio"); - var security = new Security("Security", CurrencyUnit.EUR); - account.setUpdatedAt(Instant.now()); - portfolio.setUpdatedAt(Instant.now()); - security.setUpdatedAt(Instant.now()); - - client.addAccount(account); - client.addPortfolio(portfolio); - client.addSecurity(security); - - return new Fixture(client, account, portfolio, security); - } - - private Account account(String name) - { - var account = new Account(name); - - account.setCurrencyCode(CurrencyUnit.EUR); - - return account; - } - - private record Fixture(Client client, Account account, Portfolio portfolio, Security security) - { - } -} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryDirectionConverterTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryDirectionConverterTest.java deleted file mode 100644 index 46c74e1ef2..0000000000 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryDirectionConverterTest.java +++ /dev/null @@ -1,416 +0,0 @@ -package name.abuchen.portfolio.model.ledger.compatibility; - -import static org.hamcrest.CoreMatchers.instanceOf; -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.CoreMatchers.nullValue; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertThrows; - -import java.io.ByteArrayInputStream; -import java.io.File; -import java.io.IOException; -import java.math.BigDecimal; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.time.Instant; -import java.time.LocalDateTime; -import java.util.List; - -import org.junit.Test; - -import name.abuchen.portfolio.model.Account; -import name.abuchen.portfolio.model.Client; -import name.abuchen.portfolio.model.ClientFactory; -import name.abuchen.portfolio.model.InvestmentPlan; -import name.abuchen.portfolio.model.Portfolio; -import name.abuchen.portfolio.model.PortfolioTransaction; -import name.abuchen.portfolio.model.ProtobufTestUtilities; -import name.abuchen.portfolio.model.Security; -import name.abuchen.portfolio.model.Transaction; -import name.abuchen.portfolio.model.Transaction.Unit; -import name.abuchen.portfolio.model.TransactionPair; -import name.abuchen.portfolio.model.ledger.LedgerEntry; -import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; -import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; -import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; -import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; -import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; -import name.abuchen.portfolio.money.CurrencyUnit; -import name.abuchen.portfolio.money.Money; -import name.abuchen.portfolio.money.Values; - -/** - * Tests ledger-aware reversal between inbound and outbound deliveries. - * These tests make sure a delivery changes direction without creating duplicate transaction truth. - */ -@SuppressWarnings("nls") -public class LedgerDeliveryDirectionConverterTest -{ - private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 6, 8, 9, 10); - private static final BigDecimal EXCHANGE_RATE = new BigDecimal("0.5000"); - - /** - * Verifies that an inbound delivery can be reversed into an outbound delivery. - * The same ledger entry and projection must continue to represent the booking after save/load. - */ - @Test - public void testReversesLedgerBackedInboundDeliveryToOutboundPreservingIdentityAndTruth() throws Exception - { - assertReversesDelivery(PortfolioTransaction.Type.DELIVERY_INBOUND, LedgerEntryType.DELIVERY_OUTBOUND, - PortfolioTransaction.Type.DELIVERY_OUTBOUND, LedgerProjectionRole.DELIVERY_INBOUND, - LedgerProjectionRole.DELIVERY_OUTBOUND, Values.Amount.factorize(113)); - } - - /** - * Verifies that an outbound delivery can be reversed into an inbound delivery. - * The same ledger entry and projection must continue to represent the booking after save/load. - */ - @Test - public void testReversesLedgerBackedOutboundDeliveryToInboundPreservingIdentityAndTruth() throws Exception - { - assertReversesDelivery(PortfolioTransaction.Type.DELIVERY_OUTBOUND, LedgerEntryType.DELIVERY_INBOUND, - PortfolioTransaction.Type.DELIVERY_INBOUND, LedgerProjectionRole.DELIVERY_OUTBOUND, - LedgerProjectionRole.DELIVERY_INBOUND, Values.Amount.factorize(127)); - } - - /** - * Verifies that delivery reversal rejects a missing portfolio projection before mutation. - * The converter must not rebuild the runtime view as a second booking truth. - */ - @Test - public void testMalformedDeliveryMissingProjectionRejectsBeforeMutation() - { - var fixture = fixture(); - var delivery = create(fixture, PortfolioTransaction.Type.DELIVERY_INBOUND); - var entry = fixture.client().getLedger().getEntries().get(0); - - projection(entry, LedgerProjectionRole.DELIVERY_INBOUND).getPrimaryPosting().setSemanticRole(null); - - assertRejectsWithoutMutation(fixture, () -> converter(fixture).reverse(pair(fixture, delivery)), - IllegalArgumentException.class); - } - - /** - * Verifies that delivery reversal rejects a missing security posting before mutation. - * Missing security and share facts must not be inferred from the projection. - */ - @Test - public void testMalformedDeliveryMissingSecurityPostingRejectsBeforeMutation() - { - var fixture = fixture(); - var delivery = create(fixture, PortfolioTransaction.Type.DELIVERY_INBOUND); - var entry = fixture.client().getLedger().getEntries().get(0); - - entry.removePosting(posting(entry, LedgerPostingType.SECURITY)); - - assertRejectsWithoutMutation(fixture, () -> converter(fixture).reverse(pair(fixture, delivery)), - IllegalArgumentException.class); - } - - /** - * Verifies that delivery reversal does not reinterpret unsupported forex posting facts. - * The converter must reject before mutation when those facts cannot be preserved safely. - */ - @Test - public void testUnsupportedDeliveryPostingForexRejectsBeforeMutation() - { - var fixture = fixture(); - var delivery = create(fixture, PortfolioTransaction.Type.DELIVERY_INBOUND, - Money.of(CurrencyUnit.USD, Values.Amount.factorize(246)), EXCHANGE_RATE); - - assertRejectsWithoutMutation(fixture, () -> converter(fixture).reverse(pair(fixture, delivery)), - UnsupportedOperationException.class); - } - - /** - * Verifies that a plan-generated delivery can be reversed safely. - * The plan reference must still resolve to the same generated booking after save/load. - */ - @Test - public void testInvestmentPlanReferencedDeliveryReversesAndUpdatesPlanReference() throws Exception - { - var fixture = fixture(); - var delivery = create(fixture, PortfolioTransaction.Type.DELIVERY_INBOUND); - var entry = fixture.client().getLedger().getEntries().get(0); - var plan = new InvestmentPlan("Plan"); - - entry.setGeneratedByPlanKey(plan.getPlanKey()); - entry.setPlanExecutionDate(DATE_TIME.toLocalDate()); - entry.setPreferredViewKind(InvestmentPlan.LedgerExecutionViewKind.PORTFOLIO.name()); - fixture.client().addPlan(plan); - - var reversed = converter(fixture).reverse(pair(fixture, delivery)); - - assertThat(plan.getLedgerExecutionRefs(), is(List.of())); - assertThat(entry.getGeneratedByPlanKey(), is(plan.getPlanKey())); - assertThat(entry.getPlanExecutionDate(), is(DATE_TIME.toLocalDate())); - assertThat(entry.getPreferredViewKind(), is(InvestmentPlan.LedgerExecutionViewKind.PORTFOLIO.name())); - assertSame(reversed, plan.getTransactions(fixture.client()).get(0).getTransaction()); - - var loaded = loadXml(saveXml(fixture.client())); - assertThat(((PortfolioTransaction) loaded.getPlans().get(0).getTransactions(loaded).get(0).getTransaction()) - .getType(), - is(PortfolioTransaction.Type.DELIVERY_OUTBOUND)); - } - - private void assertReversesDelivery(PortfolioTransaction.Type sourceType, LedgerEntryType targetEntryType, - PortfolioTransaction.Type targetTransactionType, LedgerProjectionRole sourceProjectionRole, - LedgerProjectionRole targetProjectionRole, long expectedAmount) throws Exception - { - var fixture = fixture(); - var delivery = create(fixture, sourceType); - var entry = fixture.client().getLedger().getEntries().get(0); - var entryUUID = entry.getUUID(); - var securityPostingUUID = posting(entry, LedgerPostingType.SECURITY).getUUID(); - var unitSnapshots = unitSnapshots(entry); - - var reversed = converter(fixture).reverse(pair(fixture, delivery)); - var targetProjectionUUID = projection(entry, targetProjectionRole).getRuntimeProjectionId(); - - assertThat(entry.getUUID(), is(entryUUID)); - assertThat(entry.getType(), is(targetEntryType)); - assertThat(posting(entry, LedgerPostingType.SECURITY).getUUID(), is(securityPostingUUID)); - assertThat(posting(entry, LedgerPostingType.SECURITY).getAmount(), is(expectedAmount)); - assertThat(posting(entry, LedgerPostingType.SECURITY).getCurrency(), is(CurrencyUnit.EUR)); - assertThat(unitSnapshots(entry), is(unitSnapshots)); - assertSame(fixture.portfolio(), projection(entry, targetProjectionRole).getPortfolio()); - - assertThat(fixture.portfolio().getTransactions(), is(List.of(reversed))); - assertThat(fixture.client().getAllTransactions().size(), is(1)); - assertSame(reversed, fixture.client().getAllTransactions().get(0).getTransaction()); - assertThat(reversed.getUUID(), is(targetProjectionUUID)); - assertThat(reversed, instanceOf(LedgerBackedTransaction.class)); - assertThat(reversed.getType(), is(targetTransactionType)); - assertThat(reversed.getCrossEntry(), is(nullValue())); - assertThat(reversed.getDateTime(), is(DATE_TIME)); - assertThat(reversed.getNote(), is("note")); - assertThat(reversed.getSource(), is("source")); - assertSame(fixture.security(), reversed.getSecurity()); - assertThat(reversed.getShares(), is(Values.Share.factorize(5))); - assertThat(reversed.getAmount(), is(expectedAmount)); - assertThat(reversed.getCurrencyCode(), is(CurrencyUnit.EUR)); - assertThat(reversed.getGrossValue(), is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(120)))); - assertThat(reversed.getUnit(Unit.Type.GROSS_VALUE).orElseThrow().getAmount(), - is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(120)))); - assertThat(reversed.getUnitSum(Unit.Type.FEE), is(Money.of(CurrencyUnit.EUR, - Values.Amount.factorize(3)))); - assertThat(reversed.getUnitSum(Unit.Type.TAX), is(Money.of(CurrencyUnit.EUR, - Values.Amount.factorize(4)))); - assertValid(fixture.client()); - - assertRoundtrip(loadXml(saveXml(fixture.client())), entryUUID, securityPostingUUID, targetProjectionUUID, - targetEntryType, targetTransactionType, targetProjectionRole, expectedAmount); - assertRoundtrip(loadProtobuf(saveProtobuf(fixture.client())), entryUUID, securityPostingUUID, targetProjectionUUID, - targetEntryType, targetTransactionType, targetProjectionRole, expectedAmount); - } - - private void assertRoundtrip(Client client, String entryUUID, String securityPostingUUID, String projectionUUID, - LedgerEntryType entryType, PortfolioTransaction.Type transactionType, - LedgerProjectionRole projectionRole, long expectedAmount) - { - assertThat(client.getAccounts().get(0).getTransactions().isEmpty(), is(true)); - assertThat(client.getPortfolios().get(0).getTransactions().size(), is(1)); - assertThat(client.getAllTransactions().size(), is(1)); - - var entry = client.getLedger().getEntries().get(0); - var transaction = client.getPortfolios().get(0).getTransactions().get(0); - - assertThat(entry.getType(), is(entryType)); - assertThat(posting(entry, LedgerPostingType.SECURITY).getAmount(), is(expectedAmount)); - assertThat(projection(entry, projectionRole).getRole(), is(projectionRole)); - assertThat(((LedgerBackedTransaction) transaction).getLedgerProjectionRole(), is(projectionRole)); - assertThat(transaction.getType(), is(transactionType)); - assertThat(transaction.getCrossEntry(), is(nullValue())); - assertThat(transaction.getAmount(), is(expectedAmount)); - assertThat(transaction.getGrossValue(), is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(120)))); - assertValid(client); - } - - private void assertRejectsWithoutMutation(Fixture fixture, ThrowingRunnable runnable, - Class expectedType) - { - var snapshot = Snapshot.capture(fixture.client()); - - assertThrows(expectedType, runnable::run); - assertThat(Snapshot.capture(fixture.client()), is(snapshot)); - } - - private PortfolioTransaction create(Fixture fixture, PortfolioTransaction.Type type) - { - return create(fixture, type, null, null); - } - - private PortfolioTransaction create(Fixture fixture, PortfolioTransaction.Type type, Money forexAmount, - BigDecimal exchangeRate) - { - return new LedgerDeliveryTransactionCreator(fixture.client()).create(fixture.portfolio(), type, DATE_TIME, - Values.Amount.factorize(123), CurrencyUnit.EUR, fixture.security(), Values.Share.factorize(5), - forexAmount, exchangeRate, units(), "note", "source"); - } - - private List units() - { - return List.of( - new Unit(Unit.Type.FEE, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(3)), - Money.of(CurrencyUnit.USD, Values.Amount.factorize(6)), EXCHANGE_RATE), - new Unit(Unit.Type.TAX, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(4))), - new Unit(Unit.Type.GROSS_VALUE, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(120)), - Money.of(CurrencyUnit.USD, Values.Amount.factorize(240)), EXCHANGE_RATE)); - } - - private LedgerDeliveryDirectionConverter converter(Fixture fixture) - { - return new LedgerDeliveryDirectionConverter(fixture.client()); - } - - private TransactionPair pair(Fixture fixture, PortfolioTransaction transaction) - { - return new TransactionPair<>(fixture.portfolio(), transaction); - } - - private name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor projection(LedgerEntry entry, LedgerProjectionRole role) - { - return name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream().filter(projection -> projection.getRole() == role).findFirst() - .orElseThrow(); - } - - private LedgerPosting posting(LedgerEntry entry, LedgerPostingType type) - { - return entry.getPostings().stream().filter(posting -> posting.getType() == type).findFirst().orElseThrow(); - } - - private List unitSnapshots(LedgerEntry entry) - { - return entry.getPostings().stream().filter(posting -> posting.getType() == LedgerPostingType.FEE - || posting.getType() == LedgerPostingType.TAX - || posting.getType() == LedgerPostingType.GROSS_VALUE).map(PostingSnapshot::capture).toList(); - } - - private String saveXml(Client client) throws IOException - { - var file = File.createTempFile("ledger-delivery-direction-converter", ".xml"); - try - { - ClientFactory.save(client, file); - return Files.readString(file.toPath(), StandardCharsets.UTF_8); - } - finally - { - Files.deleteIfExists(file.toPath()); - } - } - - private Client loadXml(String xml) throws IOException - { - return ClientFactory.load(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); - } - - private byte[] saveProtobuf(Client client) throws IOException - { - return ProtobufTestUtilities.save(client); - } - - private Client loadProtobuf(byte[] bytes) throws IOException - { - return ProtobufTestUtilities.load(bytes); - } - - private void assertValid(Client client) - { - var result = LedgerStructuralValidator.validate(client.getLedger()); - - assertThat(result.format(), result.isOK(), is(true)); - } - - private Fixture fixture() - { - var client = new Client(); - var account = new Account("Account"); - var portfolio = new Portfolio("Portfolio"); - var security = new Security("Security", CurrencyUnit.EUR); - - account.setCurrencyCode(CurrencyUnit.EUR); - portfolio.setReferenceAccount(account); - account.setUpdatedAt(Instant.now()); - portfolio.setUpdatedAt(Instant.now()); - security.setUpdatedAt(Instant.now()); - client.addAccount(account); - client.addPortfolio(portfolio); - client.addSecurity(security); - - return new Fixture(client, account, portfolio, security); - } - - @FunctionalInterface - private interface ThrowingRunnable - { - void run() throws Exception; - } - - private record Fixture(Client client, Account account, Portfolio portfolio, Security security) - { - } - - private record Snapshot(List entries, List accountTransactions, - List portfolioTransactions, List allTransactions) - { - static Snapshot capture(Client client) - { - return new Snapshot(client.getLedger().getEntries().stream().map(EntrySnapshot::capture).toList(), - client.getAccounts().stream().flatMap(account -> account.getTransactions().stream()) - .map(Transaction::getUUID).toList(), - client.getPortfolios().stream().flatMap(portfolio -> portfolio.getTransactions().stream()) - .map(Transaction::getUUID).toList(), - client.getAllTransactions().stream().map(pair -> pair.getTransaction().getUUID()) - .toList()); - } - } - - private record EntrySnapshot(String uuid, LedgerEntryType type, List postings, - List projections) - { - static EntrySnapshot capture(LedgerEntry entry) - { - List projections; - try - { - projections = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry) - .stream().map(ProjectionSnapshot::capture).toList(); - } - catch (IllegalArgumentException e) - { - projections = List.of(); - } - - return new EntrySnapshot(entry.getUUID(), entry.getType(), - entry.getPostings().stream().map(PostingSnapshot::capture).toList(), - projections); - } - } - - private record PostingSnapshot(String uuid, LedgerPostingType type, Long amount, String currency, Long forexAmount, - String forexCurrency, BigDecimal exchangeRate, Security security, Long shares, Account account, - Portfolio portfolio) - { - static PostingSnapshot capture(LedgerPosting posting) - { - return new PostingSnapshot(posting.getUUID(), posting.getType(), posting.getAmount(), posting.getCurrency(), - posting.getForexAmount(), posting.getForexCurrency(), posting.getExchangeRate(), - posting.getSecurity(), posting.getShares(), posting.getAccount(), posting.getPortfolio()); - } - } - - private record ProjectionSnapshot(String uuid, LedgerProjectionRole role, Account account, Portfolio portfolio, - String primaryPostingId, String groupKey) - { - static ProjectionSnapshot capture(name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor projection) - { - return new ProjectionSnapshot(projection.getRuntimeProjectionId(), projection.getRole(), projection.getAccount(), - projection.getPortfolio(), projection.getPrimaryPosting().getUUID(), - projection.getPrimaryPosting().getGroupKey()); - } - } -} - diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryTransactionCreatorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryTransactionCreatorTest.java deleted file mode 100644 index 41ddbdf695..0000000000 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryTransactionCreatorTest.java +++ /dev/null @@ -1,424 +0,0 @@ -package name.abuchen.portfolio.model.ledger.compatibility; - -import static org.hamcrest.CoreMatchers.instanceOf; -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; - -import java.io.ByteArrayInputStream; -import java.io.File; -import java.io.IOException; -import java.math.BigDecimal; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.time.Instant; -import java.time.LocalDateTime; -import java.util.List; - -import org.junit.Test; - -import name.abuchen.portfolio.model.Account; -import name.abuchen.portfolio.model.Client; -import name.abuchen.portfolio.model.ClientFactory; -import name.abuchen.portfolio.model.Portfolio; -import name.abuchen.portfolio.model.PortfolioTransaction; -import name.abuchen.portfolio.model.ProtobufTestUtilities; -import name.abuchen.portfolio.model.Security; -import name.abuchen.portfolio.model.Transaction.Unit; -import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; -import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; -import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; -import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; -import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; -import name.abuchen.portfolio.money.CurrencyUnit; -import name.abuchen.portfolio.money.Money; -import name.abuchen.portfolio.money.Values; - -/** - * Tests ledger-aware creation and editing of transactions in this family. - * These tests make sure user-visible rows are rebuilt from ledger truth and structural facts are not written through legacy projections. - */ -@SuppressWarnings("nls") -public class LedgerDeliveryTransactionCreatorTest -{ - private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 6, 7, 8, 9); - private static final BigDecimal EXCHANGE_RATE = BigDecimal.valueOf(2); - - /** - * Verifies that inbound and outbound deliveries are created directly in the ledger. - * The portfolio row must be a projection of the persisted delivery booking. - */ - @Test - public void testCreatesLedgerDeliveryDirectlyForInboundAndOutbound() - { - assertCreatesDelivery(PortfolioTransaction.Type.DELIVERY_INBOUND, LedgerEntryType.DELIVERY_INBOUND, - LedgerProjectionRole.DELIVERY_INBOUND); - assertCreatesDelivery(PortfolioTransaction.Type.DELIVERY_OUTBOUND, LedgerEntryType.DELIVERY_OUTBOUND, - LedgerProjectionRole.DELIVERY_OUTBOUND); - } - - /** - * Verifies that metadata setters remain allowed on delivery projections. - * Structural setters must stay blocked so runtime projections do not become a second truth. - */ - @Test - public void testCreatedDeliveryAllowsMetadataSetterAndRejectsStructuralSetters() - { - var fixture = fixture(); - var transaction = createDelivery(fixture.client(), fixture.portfolio(), fixture.security(), - PortfolioTransaction.Type.DELIVERY_INBOUND); - var updatedDateTime = DATE_TIME.plusDays(1); - - transaction.setDateTime(updatedDateTime); - transaction.setNote("updated note"); - transaction.setSource("updated source"); - - var entry = fixture.client().getLedger().getEntries().get(0); - - assertThat(entry.getDateTime(), is(updatedDateTime)); - assertThat(entry.getNote(), is("updated note")); - assertThat(entry.getSource(), is("updated source")); - assertThrows(UnsupportedOperationException.class, () -> transaction.setAmount(1L)); - assertThrows(UnsupportedOperationException.class, () -> transaction.setCurrencyCode(CurrencyUnit.USD)); - assertThrows(UnsupportedOperationException.class, () -> transaction.setSecurity(fixture.security())); - assertThrows(UnsupportedOperationException.class, () -> transaction.setShares(1L)); - assertThrows(UnsupportedOperationException.class, transaction::clearUnits); - assertThrows(UnsupportedOperationException.class, - () -> transaction.setType(PortfolioTransaction.Type.DELIVERY_OUTBOUND)); - assertValid(fixture.client()); - } - - /** - * Verifies that ledger-backed delivery projections expose gross-value read methods. - * UI code must be able to read unit-derived values without mutating projections. - */ - @Test - public void testLedgerBackedDeliverySupportsGrossValueReadMethods() - { - var fixture = fixture(); - var inbound = createDelivery(fixture.client(), fixture.portfolio(), fixture.security(), - PortfolioTransaction.Type.DELIVERY_INBOUND); - var outbound = createDelivery(fixture.client(), fixture.portfolio(), fixture.security(), - PortfolioTransaction.Type.DELIVERY_OUTBOUND); - - assertThat(inbound.getGrossValueAmount(), is(Values.Amount.factorize(80))); - assertThat(inbound.getGrossValue(), is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(80)))); - assertThat(inbound.getGrossPricePerShare().getCurrencyCode(), is(CurrencyUnit.EUR)); - assertThat(inbound.getUnits().count(), is(3L)); - assertTrue(inbound.toString().contains("DELIVERY_INBOUND")); - - assertThat(outbound.getGrossValueAmount(), is(Values.Amount.factorize(200))); - assertThat(outbound.getGrossValue(), is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(200)))); - assertThat(outbound.getGrossPricePerShare().getCurrencyCode(), is(CurrencyUnit.EUR)); - assertThat(outbound.getUnits().count(), is(3L)); - assertTrue(outbound.toString().contains("DELIVERY_OUTBOUND")); - } - - /** - * Verifies that same-shape delivery edits and owner moves are applied through ledger paths. - * The projected delivery must refresh without creating duplicate booking truth. - */ - @Test - public void testFacadeAppliesDeliverySameShapeEditAndMovesOwner() throws Exception - { - var fixture = fixture(); - var otherPortfolio = portfolio(fixture.account()); - otherPortfolio.setUpdatedAt(Instant.now()); - fixture.client().addPortfolio(otherPortfolio); - var otherSecurity = new Security("Other Security", CurrencyUnit.USD); - otherSecurity.setUpdatedAt(Instant.now()); - fixture.client().addSecurity(otherSecurity); - var creator = new LedgerDeliveryTransactionCreator(fixture.client()); - var transaction = createDelivery(fixture.client(), fixture.portfolio(), fixture.security(), - PortfolioTransaction.Type.DELIVERY_INBOUND); - var projectionUUID = transaction.getUUID(); - var expectedProjectionRoles = projectionRoles(fixture.client()); - var entry = fixture.client().getLedger().getEntries().get(0); - var entryUUID = entry.getUUID(); - var postingUUID = entry.getPostings().get(0).getUUID(); - var updatedUnits = List.of(new Unit(Unit.Type.FEE, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(3)), - Money.of(CurrencyUnit.USD, Values.Amount.factorize(2)), BigDecimal.valueOf(1.5))); - - creator.update(transaction, fixture.portfolio(), PortfolioTransaction.Type.DELIVERY_INBOUND, - DATE_TIME.plusDays(2), Values.Amount.factorize(150), CurrencyUnit.EUR, otherSecurity, - Values.Share.factorize(20), Money.of(CurrencyUnit.USD, Values.Amount.factorize(75)), - EXCHANGE_RATE, updatedUnits, "updated note", "updated source"); - - var posting = entry.getPostings().get(0); - - assertThat(entry.getDateTime(), is(DATE_TIME.plusDays(2))); - assertThat(entry.getNote(), is("updated note")); - assertThat(entry.getSource(), is("updated source")); - assertThat(posting.getAmount(), is(Values.Amount.factorize(150))); - assertThat(posting.getCurrency(), is(CurrencyUnit.EUR)); - assertThat(posting.getForexAmount(), is(Values.Amount.factorize(75))); - assertThat(posting.getForexCurrency(), is(CurrencyUnit.USD)); - assertThat(posting.getExchangeRate(), is(EXCHANGE_RATE)); - assertSame(otherSecurity, posting.getSecurity()); - assertThat(posting.getShares(), is(Values.Share.factorize(20))); - assertTrue(entry.getPostings().stream().anyMatch(p -> p.getType() == LedgerPostingType.FEE - && p.getAmount() == Values.Amount.factorize(3) - && p.getForexAmount() == Values.Amount.factorize(2) - && CurrencyUnit.USD.equals(p.getForexCurrency()) - && BigDecimal.valueOf(1.5).equals(p.getExchangeRate()))); - var moved = creator.update(transaction, otherPortfolio, PortfolioTransaction.Type.DELIVERY_INBOUND, - DATE_TIME.plusDays(3), Values.Amount.factorize(151), CurrencyUnit.EUR, otherSecurity, - Values.Share.factorize(21), null, null, List.of(), "moved note", "moved source"); - - assertTrue(fixture.portfolio().getTransactions().isEmpty()); - assertThat(otherPortfolio.getTransactions(), is(List.of(moved))); - assertThat(moved.getUUID(), is(projectionUUID)); - assertThat(entry.getUUID(), is(entryUUID)); - assertThat(entry.getPostings().get(0).getUUID(), is(postingUUID)); - assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0) - .getRuntimeProjectionId(), is(projectionUUID)); - assertSame(otherPortfolio, entry.getPostings().get(0).getPortfolio()); - assertSame(otherPortfolio, name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0).getPortfolio()); - assertThat(moved.getAmount(), is(Values.Amount.factorize(151))); - assertThat(moved.getShares(), is(Values.Share.factorize(21))); - assertThat(moved.getNote(), is("moved note")); - assertThrows(UnsupportedOperationException.class, - () -> creator.update(transaction, fixture.portfolio(), - PortfolioTransaction.Type.DELIVERY_OUTBOUND, DATE_TIME, - Values.Amount.factorize(150), CurrencyUnit.EUR, otherSecurity, - Values.Share.factorize(20), null, null, List.of(), "note", "source")); - assertThrows(UnsupportedOperationException.class, - () -> creator.update(transaction, fixture.portfolio(), PortfolioTransaction.Type.BUY, - DATE_TIME, Values.Amount.factorize(150), CurrencyUnit.EUR, otherSecurity, - Values.Share.factorize(20), null, null, List.of(), "note", "source")); - assertThat(fixture.client().getAllTransactions().size(), is(1)); - assertThat(projectionRoles(loadXml(saveXml(fixture.client()))), is(expectedProjectionRoles)); - assertThat(projectionRoles(loadProtobuf(saveProtobuf(fixture.client()))), is(expectedProjectionRoles)); - assertValid(fixture.client()); - } - - /** - * Verifies that XML save/load/save preserves delivery projection identity and fields. - * The delivery row must rematerialize from the same ledger entry. - */ - @Test - public void testXmlSaveLoadSavePreservesDeliveryProjectionUUIDAndFields() throws Exception - { - var client = deliveryClient(); - var expectedProjectionRoles = projectionRoles(client); - - var loaded = loadXml(saveXml(client)); - var reloaded = loadXml(saveXml(loaded)); - - assertThat(projectionRoles(reloaded), is(expectedProjectionRoles)); - assertThat(reloaded.getLedger().getEntries().size(), is(2)); - assertThat(reloaded.getPortfolios().get(0).getTransactions().size(), is(2)); - assertTrue(reloaded.getPortfolios().get(0).getTransactions().stream() - .allMatch(LedgerBackedTransaction.class::isInstance)); - assertTrue(reloaded.getPortfolios().get(0).getTransactions().stream() - .allMatch(transaction -> transaction.getUnits().count() == 3L)); - assertThat(reloaded.getAllTransactions().size(), is(2)); - assertValid(reloaded); - } - - /** - * Verifies that protobuf save/load/save preserves delivery projection identity and fields. - * The delivery row must rematerialize from the same ledger entry. - */ - @Test - public void testProtobufSaveLoadSavePreservesDeliveryProjectionUUIDAndFields() throws Exception - { - var client = deliveryClient(); - var expectedProjectionRoles = projectionRoles(client); - - var loaded = loadProtobuf(saveProtobuf(client)); - var reloaded = loadProtobuf(saveProtobuf(loaded)); - - assertThat(projectionRoles(reloaded), is(expectedProjectionRoles)); - assertThat(reloaded.getLedger().getEntries().size(), is(2)); - assertThat(reloaded.getPortfolios().get(0).getTransactions().size(), is(2)); - assertTrue(reloaded.getPortfolios().get(0).getTransactions().stream() - .allMatch(LedgerBackedTransaction.class::isInstance)); - assertTrue(reloaded.getPortfolios().get(0).getTransactions().stream() - .allMatch(transaction -> transaction.getUnits().count() == 3L)); - assertThat(reloaded.getAllTransactions().size(), is(2)); - assertValid(reloaded); - } - - private void assertCreatesDelivery(PortfolioTransaction.Type type, LedgerEntryType entryType, - LedgerProjectionRole projectionRole) - { - var fixture = fixture(); - var transaction = createDelivery(fixture.client(), fixture.portfolio(), fixture.security(), type); - var entry = fixture.client().getLedger().getEntries().get(0); - var posting = entry.getPostings().get(0); - var projection = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0); - - assertThat(entry.getType(), is(entryType)); - assertThat(entry.getDateTime(), is(DATE_TIME)); - assertThat(entry.getNote(), is("note")); - assertThat(entry.getSource(), is("source")); - assertThat(posting.getType(), is(LedgerPostingType.SECURITY)); - assertThat(posting.getAmount(), is(Values.Amount.factorize(140))); - assertThat(posting.getCurrency(), is(CurrencyUnit.EUR)); - assertThat(posting.getForexAmount(), is(Values.Amount.factorize(70))); - assertThat(posting.getForexCurrency(), is(CurrencyUnit.USD)); - assertThat(posting.getExchangeRate(), is(EXCHANGE_RATE)); - assertSame(fixture.portfolio(), posting.getPortfolio()); - assertSame(fixture.security(), posting.getSecurity()); - assertThat(posting.getShares(), is(Values.Share.factorize(12))); - assertTrue(entry.getPostings().stream().allMatch(p -> p.getAccount() == null)); - assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).size(), is(1)); - assertThat(projection.getRole(), is(projectionRole)); - assertSame(fixture.portfolio(), projection.getPortfolio()); - assertThat(projection.getPrimaryPosting().getUUID(), is(posting.getUUID())); - assertThat(projection.getPrimaryPosting().getUnitRole(), is(LedgerPostingUnitRole.PRIMARY)); - assertThat((int) projection.getUnitPostings().stream() - .filter(unit -> unit.getUnitRole() == LedgerPostingUnitRole.FEE).count(), is(1)); - assertThat((int) projection.getUnitPostings().stream() - .filter(unit -> unit.getUnitRole() == LedgerPostingUnitRole.TAX).count(), is(1)); - assertThat((int) projection.getUnitPostings().stream() - .filter(unit -> unit.getUnitRole() == LedgerPostingUnitRole.GROSS_VALUE).count(), is(1)); - assertThat(transaction.getUUID(), is(projection.getRuntimeProjectionId())); - assertThat(transaction, instanceOf(LedgerBackedTransaction.class)); - assertThat(transaction.getType(), is(type)); - assertThat(transaction.getDateTime(), is(DATE_TIME)); - assertThat(transaction.getAmount(), is(Values.Amount.factorize(140))); - assertThat(transaction.getCurrencyCode(), is(CurrencyUnit.EUR)); - assertSame(fixture.security(), transaction.getSecurity()); - assertThat(transaction.getShares(), is(Values.Share.factorize(12))); - assertThat(transaction.getNote(), is("note")); - assertThat(transaction.getSource(), is("source")); - assertThat(fixture.portfolio().getTransactions(), is(List.of(transaction))); - assertThat(fixture.client().getAllTransactions().size(), is(1)); - assertUnitPostings(entry.getPostings()); - assertValid(fixture.client()); - } - - private Client deliveryClient() - { - var fixture = fixture(); - - createDelivery(fixture.client(), fixture.portfolio(), fixture.security(), - PortfolioTransaction.Type.DELIVERY_INBOUND); - createDelivery(fixture.client(), fixture.portfolio(), fixture.security(), - PortfolioTransaction.Type.DELIVERY_OUTBOUND); - - return fixture.client(); - } - - private PortfolioTransaction createDelivery(Client client, Portfolio portfolio, Security security, - PortfolioTransaction.Type type) - { - return new LedgerDeliveryTransactionCreator(client).create(portfolio, type, DATE_TIME, - Values.Amount.factorize(140), CurrencyUnit.EUR, security, Values.Share.factorize(12), - Money.of(CurrencyUnit.USD, Values.Amount.factorize(70)), EXCHANGE_RATE, deliveryUnits(), - "note", "source"); - } - - private List deliveryUnits() - { - return List.of( - new Unit(Unit.Type.GROSS_VALUE, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(200)), - Money.of(CurrencyUnit.USD, Values.Amount.factorize(100)), EXCHANGE_RATE), - new Unit(Unit.Type.FEE, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(20)), - Money.of(CurrencyUnit.USD, Values.Amount.factorize(10)), EXCHANGE_RATE), - new Unit(Unit.Type.TAX, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(40)), - Money.of(CurrencyUnit.USD, Values.Amount.factorize(20)), EXCHANGE_RATE)); - } - - private void assertUnitPostings(List postings) - { - assertTrue(postings.stream().anyMatch(posting -> posting.getType() == LedgerPostingType.GROSS_VALUE - && posting.getAmount() == Values.Amount.factorize(200) - && posting.getForexAmount() == Values.Amount.factorize(100) - && CurrencyUnit.USD.equals(posting.getForexCurrency()) - && EXCHANGE_RATE.equals(posting.getExchangeRate()))); - assertTrue(postings.stream().anyMatch(posting -> posting.getType() == LedgerPostingType.FEE - && posting.getAmount() == Values.Amount.factorize(20) - && posting.getForexAmount() == Values.Amount.factorize(10) - && CurrencyUnit.USD.equals(posting.getForexCurrency()) - && EXCHANGE_RATE.equals(posting.getExchangeRate()))); - assertTrue(postings.stream().anyMatch(posting -> posting.getType() == LedgerPostingType.TAX - && posting.getAmount() == Values.Amount.factorize(40) - && posting.getForexAmount() == Values.Amount.factorize(20) - && CurrencyUnit.USD.equals(posting.getForexCurrency()) - && EXCHANGE_RATE.equals(posting.getExchangeRate()))); - } - - private List projectionRoles(Client client) - { - return client.getLedger().getEntries().stream() - .flatMap(entry -> name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream()) - .map(descriptor -> descriptor.getRole()) - .toList(); - } - - private String saveXml(Client client) throws IOException - { - var file = File.createTempFile("ledger-delivery", ".xml"); - try - { - ClientFactory.save(client, file); - return Files.readString(file.toPath(), StandardCharsets.UTF_8); - } - finally - { - Files.deleteIfExists(file.toPath()); - } - } - - private Client loadXml(String xml) throws IOException - { - return ClientFactory.load(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); - } - - private byte[] saveProtobuf(Client client) throws IOException - { - return ProtobufTestUtilities.save(client); - } - - private Client loadProtobuf(byte[] bytes) throws IOException - { - return ProtobufTestUtilities.load(bytes); - } - - private void assertValid(Client client) - { - var result = LedgerStructuralValidator.validate(client.getLedger()); - - assertThat(result.getIssues().toString(), result.isOK(), is(true)); - } - - private Fixture fixture() - { - var client = new Client(); - var account = account(); - var portfolio = portfolio(account); - var security = new Security("Security", CurrencyUnit.USD); - security.setUpdatedAt(Instant.now()); - - client.addAccount(account); - client.addPortfolio(portfolio); - client.addSecurity(security); - - return new Fixture(client, account, portfolio, security); - } - - private Account account() - { - var account = new Account("Account"); - account.setCurrencyCode(CurrencyUnit.EUR); - return account; - } - - private Portfolio portfolio(Account account) - { - var portfolio = new Portfolio("Portfolio"); - portfolio.setReferenceAccount(account); - return portfolio; - } - - private record Fixture(Client client, Account account, Portfolio portfolio, Security security) - { - } -} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDividendTransactionCreatorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDividendTransactionCreatorTest.java deleted file mode 100644 index e7850d6827..0000000000 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDividendTransactionCreatorTest.java +++ /dev/null @@ -1,394 +0,0 @@ -package name.abuchen.portfolio.model.ledger.compatibility; - -import static org.hamcrest.CoreMatchers.instanceOf; -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; - -import java.io.ByteArrayInputStream; -import java.io.File; -import java.io.IOException; -import java.math.BigDecimal; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.time.Instant; -import java.time.LocalDateTime; -import java.util.List; - -import org.junit.Test; - -import name.abuchen.portfolio.model.Account; -import name.abuchen.portfolio.model.AccountTransaction; -import name.abuchen.portfolio.model.Client; -import name.abuchen.portfolio.model.ClientFactory; -import name.abuchen.portfolio.model.ProtobufTestUtilities; -import name.abuchen.portfolio.model.Security; -import name.abuchen.portfolio.model.Transaction.Unit; -import name.abuchen.portfolio.model.ledger.LedgerParameter; -import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; -import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; -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.model.ledger.projection.LedgerBackedTransaction; -import name.abuchen.portfolio.money.CurrencyUnit; -import name.abuchen.portfolio.money.Money; -import name.abuchen.portfolio.money.Values; - -/** - * Tests ledger-aware creation and editing of transactions in this family. - * These tests make sure user-visible rows are rebuilt from ledger truth and structural facts are not written through legacy projections. - */ -@SuppressWarnings("nls") -public class LedgerDividendTransactionCreatorTest -{ - private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 6, 7, 8, 9); - private static final LocalDateTime EX_DATE = LocalDateTime.of(2026, 6, 1, 0, 0); - private static final BigDecimal EXCHANGE_RATE = BigDecimal.valueOf(2); - - /** - * Verifies that a dividend booking is created directly in the ledger. - * The account row must expose dividend facts from the persisted ledger entry. - */ - @Test - public void testCreatesLedgerDividendDirectly() - { - var fixture = fixture(); - var transaction = createDividend(fixture.client(), fixture.account(), fixture.security()); - var entry = fixture.client().getLedger().getEntries().get(0); - var cashPosting = entry.getPostings().get(0); - var projection = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0); - - assertThat(entry.getType(), is(LedgerEntryType.DIVIDENDS)); - assertThat(entry.getDateTime(), is(DATE_TIME)); - assertThat(entry.getNote(), is("note")); - assertThat(entry.getSource(), is("source")); - assertThat(cashPosting.getType(), is(LedgerPostingType.CASH)); - assertThat(cashPosting.getAmount(), is(Values.Amount.factorize(140))); - assertThat(cashPosting.getCurrency(), is(CurrencyUnit.EUR)); - assertThat(cashPosting.getForexAmount(), is(Values.Amount.factorize(70))); - assertThat(cashPosting.getForexCurrency(), is(CurrencyUnit.USD)); - assertThat(cashPosting.getExchangeRate(), is(EXCHANGE_RATE)); - assertSame(fixture.account(), cashPosting.getAccount()); - assertSame(fixture.security(), cashPosting.getSecurity()); - assertThat(cashPosting.getShares(), is(Values.Share.factorize(12))); - assertThat(exDate(cashPosting), is(EX_DATE)); - assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).size(), is(1)); - assertSame(fixture.account(), projection.getAccount()); - assertThat(projection.getPrimaryPosting().getUUID(), is(cashPosting.getUUID())); - assertThat(projection.getPrimaryPosting().getUnitRole(), is(LedgerPostingUnitRole.PRIMARY)); - assertThat((int) projection.getUnitPostings().stream() - .filter(unit -> unit.getUnitRole() == LedgerPostingUnitRole.FEE).count(), is(1)); - assertThat((int) projection.getUnitPostings().stream() - .filter(unit -> unit.getUnitRole() == LedgerPostingUnitRole.TAX).count(), is(1)); - assertThat((int) projection.getUnitPostings().stream() - .filter(unit -> unit.getUnitRole() == LedgerPostingUnitRole.GROSS_VALUE).count(), is(1)); - assertThat(transaction.getUUID(), is(projection.getRuntimeProjectionId())); - assertThat(transaction, instanceOf(LedgerBackedTransaction.class)); - assertThat(transaction.getType(), is(AccountTransaction.Type.DIVIDENDS)); - assertThat(transaction.getDateTime(), is(DATE_TIME)); - assertThat(transaction.getAmount(), is(Values.Amount.factorize(140))); - assertThat(transaction.getCurrencyCode(), is(CurrencyUnit.EUR)); - assertSame(fixture.security(), transaction.getSecurity()); - assertThat(transaction.getShares(), is(Values.Share.factorize(12))); - assertThat(transaction.getExDate(), is(EX_DATE)); - assertThat(transaction.getNote(), is("note")); - assertThat(transaction.getSource(), is("source")); - assertThat(fixture.account().getTransactions(), is(List.of(transaction))); - assertThat(fixture.client().getAllTransactions().size(), is(1)); - assertUnitPostings(entry.getPostings()); - assertValid(fixture.client()); - } - - /** - * Verifies that metadata setters remain allowed on dividend projections. - * Structural setters must stay blocked so runtime projections do not become a second truth. - */ - @Test - public void testCreatedDividendAllowsMetadataSetterAndRejectsStructuralSetters() - { - var fixture = fixture(); - var transaction = createDividend(fixture.client(), fixture.account(), fixture.security()); - var updatedDateTime = DATE_TIME.plusDays(1); - - transaction.setDateTime(updatedDateTime); - transaction.setNote("updated note"); - transaction.setSource("updated source"); - - var entry = fixture.client().getLedger().getEntries().get(0); - - assertThat(entry.getDateTime(), is(updatedDateTime)); - assertThat(entry.getNote(), is("updated note")); - assertThat(entry.getSource(), is("updated source")); - assertThrows(UnsupportedOperationException.class, () -> transaction.setAmount(1L)); - assertThrows(UnsupportedOperationException.class, () -> transaction.setCurrencyCode(CurrencyUnit.USD)); - assertThrows(UnsupportedOperationException.class, () -> transaction.setShares(1L)); - assertThrows(UnsupportedOperationException.class, () -> transaction.setExDate(EX_DATE.plusDays(1))); - assertThrows(UnsupportedOperationException.class, transaction::clearUnits); - assertValid(fixture.client()); - } - - /** - * Verifies that ledger-backed dividend projections expose gross-value read methods. - * UI code must be able to read unit-derived values without mutating projections. - */ - @Test - public void testLedgerBackedDividendSupportsGrossValueReadMethods() - { - var fixture = fixture(); - var transaction = createDividend(fixture.client(), fixture.account(), fixture.security()); - - assertThat(transaction.getGrossValueAmount(), is(Values.Amount.factorize(200))); - assertThat(transaction.getGrossValue(), is(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(200)))); - assertThat(transaction.getUnits().count(), is(3L)); - assertTrue(transaction.toString().contains("DIVIDENDS")); - } - - /** - * Verifies that same-shape dividend edits and owner moves are applied through ledger paths. - * The projected dividend must refresh without creating duplicate booking truth. - */ - @Test - public void testFacadeAppliesDividendSameShapeEditAndMovesOwner() throws Exception - { - var fixture = fixture(); - var otherAccount = account(); - fixture.client().addAccount(otherAccount); - var otherSecurity = new Security("Other Security", CurrencyUnit.USD); - otherSecurity.setUpdatedAt(Instant.now()); - fixture.client().addSecurity(otherSecurity); - var creator = new LedgerDividendTransactionCreator(fixture.client()); - var transaction = createDividend(fixture.client(), fixture.account(), fixture.security()); - var projectionUUID = transaction.getUUID(); - var expectedProjectionRoles = projectionRoles(fixture.client()); - var entry = fixture.client().getLedger().getEntries().get(0); - var entryUUID = entry.getUUID(); - var postingUUID = entry.getPostings().get(0).getUUID(); - var updatedUnits = List.of(new Unit(Unit.Type.FEE, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(3)), - Money.of(CurrencyUnit.USD, Values.Amount.factorize(2)), BigDecimal.valueOf(1.5))); - - creator.update(transaction, fixture.account(), AccountTransaction.Type.DIVIDENDS, DATE_TIME.plusDays(2), - Values.Amount.factorize(150), CurrencyUnit.EUR, otherSecurity, Values.Share.factorize(20), - EX_DATE.plusDays(1), Money.of(CurrencyUnit.USD, Values.Amount.factorize(75)), EXCHANGE_RATE, - updatedUnits, "updated note", "updated source"); - - var cashPosting = entry.getPostings().get(0); - - assertThat(entry.getDateTime(), is(DATE_TIME.plusDays(2))); - assertThat(entry.getNote(), is("updated note")); - assertThat(entry.getSource(), is("updated source")); - assertThat(cashPosting.getAmount(), is(Values.Amount.factorize(150))); - assertThat(cashPosting.getCurrency(), is(CurrencyUnit.EUR)); - assertThat(cashPosting.getForexAmount(), is(Values.Amount.factorize(75))); - assertThat(cashPosting.getForexCurrency(), is(CurrencyUnit.USD)); - assertThat(cashPosting.getExchangeRate(), is(EXCHANGE_RATE)); - assertSame(otherSecurity, cashPosting.getSecurity()); - assertThat(cashPosting.getShares(), is(Values.Share.factorize(20))); - assertThat(exDate(cashPosting), is(EX_DATE.plusDays(1))); - assertTrue(entry.getPostings().stream().anyMatch(posting -> posting.getType() == LedgerPostingType.FEE - && posting.getAmount() == Values.Amount.factorize(3) - && posting.getForexAmount() == Values.Amount.factorize(2) - && CurrencyUnit.USD.equals(posting.getForexCurrency()) - && BigDecimal.valueOf(1.5).equals(posting.getExchangeRate()))); - var moved = creator.update(transaction, otherAccount, AccountTransaction.Type.DIVIDENDS, DATE_TIME.plusDays(3), - Values.Amount.factorize(151), CurrencyUnit.EUR, otherSecurity, Values.Share.factorize(21), - EX_DATE.plusDays(2), null, null, List.of(), "moved note", "moved source"); - - assertTrue(fixture.account().getTransactions().isEmpty()); - assertThat(otherAccount.getTransactions(), is(List.of(moved))); - assertThat(moved.getUUID(), is(projectionUUID)); - assertThat(entry.getUUID(), is(entryUUID)); - assertThat(entry.getPostings().get(0).getUUID(), is(postingUUID)); - assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0) - .getRuntimeProjectionId(), is(projectionUUID)); - assertSame(otherAccount, entry.getPostings().get(0).getAccount()); - assertSame(otherAccount, name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0).getAccount()); - assertThat(moved.getAmount(), is(Values.Amount.factorize(151))); - assertThat(moved.getShares(), is(Values.Share.factorize(21))); - assertThat(moved.getNote(), is("moved note")); - assertThrows(UnsupportedOperationException.class, - () -> creator.update(transaction, fixture.account(), AccountTransaction.Type.BUY, DATE_TIME, - Values.Amount.factorize(150), CurrencyUnit.EUR, otherSecurity, - Values.Share.factorize(20), EX_DATE, null, null, List.of(), "note", "source")); - assertThat(fixture.client().getAllTransactions().size(), is(1)); - assertThat(projectionRoles(loadXml(saveXml(fixture.client()))), is(expectedProjectionRoles)); - assertThat(projectionRoles(loadProtobuf(saveProtobuf(fixture.client()))), is(expectedProjectionRoles)); - assertValid(fixture.client()); - } - - /** - * Verifies that XML save/load/save preserves dividend projection identity and fields. - * The dividend row must rematerialize from the same ledger entry with ex-date and units intact. - */ - @Test - public void testXmlSaveLoadSavePreservesDividendProjectionUUIDAndFields() throws Exception - { - var client = dividendClient(); - var expectedProjectionRoles = projectionRoles(client); - - var loaded = loadXml(saveXml(client)); - var reloaded = loadXml(saveXml(loaded)); - var transaction = reloaded.getAccounts().get(0).getTransactions().get(0); - - assertThat(projectionRoles(reloaded), is(expectedProjectionRoles)); - assertThat(reloaded.getLedger().getEntries().size(), is(1)); - assertThat(transaction, instanceOf(LedgerBackedTransaction.class)); - assertThat(transaction.getType(), is(AccountTransaction.Type.DIVIDENDS)); - assertThat(transaction.getExDate(), is(EX_DATE)); - assertThat(transaction.getShares(), is(Values.Share.factorize(12))); - assertThat(transaction.getUnits().count(), is(3L)); - assertValid(reloaded); - } - - /** - * Verifies that protobuf save/load/save preserves dividend projection identity and fields. - * The dividend row must rematerialize from the same ledger entry with ex-date and units intact. - */ - @Test - public void testProtobufSaveLoadSavePreservesDividendProjectionUUIDAndFields() throws Exception - { - var client = dividendClient(); - var expectedProjectionRoles = projectionRoles(client); - - var loaded = loadProtobuf(saveProtobuf(client)); - var reloaded = loadProtobuf(saveProtobuf(loaded)); - var transaction = reloaded.getAccounts().get(0).getTransactions().get(0); - - assertThat(projectionRoles(reloaded), is(expectedProjectionRoles)); - assertThat(reloaded.getLedger().getEntries().size(), is(1)); - assertThat(transaction, instanceOf(LedgerBackedTransaction.class)); - assertThat(transaction.getType(), is(AccountTransaction.Type.DIVIDENDS)); - assertThat(transaction.getExDate(), is(EX_DATE)); - assertThat(transaction.getShares(), is(Values.Share.factorize(12))); - assertThat(transaction.getUnits().count(), is(3L)); - assertValid(reloaded); - } - - private Client dividendClient() - { - var fixture = fixture(); - createDividend(fixture.client(), fixture.account(), fixture.security()); - return fixture.client(); - } - - private AccountTransaction createDividend(Client client, Account account, Security security) - { - return new LedgerDividendTransactionCreator(client).create(account, DATE_TIME, Values.Amount.factorize(140), - CurrencyUnit.EUR, security, Values.Share.factorize(12), EX_DATE, - Money.of(CurrencyUnit.USD, Values.Amount.factorize(70)), EXCHANGE_RATE, dividendUnits(), - "note", "source"); - } - - private List dividendUnits() - { - return List.of( - new Unit(Unit.Type.GROSS_VALUE, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(200)), - Money.of(CurrencyUnit.USD, Values.Amount.factorize(100)), EXCHANGE_RATE), - new Unit(Unit.Type.FEE, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(20)), - Money.of(CurrencyUnit.USD, Values.Amount.factorize(10)), EXCHANGE_RATE), - new Unit(Unit.Type.TAX, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(40)), - Money.of(CurrencyUnit.USD, Values.Amount.factorize(20)), EXCHANGE_RATE)); - } - - private void assertUnitPostings(List postings) - { - assertTrue(postings.stream().anyMatch(posting -> posting.getType() == LedgerPostingType.GROSS_VALUE - && posting.getAmount() == Values.Amount.factorize(200) - && posting.getForexAmount() == Values.Amount.factorize(100) - && CurrencyUnit.USD.equals(posting.getForexCurrency()) - && EXCHANGE_RATE.equals(posting.getExchangeRate()))); - assertTrue(postings.stream().anyMatch(posting -> posting.getType() == LedgerPostingType.FEE - && posting.getAmount() == Values.Amount.factorize(20) - && posting.getForexAmount() == Values.Amount.factorize(10) - && CurrencyUnit.USD.equals(posting.getForexCurrency()) - && EXCHANGE_RATE.equals(posting.getExchangeRate()))); - assertTrue(postings.stream().anyMatch(posting -> posting.getType() == LedgerPostingType.TAX - && posting.getAmount() == Values.Amount.factorize(40) - && posting.getForexAmount() == Values.Amount.factorize(20) - && CurrencyUnit.USD.equals(posting.getForexCurrency()) - && EXCHANGE_RATE.equals(posting.getExchangeRate()))); - } - - private LocalDateTime exDate(LedgerPosting posting) - { - return posting.getParameters().stream() - .filter(parameter -> parameter.getType() == LedgerParameterType.EX_DATE) - .filter(parameter -> parameter.getValueKind() == LedgerParameter.ValueKind.LOCAL_DATE_TIME) - .map(LedgerParameter::getValue) - .map(LocalDateTime.class::cast) - .findFirst() - .orElse(null); - } - - private List projectionRoles(Client client) - { - return client.getLedger().getEntries().stream() - .flatMap(entry -> name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream()) - .map(descriptor -> descriptor.getRole()) - .toList(); - } - - private String saveXml(Client client) throws IOException - { - var file = File.createTempFile("ledger-dividend", ".xml"); - try - { - ClientFactory.save(client, file); - return Files.readString(file.toPath(), StandardCharsets.UTF_8); - } - finally - { - Files.deleteIfExists(file.toPath()); - } - } - - private Client loadXml(String xml) throws IOException - { - return ClientFactory.load(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); - } - - private byte[] saveProtobuf(Client client) throws IOException - { - return ProtobufTestUtilities.save(client); - } - - private Client loadProtobuf(byte[] bytes) throws IOException - { - return ProtobufTestUtilities.load(bytes); - } - - private void assertValid(Client client) - { - var result = LedgerStructuralValidator.validate(client.getLedger()); - - assertThat(result.getIssues().toString(), result.isOK(), is(true)); - } - - private Fixture fixture() - { - var client = new Client(); - var account = account(); - var security = new Security("Security", CurrencyUnit.USD); - security.setUpdatedAt(Instant.now()); - - client.addAccount(account); - client.addSecurity(security); - - return new Fixture(client, account, security); - } - - private Account account() - { - var account = new Account("Account"); - account.setCurrencyCode(CurrencyUnit.EUR); - return account; - } - - private record Fixture(Client client, Account account, Security security) - { - } -} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerInvestmentPlanRefSupportTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerInvestmentPlanRefSupportTest.java deleted file mode 100644 index bccae41f8f..0000000000 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerInvestmentPlanRefSupportTest.java +++ /dev/null @@ -1,107 +0,0 @@ -package name.abuchen.portfolio.model.ledger.compatibility; - -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.CoreMatchers.nullValue; -import static org.hamcrest.MatcherAssert.assertThat; - -import org.junit.Test; - -import name.abuchen.portfolio.model.Client; -import name.abuchen.portfolio.model.InvestmentPlan; -import name.abuchen.portfolio.model.ledger.LedgerEntry; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; -import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; - -/** - * Tests helper rules used when generated bookings are converted. - * Plan executions are linked by plan key and LedgerEntry metadata, so projection-ref - * conversion helpers no longer rewrite or reject InvestmentPlan refs. - */ -@SuppressWarnings("nls") -public class LedgerInvestmentPlanRefSupportTest -{ - /** - * Verifies that splitting a transfer does not rewrite legacy execution refs. - * New plan linkage follows entry metadata, not projection UUID side mappings. - */ - @Test - public void testSplitUpdatesDoNotRewriteExecutionRefs() - { - var fixture = fixture(); - var plan = planWithRef(fixture.client(), - new InvestmentPlan.LedgerExecutionRef(fixture.transferEntry().getUUID(), null, - LedgerProjectionRole.SOURCE_ACCOUNT)); - - LedgerInvestmentPlanRefSupport.prepareAccountTransferSplitExecutionRefUpdates(fixture.client(), - fixture.transferEntry(), LedgerProjectionRole.SOURCE_ACCOUNT, LedgerProjectionRole.TARGET_ACCOUNT, - fixture.removalEntry(), fixture.depositEntry()).apply(); - - assertExecutionRef(plan, fixture.transferEntry().getUUID(), null, LedgerProjectionRole.SOURCE_ACCOUNT); - } - - /** - * Verifies that role-change validation no longer blocks conversions based on - * projection-scoped plan identity. - */ - @Test - public void testRoleChangeHelpersIgnoreProjectionScopedPlanRefs() - { - var fixture = fixture(); - var plan = planWithRef(fixture.client(), - new InvestmentPlan.LedgerExecutionRef(fixture.transferEntry().getUUID(), null, null)); - - var roleChange = LedgerInvestmentPlanRefSupport.roleChange( - name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport - .runtimeProjectionId(fixture.transferEntry(), LedgerProjectionRole.SOURCE_ACCOUNT), - LedgerProjectionRole.SOURCE_ACCOUNT, LedgerProjectionRole.TARGET_ACCOUNT); - - LedgerInvestmentPlanRefSupport.requireCurrentRefsResolveUniquely(fixture.client(), fixture.transferEntry()); - LedgerInvestmentPlanRefSupport.requireRefsFollowRoleChanges(fixture.client(), fixture.transferEntry(), - roleChange); - LedgerInvestmentPlanRefSupport.updateProjectionRoles(fixture.client(), fixture.transferEntry(), roleChange); - - assertExecutionRef(plan, fixture.transferEntry().getUUID(), null, null); - } - - private InvestmentPlan planWithRef(Client client, InvestmentPlan.LedgerExecutionRef ref) - { - var plan = new InvestmentPlan("Plan"); - - plan.addLedgerExecutionRef(ref); - client.addPlan(plan); - - return plan; - } - - private void assertExecutionRef(InvestmentPlan plan, String entryUUID, String projectionUUID, - LedgerProjectionRole role) - { - var ref = plan.getLedgerExecutionRefs().get(0); - - assertThat(ref.getLedgerEntryUUID(), is(entryUUID)); - assertThat(ref.getProjectionUUID(), projectionUUID == null ? nullValue() : is(projectionUUID)); - assertThat(ref.getProjectionRole(), role == null ? nullValue() : is(role)); - } - - private Fixture fixture() - { - var client = new Client(); - var transferEntry = entry("transfer-entry", LedgerEntryType.CASH_TRANSFER); - var removalEntry = entry("removal-entry", LedgerEntryType.REMOVAL); - var depositEntry = entry("deposit-entry", LedgerEntryType.DEPOSIT); - return new Fixture(client, transferEntry, removalEntry, depositEntry); - } - - private LedgerEntry entry(String uuid, LedgerEntryType type) - { - var entry = new LedgerEntry(uuid); - - entry.setType(type); - - return entry; - } - - private record Fixture(Client client, LedgerEntry transferEntry, LedgerEntry removalEntry, LedgerEntry depositEntry) - { - } -} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerNativeComponentInspectorModelTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerNativeComponentInspectorModelTest.java deleted file mode 100644 index 29f352ab8a..0000000000 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerNativeComponentInspectorModelTest.java +++ /dev/null @@ -1,276 +0,0 @@ -package name.abuchen.portfolio.model.ledger.compatibility; - -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.math.BigDecimal; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.List; - -import org.junit.Test; - -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.ledger.LedgerEntry; -import name.abuchen.portfolio.model.ledger.LedgerParameter; -import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerPostingDirection; -import name.abuchen.portfolio.model.ledger.LedgerPostingSemanticRole; -import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; -import name.abuchen.portfolio.model.ledger.compatibility.LedgerNativeComponentInspectorModel.HeaderField; -import name.abuchen.portfolio.model.ledger.configuration.CorporateActionKind; -import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; -import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinitionRegistry; -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.model.ledger.nativeentry.LedgerNativeEntryAssembler; -import name.abuchen.portfolio.model.ledger.nativeentry.NativeCorporateActionEvent; -import name.abuchen.portfolio.model.ledger.nativeentry.NativeEntryMetadata; -import name.abuchen.portfolio.model.ledger.nativeentry.NativeSecurityLeg; -import name.abuchen.portfolio.model.ledger.nativeentry.Ratio; -import name.abuchen.portfolio.money.CurrencyUnit; -import name.abuchen.portfolio.money.Money; -import name.abuchen.portfolio.money.Values; - -/** - * Tests the read-only model behind the Ledger entry inspector. - * The tests make sure selected ledger-backed projections can be displayed from Ledger facts - * and optional Java-only native leg definitions without mutating the entry or runtime projection. - */ -public class LedgerNativeComponentInspectorModelTest -{ - /** - * Checks that a spin-off entry can be inspected from the selected old-security projection. - * The inspector model must expose entry parameters, functional legs, postings, posting - * parameters, and derived descriptors from the persisted Ledger entry. - */ - @Test - public void testSpinOffInspectionIncludesEntryLegPostingParameterAndProjectionRows() - { - var entry = spinOffEntry(); - var selectedDescriptor = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0); - var updatedAt = entry.getUpdatedAt(); - - var model = LedgerNativeComponentInspectorModel - .from(entry, selectedDescriptor, LedgerEntryDefinitionRegistry::lookup).orElseThrow(); - - assertThat(model.getHeaderRows().stream() - .anyMatch(row -> row.field() == HeaderField.ENTRY_TYPE - && "CORPORATE_ACTION".equals(row.value())), - is(true)); - assertThat(model.getHeaderRows().stream() - .anyMatch(row -> row.field() == HeaderField.NATIVE_TARGETED && "true".equals(row.value())), - is(true)); - assertThat(model.getHeaderRows().stream() - .anyMatch(row -> row.field() == HeaderField.SELECTED_RUNTIME_PROJECTION_ID - && selectedDescriptor.getRuntimeProjectionId().equals(row.value())), - is(true)); - assertTrue(model.isNativeEntryDefinitionAvailable()); - assertThat(model.getEntryParameters().stream() - .anyMatch(row -> "CORPORATE_ACTION_KIND".equals(row.parameter()) - && "SPIN_OFF".equals(row.value())), - is(true)); - assertThat(model.getLegs().stream().anyMatch(row -> "SOURCE_SECURITY_LEG".equals(row.legRole())), is(true)); - assertThat(model.getLegs().stream().anyMatch(row -> "TARGET_SECURITY_LEG".equals(row.legRole())), is(true)); - assertThat(model.getPostings().stream() - .anyMatch(row -> "posting-source".equals(row.postingUUID()) - && "SECURITY".equals(row.postingType()) - && "Old Security".equals(row.security())), - is(true)); - assertThat(model.getPostingParameters().stream() - .anyMatch(row -> "posting-source".equals(row.postingUUID()) - && "SOURCE_SECURITY".equals(row.parameter()) - && "Old Security".equals(row.value())), - is(true)); - assertThat(model.getDescriptors().stream() - .anyMatch(row -> "OLD_SECURITY_LEG".equals(row.projectionRole()) - && selectedDescriptor.getRuntimeProjectionId() - .equals(row.runtimeProjectionId()) - && "posting-source".equals(row.primaryPostingId())), - is(true)); - - assertThat(entry.getUpdatedAt(), is(updatedAt)); - assertThat(entry.getPostings().size(), is(2)); - assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).size(), is(2)); - } - - /** - * Checks that a materialized native corporate-action projection is recognized by the - * inspector support method. - * UI guards use this result to offer inspection without legacy edit, duplicate, delete, or - * convert actions. - */ - @Test - public void testMaterializedNativeProjectionIsRecognizedAsNativeTargeted() - { - var fixture = fixture(); - - LedgerNativeEntryAssembler.forClient(fixture.client()).spinOff() - .metadata(NativeEntryMetadata.of(LocalDateTime.of(2026, 6, 23, 9, 0))) - .event(NativeCorporateActionEvent.builder() - .kind(CorporateActionKind.SPIN_OFF) - .effectiveDate(LocalDate.of(2026, 6, 20)) - .build()) - .securityLeg(NativeSecurityLeg.source() - .portfolio(fixture.portfolio()) - .security(fixture.security()) - .shares(Values.Share.factorize(10)) - .amount(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(100))) - .sourceSecurity(fixture.security()) - .targetSecurity(fixture.targetSecurity()) - .ratio(Ratio.of(BigDecimal.ONE, BigDecimal.TEN)) - .build()) - .securityLeg(NativeSecurityLeg.context() - .portfolio(fixture.portfolio()) - .security(fixture.security()) - .shares(0L) - .amount(Money.of(CurrencyUnit.EUR, 0L)) - .groupKey("main") - .localKey("context-1") - .build()) - .securityLeg(NativeSecurityLeg.target() - .portfolio(fixture.portfolio()) - .security(fixture.targetSecurity()) - .shares(Values.Share.factorize(1)) - .amount(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(10))) - .sourceSecurity(fixture.security()) - .targetSecurity(fixture.targetSecurity()) - .ratio(Ratio.of(BigDecimal.ONE, BigDecimal.TEN)) - .build()) - .buildAndAdd(); - - var transaction = fixture.portfolio().getTransactions().get(0); - - assertTrue(LedgerNativeComponentInspectorModel.isLedgerNativeTargetedProjection(transaction)); - assertTrue(LedgerNativeComponentInspectorModel.from(transaction).isPresent()); - } - - /** - * Checks that a legacy fixed-shape Ledger-backed transaction can still be inspected. - * The model must show Ledger facts without native leg definitions because standard - * transaction families are not configured through native legs. - */ - @Test - public void testLegacyFixedShapeEntryShowsLedgerFactsWithoutNativeLegDefinitions() - { - var client = new Client(); - var account = new Account("Account"); - var portfolio = new Portfolio("Portfolio"); - var security = new Security("Security", CurrencyUnit.EUR); - - account.setCurrencyCode(CurrencyUnit.EUR); - portfolio.setReferenceAccount(account); - client.addAccount(account); - client.addPortfolio(portfolio); - client.addSecurity(security); - - var buySell = new LedgerBuySellTransactionCreator(client).create(portfolio, account, - PortfolioTransaction.Type.BUY, LocalDateTime.of(2026, 6, 23, 9, 0), - Values.Amount.factorize(123), CurrencyUnit.EUR, security, Values.Share.factorize(5), - List.of(), "note", "source"); - - var model = LedgerNativeComponentInspectorModel.from(buySell.getPortfolioTransaction()).orElseThrow(); - - assertThat(model.getHeaderRows().stream() - .anyMatch(row -> row.field() == HeaderField.ENTRY_TYPE && "BUY".equals(row.value())), - is(true)); - assertFalse(model.isNativeEntryDefinitionAvailable()); - assertTrue(model.getLegs().isEmpty()); - assertFalse(model.getPostings().isEmpty()); - assertFalse(model.getDescriptors().isEmpty()); - } - - /** - * Checks that normal legacy transactions are not offered to the Ledger inspector. - * The action must only appear when a selected row resolves to a ledger-backed projection. - */ - @Test - public void testNonLedgerBackedTransactionIsUnsupported() - { - var transaction = new AccountTransaction(); - transaction.setType(AccountTransaction.Type.DEPOSIT); - - assertFalse(LedgerNativeComponentInspectorModel.from(transaction).isPresent()); - } - - private static LedgerEntry spinOffEntry() - { - var entry = new LedgerEntry("entry-spin-off"); - entry.setType(LedgerEntryType.CORPORATE_ACTION); - entry.setDateTime(LocalDateTime.of(2026, 6, 23, 9, 0)); - entry.setNote("Spin-off note"); - entry.setSource("manual"); - entry.addParameter(LedgerParameter.ofCode(LedgerParameterType.CORPORATE_ACTION_KIND, - CorporateActionKind.SPIN_OFF)); - entry.addParameter(LedgerParameter.ofLocalDate(LedgerParameterType.EFFECTIVE_DATE, - LocalDate.of(2026, 6, 20))); - - var portfolio = new Portfolio("Growth Portfolio"); - var oldSecurity = new Security("Old Security", CurrencyUnit.EUR); - var newSecurity = new Security("New Security", CurrencyUnit.EUR); - - var sourcePosting = securityPosting("posting-source", oldSecurity, CorporateActionLeg.SOURCE_SECURITY, - LedgerParameterType.SOURCE_SECURITY, oldSecurity); - sourcePosting.setPortfolio(portfolio); - entry.addPosting(sourcePosting); - - var targetPosting = securityPosting("posting-target", newSecurity, CorporateActionLeg.TARGET_SECURITY, - LedgerParameterType.TARGET_SECURITY, newSecurity); - targetPosting.setPortfolio(portfolio); - entry.addPosting(targetPosting); - - return entry; - } - - private static Fixture fixture() - { - var client = new Client(); - var account = new Account("Account"); - var portfolio = new Portfolio("Portfolio"); - var security = new Security("Security", CurrencyUnit.EUR); - var targetSecurity = new Security("Target Security", CurrencyUnit.EUR); - - account.setCurrencyCode(CurrencyUnit.EUR); - portfolio.setReferenceAccount(account); - client.addAccount(account); - client.addPortfolio(portfolio); - client.addSecurity(security); - client.addSecurity(targetSecurity); - - return new Fixture(client, account, portfolio, security, targetSecurity); - } - - private static LedgerPosting securityPosting(String uuid, Security security, CorporateActionLeg leg, - LedgerParameterType securityParameterType, Security parameterSecurity) - { - var posting = new LedgerPosting(uuid); - posting.setType(LedgerPostingType.SECURITY); - posting.setSecurity(security); - posting.setShares(123_00000000L); - posting.setSemanticRole(LedgerPostingSemanticRole.SECURITY); - posting.setDirection(leg == CorporateActionLeg.SOURCE_SECURITY ? LedgerPostingDirection.OUTBOUND - : LedgerPostingDirection.INBOUND); - posting.setCorporateActionLeg(leg); - posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); - posting.setLocalKey(leg == CorporateActionLeg.SOURCE_SECURITY ? LedgerProjectionRole.OLD_SECURITY_LEG.name() - : LedgerProjectionRole.NEW_SECURITY_LEG.name()); - posting.addParameter(LedgerParameter.ofCode(LedgerParameterType.CORPORATE_ACTION_LEG, leg)); - posting.addParameter(LedgerParameter.ofSecurity(securityParameterType, parameterSecurity)); - posting.addParameter(LedgerParameter.ofDecimal(LedgerParameterType.RATIO_NUMERATOR, BigDecimal.ONE)); - posting.addParameter(LedgerParameter.ofDecimal(LedgerParameterType.RATIO_DENOMINATOR, BigDecimal.TEN)); - return posting; - } - - private record Fixture(Client client, Account account, Portfolio portfolio, Security security, Security targetSecurity) - { - } -} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferTransactionCreatorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferTransactionCreatorTest.java deleted file mode 100644 index e32a17b75c..0000000000 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferTransactionCreatorTest.java +++ /dev/null @@ -1,433 +0,0 @@ -package name.abuchen.portfolio.model.ledger.compatibility; - -import static org.hamcrest.CoreMatchers.instanceOf; -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; - -import java.io.ByteArrayInputStream; -import java.io.File; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.time.Instant; -import java.time.LocalDateTime; -import java.util.List; - -import org.junit.Test; - -import name.abuchen.portfolio.model.Client; -import name.abuchen.portfolio.model.ClientFactory; -import name.abuchen.portfolio.model.Portfolio; -import name.abuchen.portfolio.model.PortfolioTransaction; -import name.abuchen.portfolio.model.PortfolioTransferEntry; -import name.abuchen.portfolio.model.ProtobufTestUtilities; -import name.abuchen.portfolio.model.Security; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; -import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; -import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; -import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; -import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; -import name.abuchen.portfolio.money.CurrencyUnit; -import name.abuchen.portfolio.money.Money; -import name.abuchen.portfolio.money.Values; - -/** - * Tests ledger-aware creation and editing of transactions in this family. - * These tests make sure user-visible rows are rebuilt from ledger truth and structural facts are not written through legacy projections. - */ -@SuppressWarnings("nls") -public class LedgerPortfolioTransferTransactionCreatorTest -{ - private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 6, 7, 8, 9); - - /** - * Verifies that a portfolio transfer is created directly in the ledger. - * Source and target depot rows must be projections of one persisted transfer booking. - */ - @Test - public void testCreatesLedgerPortfolioTransferDirectly() - { - var fixture = fixture(); - var transfer = createTransfer(fixture); - var sourceTransaction = transfer.getSourceTransaction(); - var targetTransaction = transfer.getTargetTransaction(); - var entry = fixture.client().getLedger().getEntries().get(0); - var sourcePosting = entry.getPostings().get(0); - var targetPosting = entry.getPostings().get(1); - var sourceProjection = projection(entry, LedgerProjectionRole.SOURCE_PORTFOLIO); - var targetProjection = projection(entry, LedgerProjectionRole.TARGET_PORTFOLIO); - - assertThat(entry.getType(), is(LedgerEntryType.SECURITY_TRANSFER)); - assertThat(entry.getDateTime(), is(DATE_TIME)); - assertThat(entry.getNote(), is("note")); - assertThat(entry.getSource(), is("source")); - assertThat(sourcePosting.getType(), is(LedgerPostingType.SECURITY)); - assertThat(sourcePosting.getAmount(), is(Values.Amount.factorize(100))); - assertThat(sourcePosting.getCurrency(), is(CurrencyUnit.EUR)); - assertSame(fixture.source(), sourcePosting.getPortfolio()); - assertSame(fixture.security(), sourcePosting.getSecurity()); - assertThat(sourcePosting.getShares(), is(Values.Share.factorize(5))); - assertThat(targetPosting.getType(), is(LedgerPostingType.SECURITY)); - assertThat(targetPosting.getAmount(), is(Values.Amount.factorize(100))); - assertThat(targetPosting.getCurrency(), is(CurrencyUnit.EUR)); - assertSame(fixture.target(), targetPosting.getPortfolio()); - assertSame(fixture.security(), targetPosting.getSecurity()); - assertThat(targetPosting.getShares(), is(Values.Share.factorize(5))); - assertTrue(entry.getPostings().stream().allMatch(posting -> posting.getAccount() == null)); - - assertThat(sourceProjection.getRole(), is(LedgerProjectionRole.SOURCE_PORTFOLIO)); - assertSame(fixture.source(), sourceProjection.getPortfolio()); - assertThat(sourceProjection.getPrimaryPosting().getUUID(), is(sourcePosting.getUUID())); - assertThat(targetProjection.getRole(), is(LedgerProjectionRole.TARGET_PORTFOLIO)); - assertSame(fixture.target(), targetProjection.getPortfolio()); - assertThat(targetProjection.getPrimaryPosting().getUUID(), is(targetPosting.getUUID())); - assertThat(sourceTransaction.getUUID(), is(sourceProjection.getRuntimeProjectionId())); - assertThat(targetTransaction.getUUID(), is(targetProjection.getRuntimeProjectionId())); - assertThat(sourceTransaction, instanceOf(LedgerBackedTransaction.class)); - assertThat(targetTransaction, instanceOf(LedgerBackedTransaction.class)); - assertThat(sourceTransaction.getType(), is(PortfolioTransaction.Type.TRANSFER_OUT)); - assertThat(targetTransaction.getType(), is(PortfolioTransaction.Type.TRANSFER_IN)); - assertThat(sourceTransaction.getAmount(), is(Values.Amount.factorize(100))); - assertThat(targetTransaction.getAmount(), is(Values.Amount.factorize(100))); - assertThat(sourceTransaction.getCurrencyCode(), is(CurrencyUnit.EUR)); - assertThat(targetTransaction.getCurrencyCode(), is(CurrencyUnit.EUR)); - assertSame(fixture.security(), sourceTransaction.getSecurity()); - assertSame(fixture.security(), targetTransaction.getSecurity()); - assertThat(sourceTransaction.getShares(), is(Values.Share.factorize(5))); - assertThat(targetTransaction.getShares(), is(Values.Share.factorize(5))); - assertThat(sourceTransaction.getNote(), is("note")); - assertThat(sourceTransaction.getSource(), is("source")); - assertThat(fixture.source().getTransactions(), is(List.of(sourceTransaction))); - assertThat(fixture.target().getTransactions(), is(List.of(targetTransaction))); - assertThat(fixture.client().getAllTransactions().size(), is(1)); - assertSame(sourceTransaction, fixture.client().getAllTransactions().get(0).getTransaction()); - assertCrossEntryReadCompatibility(sourceTransaction, targetTransaction, fixture.source(), fixture.target()); - assertValid(fixture.client()); - } - - /** - * Verifies that same-shape portfolio transfer edits and owner moves are applied through ledger paths. - * Source and target projections must move without legacy delete/insert replay. - */ - @Test - public void testFacadeAppliesSameShapeEditAndMovesOwners() throws Exception - { - var fixture = fixture(); - var otherPortfolio = new Portfolio("Other"); - var otherTarget = new Portfolio("Other Target"); - otherPortfolio.setUpdatedAt(Instant.now()); - otherTarget.setUpdatedAt(Instant.now()); - var otherSecurity = new Security("Other Security", CurrencyUnit.EUR); - otherSecurity.setUpdatedAt(Instant.now()); - fixture.client().addPortfolio(otherPortfolio); - fixture.client().addPortfolio(otherTarget); - fixture.client().addSecurity(otherSecurity); - var creator = new LedgerPortfolioTransferTransactionCreator(fixture.client()); - var transfer = createTransfer(fixture); - var sourceTransaction = transfer.getSourceTransaction(); - var targetTransaction = transfer.getTargetTransaction(); - var sourcePostingUUID = fixture.client().getLedger().getEntries().get(0).getPostings().get(0).getUUID(); - var targetPostingUUID = fixture.client().getLedger().getEntries().get(0).getPostings().get(1).getUUID(); - var entryUUID = fixture.client().getLedger().getEntries().get(0).getUUID(); - var expectedProjectionUUIDs = projectionUUIDs(fixture.client()); - var expectedProjectionRoles = projectionRoles(fixture.client()); - - creator.update(transfer, fixture.source(), fixture.target(), otherSecurity, DATE_TIME.plusDays(1), - Values.Share.factorize(7), Values.Amount.factorize(150), CurrencyUnit.EUR, "updated", - "updated source"); - - var entry = fixture.client().getLedger().getEntries().get(0); - - assertThat(entry.getDateTime(), is(DATE_TIME.plusDays(1))); - assertThat(entry.getNote(), is("updated")); - assertThat(entry.getSource(), is("updated source")); - assertThat(entry.getPostings().get(0).getUUID(), is(sourcePostingUUID)); - assertThat(entry.getPostings().get(0).getAmount(), is(Values.Amount.factorize(150))); - assertSame(otherSecurity, entry.getPostings().get(0).getSecurity()); - assertThat(entry.getPostings().get(0).getShares(), is(Values.Share.factorize(7))); - assertThat(entry.getPostings().get(1).getUUID(), is(targetPostingUUID)); - assertThat(entry.getPostings().get(1).getAmount(), is(Values.Amount.factorize(150))); - assertSame(otherSecurity, entry.getPostings().get(1).getSecurity()); - assertThat(entry.getPostings().get(1).getShares(), is(Values.Share.factorize(7))); - assertThat(sourceTransaction.getAmount(), is(Values.Amount.factorize(150))); - assertThat(targetTransaction.getAmount(), is(Values.Amount.factorize(150))); - - var moved = creator.update(transfer, otherPortfolio, otherTarget, otherSecurity, DATE_TIME.plusDays(2), - Values.Share.factorize(8), Values.Amount.factorize(175), CurrencyUnit.EUR, "moved note", - "moved source"); - var movedSourceTransaction = moved.getSourceTransaction(); - var movedTargetTransaction = moved.getTargetTransaction(); - - assertTrue(fixture.source().getTransactions().isEmpty()); - assertTrue(fixture.target().getTransactions().isEmpty()); - assertThat(otherPortfolio.getTransactions(), is(List.of(movedSourceTransaction))); - assertThat(otherTarget.getTransactions(), is(List.of(movedTargetTransaction))); - assertThat(movedSourceTransaction.getUUID(), is(expectedProjectionUUIDs.get(0))); - assertThat(movedTargetTransaction.getUUID(), is(expectedProjectionUUIDs.get(1))); - assertThat(entry.getUUID(), is(entryUUID)); - assertThat(entry.getPostings().get(0).getUUID(), is(sourcePostingUUID)); - assertThat(entry.getPostings().get(1).getUUID(), is(targetPostingUUID)); - assertSame(otherPortfolio, entry.getPostings().get(0).getPortfolio()); - assertSame(otherTarget, entry.getPostings().get(1).getPortfolio()); - assertSame(otherPortfolio, projection(entry, LedgerProjectionRole.SOURCE_PORTFOLIO).getPortfolio()); - assertSame(otherTarget, projection(entry, LedgerProjectionRole.TARGET_PORTFOLIO).getPortfolio()); - assertCrossEntryReadCompatibility(movedSourceTransaction, movedTargetTransaction, otherPortfolio, otherTarget); - assertThrows(UnsupportedOperationException.class, - () -> sourceTransaction.setType(PortfolioTransaction.Type.DELIVERY_INBOUND)); - assertThrows(UnsupportedOperationException.class, () -> sourceTransaction.setAmount(1L)); - sourceTransaction.getCrossEntry().updateFrom(sourceTransaction); - assertTrue(fixture.source().getTransactions().isEmpty()); - assertThat(otherPortfolio.getTransactions(), is(List.of(movedSourceTransaction))); - assertThrows(UnsupportedOperationException.class, - () -> sourceTransaction.getCrossEntry().setOwner(sourceTransaction, otherPortfolio)); - assertThrows(UnsupportedOperationException.class, () -> sourceTransaction.getCrossEntry().insert()); - assertThat(fixture.client().getAllTransactions().size(), is(1)); - assertThat(projectionRoles(loadXml(saveXml(fixture.client()))), is(expectedProjectionRoles)); - assertThat(projectionRoles(loadProtobuf(saveProtobuf(fixture.client()))), is(expectedProjectionRoles)); - assertValid(fixture.client()); - } - - /** - * Verifies that invalid portfolio transfer units are rejected before a ledger entry is added. - * The creator must not leave partial transfer truth behind. - */ - @Test - public void testCreateRejectsInvalidUnitsWithoutPartialLedgerEntry() - { - var fixture = fixture(); - var creator = new LedgerPortfolioTransferTransactionCreator(fixture.client()); - var invalidUnits = LedgerUnitPostingPatch.of(LedgerUnitPostingEdit.add(LedgerPostingType.FEE, - Money.of(CurrencyUnit.EUR, Values.Amount.factorize(-1)))); - - assertThrows(IllegalArgumentException.class, - () -> creator.create(fixture.source(), fixture.target(), fixture.security(), DATE_TIME, - Values.Share.factorize(5), Values.Amount.factorize(100), CurrencyUnit.EUR, - LedgerForexAmount.none(), LedgerForexAmount.none(), invalidUnits, "note", - "source")); - - assertTrue(fixture.client().getLedger().getEntries().isEmpty()); - assertTrue(fixture.source().getTransactions().isEmpty()); - assertTrue(fixture.target().getTransactions().isEmpty()); - } - - /** - * Verifies that mutable legacy setters stay blocked on ledger-backed portfolio transfers. - * A failed setter attempt must leave the ledger and both owner lists unchanged. - */ - @Test - public void testReadOnlyWrapperRejectsAllMutableSettersWithoutPartialMutation() - { - var fixture = fixture(); - var otherPortfolio = new Portfolio("Other"); - var otherSourceTransaction = new PortfolioTransaction(); - var otherTargetTransaction = new PortfolioTransaction(); - var transfer = createTransfer(fixture); - var sourceTransaction = transfer.getSourceTransaction(); - var targetTransaction = transfer.getTargetTransaction(); - - assertThrows(UnsupportedOperationException.class, () -> transfer.setSourcePortfolio(otherPortfolio)); - assertThrows(UnsupportedOperationException.class, () -> transfer.setTargetPortfolio(otherPortfolio)); - assertThrows(UnsupportedOperationException.class, () -> transfer.setSourceTransaction(otherSourceTransaction)); - assertThrows(UnsupportedOperationException.class, () -> transfer.setTargetTransaction(otherTargetTransaction)); - assertThrows(UnsupportedOperationException.class, () -> transfer.setDate(DATE_TIME.plusDays(1))); - assertThrows(UnsupportedOperationException.class, () -> transfer.setSecurity(fixture.security())); - assertThrows(UnsupportedOperationException.class, () -> transfer.setShares(1L)); - assertThrows(UnsupportedOperationException.class, () -> transfer.setAmount(1L)); - assertThrows(UnsupportedOperationException.class, () -> transfer.setCurrencyCode(CurrencyUnit.USD)); - assertThrows(UnsupportedOperationException.class, () -> transfer.setNote("other")); - assertThrows(UnsupportedOperationException.class, () -> transfer.setSource("other")); - - assertSame(fixture.source(), transfer.getSourcePortfolio()); - assertSame(fixture.target(), transfer.getTargetPortfolio()); - assertSame(sourceTransaction, transfer.getSourceTransaction()); - assertSame(targetTransaction, transfer.getTargetTransaction()); - assertCrossEntryReadCompatibility(sourceTransaction, targetTransaction, fixture.source(), fixture.target()); - assertValid(fixture.client()); - } - - /** - * Verifies that normal legacy transfer entries still allow their mutable setters. - * Ledger read-only protection must not change non-ledger portfolio transfer behavior. - */ - @Test - public void testLegacyTransferEntryMutableSettersStillWork() - { - var source = new Portfolio("Source"); - var target = new Portfolio("Target"); - var otherSource = new Portfolio("Other Source"); - var otherTarget = new Portfolio("Other Target"); - var replacementSourceTransaction = new PortfolioTransaction(); - var replacementTargetTransaction = new PortfolioTransaction(); - var transfer = new PortfolioTransferEntry(source, target); - - transfer.setSourcePortfolio(otherSource); - transfer.setTargetPortfolio(otherTarget); - transfer.setSourceTransaction(replacementSourceTransaction); - transfer.setTargetTransaction(replacementTargetTransaction); - - assertSame(otherSource, transfer.getSourcePortfolio()); - assertSame(otherTarget, transfer.getTargetPortfolio()); - assertSame(replacementSourceTransaction, transfer.getSourceTransaction()); - assertSame(replacementTargetTransaction, transfer.getTargetTransaction()); - } - - /** - * Verifies that XML save/load/save preserves portfolio-transfer projection identities and fields. - * Source and target depot rows must rematerialize from the same ledger entry. - */ - @Test - public void testXmlSaveLoadSavePreservesPortfolioTransferProjectionUUIDsAndFields() throws Exception - { - var client = transferClient(); - var expectedProjectionRoles = projectionRoles(client); - - var loaded = loadXml(saveXml(client)); - var reloaded = loadXml(saveXml(loaded)); - - assertThat(projectionRoles(reloaded), is(expectedProjectionRoles)); - assertThat(reloaded.getLedger().getEntries().size(), is(1)); - assertThat(reloaded.getPortfolios().get(0).getTransactions().get(0), instanceOf(LedgerBackedTransaction.class)); - assertThat(reloaded.getPortfolios().get(1).getTransactions().get(0), instanceOf(LedgerBackedTransaction.class)); - assertThat(reloaded.getPortfolios().get(0).getTransactions().get(0).getType(), - is(PortfolioTransaction.Type.TRANSFER_OUT)); - assertThat(reloaded.getPortfolios().get(1).getTransactions().get(0).getType(), - is(PortfolioTransaction.Type.TRANSFER_IN)); - assertThat(reloaded.getAllTransactions().size(), is(1)); - assertValid(reloaded); - } - - /** - * Verifies that protobuf save/load/save preserves portfolio-transfer projection identities and fields. - * Source and target depot rows must rematerialize from the same ledger entry. - */ - @Test - public void testProtobufSaveLoadSavePreservesPortfolioTransferProjectionUUIDsAndFields() throws Exception - { - var client = transferClient(); - var expectedProjectionRoles = projectionRoles(client); - - var loaded = loadProtobuf(saveProtobuf(client)); - var reloaded = loadProtobuf(saveProtobuf(loaded)); - - assertThat(projectionRoles(reloaded), is(expectedProjectionRoles)); - assertThat(reloaded.getLedger().getEntries().size(), is(1)); - assertThat(reloaded.getPortfolios().get(0).getTransactions().get(0), instanceOf(LedgerBackedTransaction.class)); - assertThat(reloaded.getPortfolios().get(1).getTransactions().get(0), instanceOf(LedgerBackedTransaction.class)); - assertThat(reloaded.getPortfolios().get(0).getTransactions().get(0).getType(), - is(PortfolioTransaction.Type.TRANSFER_OUT)); - assertThat(reloaded.getPortfolios().get(1).getTransactions().get(0).getType(), - is(PortfolioTransaction.Type.TRANSFER_IN)); - assertThat(reloaded.getAllTransactions().size(), is(1)); - assertValid(reloaded); - } - - private void assertCrossEntryReadCompatibility(PortfolioTransaction sourceTransaction, - PortfolioTransaction targetTransaction, Portfolio source, Portfolio target) - { - assertThat(sourceTransaction.getCrossEntry(), instanceOf(PortfolioTransferEntry.class)); - assertThat(targetTransaction.getCrossEntry(), instanceOf(PortfolioTransferEntry.class)); - assertSame(targetTransaction, sourceTransaction.getCrossEntry().getCrossTransaction(sourceTransaction)); - assertSame(sourceTransaction, targetTransaction.getCrossEntry().getCrossTransaction(targetTransaction)); - assertSame(target, sourceTransaction.getCrossEntry().getCrossOwner(sourceTransaction)); - assertSame(source, targetTransaction.getCrossEntry().getCrossOwner(targetTransaction)); - } - - private PortfolioTransferEntry createTransfer(Fixture fixture) - { - return new LedgerPortfolioTransferTransactionCreator(fixture.client()).create(fixture.source(), - fixture.target(), fixture.security(), DATE_TIME, Values.Share.factorize(5), - Values.Amount.factorize(100), CurrencyUnit.EUR, "note", "source"); - } - - private Client transferClient() - { - var fixture = fixture(); - - createTransfer(fixture); - - return fixture.client(); - } - - private name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor projection(name.abuchen.portfolio.model.ledger.LedgerEntry entry, - LedgerProjectionRole role) - { - return name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream().filter(projection -> projection.getRole() == role).findFirst() - .orElseThrow(); - } - - private List projectionUUIDs(Client client) - { - return client.getLedger().getEntries().stream() - .flatMap(entry -> name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream()) - .map(descriptor -> descriptor.getRuntimeProjectionId()) - .toList(); - } - - private List projectionRoles(Client client) - { - return client.getLedger().getEntries().stream() - .flatMap(entry -> name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream()) - .map(descriptor -> descriptor.getRole()) - .toList(); - } - - private String saveXml(Client client) throws IOException - { - var file = File.createTempFile("ledger-portfolio-transfer", ".xml"); - try - { - ClientFactory.save(client, file); - return Files.readString(file.toPath(), StandardCharsets.UTF_8); - } - finally - { - Files.deleteIfExists(file.toPath()); - } - } - - private Client loadXml(String xml) throws IOException - { - return ClientFactory.load(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); - } - - private byte[] saveProtobuf(Client client) throws IOException - { - return ProtobufTestUtilities.save(client); - } - - private Client loadProtobuf(byte[] bytes) throws IOException - { - return ProtobufTestUtilities.load(bytes); - } - - private void assertValid(Client client) - { - var result = LedgerStructuralValidator.validate(client.getLedger()); - - assertThat(result.getIssues().toString(), result.isOK(), is(true)); - } - - private Fixture fixture() - { - var client = new Client(); - var source = new Portfolio("Source"); - var target = new Portfolio("Target"); - var security = new Security("Security", CurrencyUnit.EUR); - source.setUpdatedAt(Instant.now()); - target.setUpdatedAt(Instant.now()); - security.setUpdatedAt(Instant.now()); - - client.addPortfolio(source); - client.addPortfolio(target); - client.addSecurity(security); - - return new Fixture(client, source, target, security); - } - - private record Fixture(Client client, Portfolio source, Portfolio target, Security security) - { - } -} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerTransferDirectionConverterTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerTransferDirectionConverterTest.java deleted file mode 100644 index f6d733f625..0000000000 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerTransferDirectionConverterTest.java +++ /dev/null @@ -1,562 +0,0 @@ -package name.abuchen.portfolio.model.ledger.compatibility; - -import static org.hamcrest.CoreMatchers.instanceOf; -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertThrows; - -import java.io.ByteArrayInputStream; -import java.io.File; -import java.io.IOException; -import java.math.BigDecimal; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.time.Instant; -import java.time.LocalDateTime; -import java.util.List; - -import org.junit.Test; - -import name.abuchen.portfolio.model.Account; -import name.abuchen.portfolio.model.AccountTransaction; -import name.abuchen.portfolio.model.AccountTransferEntry; -import name.abuchen.portfolio.model.Client; -import name.abuchen.portfolio.model.ClientFactory; -import name.abuchen.portfolio.model.InvestmentPlan; -import name.abuchen.portfolio.model.Portfolio; -import name.abuchen.portfolio.model.PortfolioTransaction; -import name.abuchen.portfolio.model.PortfolioTransferEntry; -import name.abuchen.portfolio.model.ProtobufTestUtilities; -import name.abuchen.portfolio.model.Security; -import name.abuchen.portfolio.model.Transaction; -import name.abuchen.portfolio.model.ledger.LedgerEntry; -import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; -import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; -import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; -import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; -import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; -import name.abuchen.portfolio.money.CurrencyUnit; -import name.abuchen.portfolio.money.ExchangeRate; -import name.abuchen.portfolio.money.Money; -import name.abuchen.portfolio.money.Values; - -/** - * Tests ledger-aware reversal of account and portfolio transfers. - * These tests make sure transfer sides swap without guessing missing facts or leaving stale owner rows. - */ -@SuppressWarnings("nls") -public class LedgerTransferDirectionConverterTest -{ - private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 6, 7, 8, 9); - private static final BigDecimal EXCHANGE_RATE = BigDecimal.valueOf(0.5); - - /** - * Verifies that an account transfer can be reversed without creating a new booking. - * Source and target sides must swap while the ledger entry and projections stay consistent after save/load. - */ - @Test - public void testReversesLedgerBackedAccountTransferPreservingIdentityAndTruth() throws Exception - { - var fixture = accountFixture(); - var transfer = createAccountTransfer(fixture); - var entry = fixture.client().getLedger().getEntries().get(0); - var entryUUID = entry.getUUID(); - var sourcePostingUUID = posting(entry, fixture.source()).getUUID(); - var targetPostingUUID = posting(entry, fixture.target()).getUUID(); - var sourceProjectionUUID = projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT).getRuntimeProjectionId(); - var targetProjectionUUID = projection(entry, LedgerProjectionRole.TARGET_ACCOUNT).getRuntimeProjectionId(); - - var reversed = converter(fixture.client()).reverse(transfer); - var reversedSource = reversed.getSourceTransaction(); - var reversedTarget = reversed.getTargetTransaction(); - - assertThat(entry.getUUID(), is(entryUUID)); - assertThat(entry.getType(), is(LedgerEntryType.CASH_TRANSFER)); - assertThat(posting(entry, fixture.source()).getUUID(), is(sourcePostingUUID)); - assertThat(posting(entry, fixture.target()).getUUID(), is(targetPostingUUID)); - assertThat(projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT).getRuntimeProjectionId(), is(sourceProjectionUUID)); - assertThat(projection(entry, LedgerProjectionRole.TARGET_ACCOUNT).getRuntimeProjectionId(), is(targetProjectionUUID)); - assertSame(fixture.target(), projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT).getAccount()); - assertSame(fixture.source(), projection(entry, LedgerProjectionRole.TARGET_ACCOUNT).getAccount()); - assertSame(fixture.source(), posting(entry, fixture.source()).getAccount()); - assertSame(fixture.target(), posting(entry, fixture.target()).getAccount()); - assertThat(posting(entry, fixture.source()).getAmount(), is(Values.Amount.factorize(100))); - assertThat(posting(entry, fixture.source()).getCurrency(), is(CurrencyUnit.EUR)); - assertThat(posting(entry, fixture.source()).getForexAmount(), is((Long) null)); - assertThat(posting(entry, fixture.source()).getForexCurrency(), is((String) null)); - assertThat(posting(entry, fixture.source()).getExchangeRate(), is((BigDecimal) null)); - assertThat(posting(entry, fixture.target()).getAmount(), is(Values.Amount.factorize(200))); - assertThat(posting(entry, fixture.target()).getCurrency(), is(CurrencyUnit.USD)); - assertThat(posting(entry, fixture.target()).getForexAmount(), is(Values.Amount.factorize(100))); - assertThat(posting(entry, fixture.target()).getForexCurrency(), is(CurrencyUnit.EUR)); - assertThat(posting(entry, fixture.target()).getExchangeRate().compareTo(ExchangeRate.inverse(EXCHANGE_RATE)), - is(0)); - - assertThat(fixture.source().getTransactions(), is(List.of(reversedTarget))); - assertThat(fixture.target().getTransactions(), is(List.of(reversedSource))); - assertThat(reversedSource.getUUID(), is(sourceProjectionUUID)); - assertThat(reversedTarget.getUUID(), is(targetProjectionUUID)); - assertThat(reversedSource.getType(), is(AccountTransaction.Type.TRANSFER_OUT)); - assertThat(reversedTarget.getType(), is(AccountTransaction.Type.TRANSFER_IN)); - assertThat(reversedSource.getAmount(), is(Values.Amount.factorize(200))); - assertThat(reversedSource.getCurrencyCode(), is(CurrencyUnit.USD)); - assertThat(reversedTarget.getAmount(), is(Values.Amount.factorize(100))); - assertThat(reversedTarget.getCurrencyCode(), is(CurrencyUnit.EUR)); - assertThat(reversedSource.getDateTime(), is(DATE_TIME)); - assertThat(reversedSource.getNote(), is("note")); - assertThat(reversedSource.getSource(), is("source")); - assertThat(fixture.client().getAllTransactions().size(), is(1)); - assertSame(reversedSource, fixture.client().getAllTransactions().get(0).getTransaction()); - assertAccountCrossEntry(reversedSource, reversedTarget, fixture.target(), fixture.source()); - assertThat(new LedgerAccountTransferTransactionCreator(fixture.client()).getSourceExchangeRate(reversed) - .orElseThrow().compareTo(EXCHANGE_RATE), is(0)); - assertValid(fixture.client()); - - assertAccountTransferRoundtrip(loadXml(saveXml(fixture.client())), entryUUID, sourcePostingUUID, - targetPostingUUID, sourceProjectionUUID, targetProjectionUUID); - assertAccountTransferRoundtrip(loadProtobuf(saveProtobuf(fixture.client())), entryUUID, sourcePostingUUID, - targetPostingUUID, sourceProjectionUUID, targetProjectionUUID); - } - - /** - * Verifies that a portfolio transfer can be reversed without creating a new booking. - * Source and target depots must swap while shares, security, and projections remain consistent after save/load. - */ - @Test - public void testReversesLedgerBackedPortfolioTransferPreservingIdentityAndTruth() throws Exception - { - var fixture = portfolioFixture(); - var transfer = createPortfolioTransfer(fixture); - var entry = fixture.client().getLedger().getEntries().get(0); - var entryUUID = entry.getUUID(); - var sourcePostingUUID = posting(entry, fixture.source()).getUUID(); - var targetPostingUUID = posting(entry, fixture.target()).getUUID(); - var sourceProjectionUUID = projection(entry, LedgerProjectionRole.SOURCE_PORTFOLIO).getRuntimeProjectionId(); - var targetProjectionUUID = projection(entry, LedgerProjectionRole.TARGET_PORTFOLIO).getRuntimeProjectionId(); - - var reversed = converter(fixture.client()).reverse(transfer); - var reversedSource = reversed.getSourceTransaction(); - var reversedTarget = reversed.getTargetTransaction(); - - assertThat(entry.getUUID(), is(entryUUID)); - assertThat(entry.getType(), is(LedgerEntryType.SECURITY_TRANSFER)); - assertThat(posting(entry, fixture.source()).getUUID(), is(sourcePostingUUID)); - assertThat(posting(entry, fixture.target()).getUUID(), is(targetPostingUUID)); - assertThat(projection(entry, LedgerProjectionRole.SOURCE_PORTFOLIO).getRuntimeProjectionId(), is(sourceProjectionUUID)); - assertThat(projection(entry, LedgerProjectionRole.TARGET_PORTFOLIO).getRuntimeProjectionId(), is(targetProjectionUUID)); - assertSame(fixture.target(), projection(entry, LedgerProjectionRole.SOURCE_PORTFOLIO).getPortfolio()); - assertSame(fixture.source(), projection(entry, LedgerProjectionRole.TARGET_PORTFOLIO).getPortfolio()); - assertSame(fixture.source(), posting(entry, fixture.source()).getPortfolio()); - assertSame(fixture.target(), posting(entry, fixture.target()).getPortfolio()); - assertSame(fixture.security(), posting(entry, fixture.source()).getSecurity()); - assertSame(fixture.security(), posting(entry, fixture.target()).getSecurity()); - assertThat(posting(entry, fixture.source()).getShares(), is(Values.Share.factorize(5))); - assertThat(posting(entry, fixture.target()).getShares(), is(Values.Share.factorize(5))); - assertThat(posting(entry, fixture.source()).getAmount(), is(Values.Amount.factorize(100))); - assertThat(posting(entry, fixture.target()).getAmount(), is(Values.Amount.factorize(100))); - assertThat(posting(entry, fixture.source()).getCurrency(), is(CurrencyUnit.EUR)); - assertThat(posting(entry, fixture.target()).getCurrency(), is(CurrencyUnit.EUR)); - - assertThat(fixture.source().getTransactions(), is(List.of(reversedTarget))); - assertThat(fixture.target().getTransactions(), is(List.of(reversedSource))); - assertThat(reversedSource.getUUID(), is(sourceProjectionUUID)); - assertThat(reversedTarget.getUUID(), is(targetProjectionUUID)); - assertThat(reversedSource.getType(), is(PortfolioTransaction.Type.TRANSFER_OUT)); - assertThat(reversedTarget.getType(), is(PortfolioTransaction.Type.TRANSFER_IN)); - assertSame(fixture.security(), reversedSource.getSecurity()); - assertSame(fixture.security(), reversedTarget.getSecurity()); - assertThat(reversedSource.getShares(), is(Values.Share.factorize(5))); - assertThat(reversedTarget.getShares(), is(Values.Share.factorize(5))); - assertThat(reversedSource.getDateTime(), is(DATE_TIME)); - assertThat(reversedSource.getNote(), is("note")); - assertThat(reversedSource.getSource(), is("source")); - assertThat(fixture.client().getAllTransactions().size(), is(1)); - assertSame(reversedSource, fixture.client().getAllTransactions().get(0).getTransaction()); - assertPortfolioCrossEntry(reversedSource, reversedTarget, fixture.target(), fixture.source()); - assertValid(fixture.client()); - - assertPortfolioTransferRoundtrip(loadXml(saveXml(fixture.client())), entryUUID, sourcePostingUUID, - targetPostingUUID, sourceProjectionUUID, targetProjectionUUID); - assertPortfolioTransferRoundtrip(loadProtobuf(saveProtobuf(fixture.client())), entryUUID, sourcePostingUUID, - targetPostingUUID, sourceProjectionUUID, targetProjectionUUID); - } - - /** - * Verifies that a malformed account transfer is rejected before reversal. - * The converter must not guess a missing transfer side from the remaining projection. - */ - @Test - public void testMalformedAccountTransferRejectsBeforeMutation() - { - var fixture = accountFixture(); - var transfer = createAccountTransfer(fixture); - var entry = fixture.client().getLedger().getEntries().get(0); - var projection = projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT); - - projection.getPrimaryPosting().setAccount(null); - var snapshot = Snapshot.capture(fixture.client()); - - assertThrows(IllegalArgumentException.class, () -> converter(fixture.client()).reverse(transfer)); - - assertThat(Snapshot.capture(fixture.client()), is(snapshot)); - } - - /** - * Verifies that an account transfer without its posting is rejected before reversal. - * Missing cash facts must not be reconstructed from owner-list projections. - */ - @Test - public void testAccountTransferMissingPostingRejectsBeforeMutation() - { - var fixture = accountFixture(); - var transfer = createAccountTransfer(fixture); - var entry = fixture.client().getLedger().getEntries().get(0); - var posting = posting(entry, fixture.source()); - - entry.removePosting(posting); - var snapshot = Snapshot.capture(fixture.client()); - - assertThrows(IllegalArgumentException.class, () -> converter(fixture.client()).reverse(transfer)); - - assertThat(Snapshot.capture(fixture.client()), is(snapshot)); - } - - /** - * Verifies that a plan-generated account transfer can be reversed safely. - * The plan reference must follow the same generated booking after the source and target sides swap. - */ - @Test - public void testInvestmentPlanReferencedAccountTransferReversesAndUpdatesPlanReference() throws Exception - { - var fixture = accountFixture(); - var transfer = createAccountTransfer(fixture); - var plan = new InvestmentPlan("Plan"); - var entry = fixture.client().getLedger().getEntries().get(0); - entry.setGeneratedByPlanKey(plan.getPlanKey()); - entry.setPlanExecutionDate(DATE_TIME.toLocalDate()); - entry.setPreferredViewKind(InvestmentPlan.LedgerExecutionViewKind.ACCOUNT.name()); - fixture.client().addPlan(plan); - - converter(fixture.client()).reverse(transfer); - - assertThat(plan.getLedgerExecutionRefs(), is(List.of())); - assertThat(entry.getGeneratedByPlanKey(), is(plan.getPlanKey())); - assertThat(entry.getPlanExecutionDate(), is(DATE_TIME.toLocalDate())); - assertThat(entry.getPreferredViewKind(), is(InvestmentPlan.LedgerExecutionViewKind.ACCOUNT.name())); - - var loaded = loadXml(saveXml(fixture.client())); - assertThat(loaded.getLedger().getEntries().get(0).getGeneratedByPlanKey(), - is(loaded.getPlans().get(0).getPlanKey())); - } - - /** - * Verifies that a malformed portfolio transfer is rejected before reversal. - * The converter must not guess a missing depot side from the remaining projection. - */ - @Test - public void testMalformedPortfolioTransferRejectsBeforeMutation() - { - var fixture = portfolioFixture(); - var transfer = createPortfolioTransfer(fixture); - var entry = fixture.client().getLedger().getEntries().get(0); - var projection = projection(entry, LedgerProjectionRole.TARGET_PORTFOLIO); - - projection.getPrimaryPosting().setPortfolio(null); - var snapshot = Snapshot.capture(fixture.client()); - - assertThrows(IllegalArgumentException.class, () -> converter(fixture.client()).reverse(transfer)); - - assertThat(Snapshot.capture(fixture.client()), is(snapshot)); - } - - /** - * Verifies that a portfolio transfer without its security posting is rejected before reversal. - * Missing share and security facts must not be reconstructed from owner-list projections. - */ - @Test - public void testPortfolioTransferMissingPostingRejectsBeforeMutation() - { - var fixture = portfolioFixture(); - var transfer = createPortfolioTransfer(fixture); - var entry = fixture.client().getLedger().getEntries().get(0); - var posting = posting(entry, fixture.target()); - - entry.removePosting(posting); - var snapshot = Snapshot.capture(fixture.client()); - - assertThrows(IllegalArgumentException.class, () -> converter(fixture.client()).reverse(transfer)); - - assertThat(Snapshot.capture(fixture.client()), is(snapshot)); - } - - /** - * Verifies that a plan-generated portfolio transfer can be reversed safely. - * The plan reference must follow the same generated booking after the depot sides swap. - */ - @Test - public void testInvestmentPlanReferencedPortfolioTransferReversesAndUpdatesPlanReference() throws Exception - { - var fixture = portfolioFixture(); - var transfer = createPortfolioTransfer(fixture); - var plan = new InvestmentPlan("Plan"); - var entry = fixture.client().getLedger().getEntries().get(0); - entry.setGeneratedByPlanKey(plan.getPlanKey()); - entry.setPlanExecutionDate(DATE_TIME.toLocalDate()); - entry.setPreferredViewKind(InvestmentPlan.LedgerExecutionViewKind.PORTFOLIO.name()); - fixture.client().addPlan(plan); - - converter(fixture.client()).reverse(transfer); - - assertThat(plan.getLedgerExecutionRefs(), is(List.of())); - assertThat(entry.getGeneratedByPlanKey(), is(plan.getPlanKey())); - assertThat(entry.getPlanExecutionDate(), is(DATE_TIME.toLocalDate())); - assertThat(entry.getPreferredViewKind(), is(InvestmentPlan.LedgerExecutionViewKind.PORTFOLIO.name())); - - var loaded = loadXml(saveXml(fixture.client())); - assertThat(loaded.getLedger().getEntries().get(0).getGeneratedByPlanKey(), - is(loaded.getPlans().get(0).getPlanKey())); - } - - private void assertAccountTransferRoundtrip(Client client, String entryUUID, String sourcePostingUUID, - String targetPostingUUID, String sourceProjectionUUID, String targetProjectionUUID) - { - var source = client.getAccounts().get(0); - var target = client.getAccounts().get(1); - var entry = client.getLedger().getEntries().get(0); - var sourceTransaction = target.getTransactions().get(0); - var targetTransaction = source.getTransactions().get(0); - - assertThat(projection(entry, LedgerProjectionRole.SOURCE_ACCOUNT).getAccount(), is(target)); - assertThat(projection(entry, LedgerProjectionRole.TARGET_ACCOUNT).getAccount(), is(source)); - assertThat(((LedgerBackedTransaction) sourceTransaction).getLedgerProjectionRole(), is(LedgerProjectionRole.SOURCE_ACCOUNT)); - assertThat(((LedgerBackedTransaction) targetTransaction).getLedgerProjectionRole(), is(LedgerProjectionRole.TARGET_ACCOUNT)); - assertThat(sourceTransaction.getType(), is(AccountTransaction.Type.TRANSFER_OUT)); - assertThat(targetTransaction.getType(), is(AccountTransaction.Type.TRANSFER_IN)); - assertAccountCrossEntry(sourceTransaction, targetTransaction, target, source); - assertValid(client); - } - - private void assertPortfolioTransferRoundtrip(Client client, String entryUUID, String sourcePostingUUID, - String targetPostingUUID, String sourceProjectionUUID, String targetProjectionUUID) - { - var source = client.getPortfolios().get(0); - var target = client.getPortfolios().get(1); - var entry = client.getLedger().getEntries().get(0); - var sourceTransaction = target.getTransactions().get(0); - var targetTransaction = source.getTransactions().get(0); - - assertThat(projection(entry, LedgerProjectionRole.SOURCE_PORTFOLIO).getPortfolio(), is(target)); - assertThat(projection(entry, LedgerProjectionRole.TARGET_PORTFOLIO).getPortfolio(), is(source)); - assertThat(((LedgerBackedTransaction) sourceTransaction).getLedgerProjectionRole(), is(LedgerProjectionRole.SOURCE_PORTFOLIO)); - assertThat(((LedgerBackedTransaction) targetTransaction).getLedgerProjectionRole(), is(LedgerProjectionRole.TARGET_PORTFOLIO)); - assertThat(sourceTransaction.getType(), is(PortfolioTransaction.Type.TRANSFER_OUT)); - assertThat(targetTransaction.getType(), is(PortfolioTransaction.Type.TRANSFER_IN)); - assertPortfolioCrossEntry(sourceTransaction, targetTransaction, target, source); - assertValid(client); - } - - private void assertAccountCrossEntry(AccountTransaction sourceTransaction, AccountTransaction targetTransaction, - Account source, Account target) - { - assertThat(sourceTransaction.getCrossEntry(), instanceOf(AccountTransferEntry.class)); - assertThat(targetTransaction.getCrossEntry(), instanceOf(AccountTransferEntry.class)); - assertSame(targetTransaction, sourceTransaction.getCrossEntry().getCrossTransaction(sourceTransaction)); - assertSame(sourceTransaction, targetTransaction.getCrossEntry().getCrossTransaction(targetTransaction)); - assertSame(target, sourceTransaction.getCrossEntry().getCrossOwner(sourceTransaction)); - assertSame(source, targetTransaction.getCrossEntry().getCrossOwner(targetTransaction)); - } - - private void assertPortfolioCrossEntry(PortfolioTransaction sourceTransaction, - PortfolioTransaction targetTransaction, Portfolio source, Portfolio target) - { - assertThat(sourceTransaction.getCrossEntry(), instanceOf(PortfolioTransferEntry.class)); - assertThat(targetTransaction.getCrossEntry(), instanceOf(PortfolioTransferEntry.class)); - assertSame(targetTransaction, sourceTransaction.getCrossEntry().getCrossTransaction(sourceTransaction)); - assertSame(sourceTransaction, targetTransaction.getCrossEntry().getCrossTransaction(targetTransaction)); - assertSame(target, sourceTransaction.getCrossEntry().getCrossOwner(sourceTransaction)); - assertSame(source, targetTransaction.getCrossEntry().getCrossOwner(targetTransaction)); - } - - private LedgerTransferDirectionConverter converter(Client client) - { - return new LedgerTransferDirectionConverter(client); - } - - private AccountTransferEntry createAccountTransfer(AccountFixture fixture) - { - return new LedgerAccountTransferTransactionCreator(fixture.client()).create(fixture.source(), fixture.target(), - DATE_TIME, Values.Amount.factorize(100), CurrencyUnit.EUR, Values.Amount.factorize(200), - CurrencyUnit.USD, Money.of(CurrencyUnit.USD, Values.Amount.factorize(200)), EXCHANGE_RATE, - "note", "source"); - } - - private PortfolioTransferEntry createPortfolioTransfer(PortfolioFixture fixture) - { - return new LedgerPortfolioTransferTransactionCreator(fixture.client()).create(fixture.source(), - fixture.target(), fixture.security(), DATE_TIME, Values.Share.factorize(5), - Values.Amount.factorize(100), CurrencyUnit.EUR, "note", "source"); - } - - private name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor projection(LedgerEntry entry, LedgerProjectionRole role) - { - return name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream().filter(projection -> projection.getRole() == role).findFirst() - .orElseThrow(); - } - - private LedgerPosting posting(LedgerEntry entry, Account account) - { - return entry.getPostings().stream().filter(posting -> posting.getType() == LedgerPostingType.CASH) - .filter(posting -> posting.getAccount() == account).findFirst().orElseThrow(); - } - - private LedgerPosting posting(LedgerEntry entry, Portfolio portfolio) - { - return entry.getPostings().stream().filter(posting -> posting.getType() == LedgerPostingType.SECURITY) - .filter(posting -> posting.getPortfolio() == portfolio).findFirst().orElseThrow(); - } - - private String saveXml(Client client) throws IOException - { - var file = File.createTempFile("ledger-transfer-direction", ".xml"); - try - { - ClientFactory.save(client, file); - return Files.readString(file.toPath(), StandardCharsets.UTF_8); - } - finally - { - Files.deleteIfExists(file.toPath()); - } - } - - private Client loadXml(String xml) throws IOException - { - return ClientFactory.load(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); - } - - private byte[] saveProtobuf(Client client) throws IOException - { - return ProtobufTestUtilities.save(client); - } - - private Client loadProtobuf(byte[] bytes) throws IOException - { - return ProtobufTestUtilities.load(bytes); - } - - private void assertValid(Client client) - { - var result = LedgerStructuralValidator.validate(client.getLedger()); - - assertThat(result.format(), result.isOK(), is(true)); - } - - private AccountFixture accountFixture() - { - var client = new Client(); - var source = account("Source", CurrencyUnit.EUR); - var target = account("Target", CurrencyUnit.USD); - - client.addAccount(source); - client.addAccount(target); - - return new AccountFixture(client, source, target); - } - - private PortfolioFixture portfolioFixture() - { - var client = new Client(); - var source = new Portfolio("Source"); - var target = new Portfolio("Target"); - var security = new Security("Security", CurrencyUnit.EUR); - - source.setUpdatedAt(Instant.now()); - target.setUpdatedAt(Instant.now()); - security.setUpdatedAt(Instant.now()); - client.addPortfolio(source); - client.addPortfolio(target); - client.addSecurity(security); - - return new PortfolioFixture(client, source, target, security); - } - - private Account account(String name, String currency) - { - var account = new Account(name); - - account.setCurrencyCode(currency); - - return account; - } - - private record AccountFixture(Client client, Account source, Account target) - { - } - - private record PortfolioFixture(Client client, Portfolio source, Portfolio target, Security security) - { - } - - private record Snapshot(List entries, List accountTransactions, - List portfolioTransactions, List allTransactions) - { - static Snapshot capture(Client client) - { - return new Snapshot(client.getLedger().getEntries().stream().map(EntrySnapshot::capture).toList(), - client.getAccounts().stream().flatMap(account -> account.getTransactions().stream()) - .map(Transaction::getUUID).toList(), - client.getPortfolios().stream().flatMap(portfolio -> portfolio.getTransactions().stream()) - .map(Transaction::getUUID).toList(), - client.getAllTransactions().stream().map(pair -> pair.getTransaction().getUUID()) - .toList()); - } - } - - private record EntrySnapshot(String uuid, LedgerEntryType type, List postings, - List projections) - { - static EntrySnapshot capture(LedgerEntry entry) - { - List projections; - try - { - projections = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry) - .stream().map(ProjectionSnapshot::capture).toList(); - } - catch (IllegalArgumentException e) - { - projections = List.of(); - } - - return new EntrySnapshot(entry.getUUID(), entry.getType(), - entry.getPostings().stream().map(PostingSnapshot::capture).toList(), - projections); - } - } - - private record PostingSnapshot(String uuid, LedgerPostingType type, Long amount, String currency, Long forexAmount, - String forexCurrency, BigDecimal exchangeRate, Security security, Long shares, Account account, - Portfolio portfolio) - { - static PostingSnapshot capture(LedgerPosting posting) - { - return new PostingSnapshot(posting.getUUID(), posting.getType(), posting.getAmount(), posting.getCurrency(), - posting.getForexAmount(), posting.getForexCurrency(), posting.getExchangeRate(), - posting.getSecurity(), posting.getShares(), posting.getAccount(), posting.getPortfolio()); - } - } - - private record ProjectionSnapshot(String uuid, LedgerProjectionRole role, Account account, Portfolio portfolio, - String primaryPostingId, String groupKey) - { - static ProjectionSnapshot capture(name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor projection) - { - return new ProjectionSnapshot(projection.getRuntimeProjectionId(), projection.getRole(), projection.getAccount(), - projection.getPortfolio(), projection.getPrimaryPosting().getUUID(), - projection.getPrimaryPosting().getGroupKey()); - } - } -} - diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblerTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblerTest.java deleted file mode 100644 index d1af4981a9..0000000000 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssemblerTest.java +++ /dev/null @@ -1,746 +0,0 @@ -package name.abuchen.portfolio.model.ledger.nativeentry; - -import static org.hamcrest.CoreMatchers.containsString; -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.CoreMatchers.notNullValue; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; - -import java.math.BigDecimal; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.Collection; -import java.util.Optional; -import java.util.stream.Collectors; - -import org.junit.Test; - -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.ledger.Ledger; -import name.abuchen.portfolio.model.ledger.LedgerParameter; -import name.abuchen.portfolio.model.ledger.LedgerPosting; -import name.abuchen.portfolio.model.ledger.LedgerPostingDirection; -import name.abuchen.portfolio.model.ledger.LedgerPostingSemanticRole; -import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; -import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; -import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; -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.EventStage; -import name.abuchen.portfolio.model.ledger.configuration.FeeReason; -import name.abuchen.portfolio.model.ledger.configuration.FractionTreatment; -import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinitionRegistry; -import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; -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.LedgerReportingClass; -import name.abuchen.portfolio.model.ledger.configuration.RoundingModeCode; -import name.abuchen.portfolio.model.ledger.configuration.TaxReason; -import name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptor; -import name.abuchen.portfolio.model.ledger.projection.DerivedProjectionDescriptorService; -import name.abuchen.portfolio.model.ledger.projection.LedgerBackedTransaction; -import name.abuchen.portfolio.money.CurrencyUnit; -import name.abuchen.portfolio.money.Money; -import name.abuchen.portfolio.money.Values; - -/** - * Tests ledger-native entry assembly for advanced transaction shapes. - * These tests make sure structural facts can be represented without enabling unsupported UI workflows. - */ -@SuppressWarnings("nls") -public class LedgerNativeEntryAssemblerTest -{ - /** - * Checks the Ledger-V6 scenario: rejects legacy fixed shape entry type. - * The result must keep ledger truth and visible runtime rows consistent. - * This protects against duplicate truth or partial mutation. - */ - @Test - public void testRejectsLegacyFixedShapeEntryType() - { - var exception = assertThrows(LedgerNativeEntryAssemblyException.class, - () -> LedgerNativeEntryAssembler.forClient(new Client()).forType(LedgerEntryType.BUY)); - - assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.ENTRY_TYPE_NOT_NATIVE)); - assertThat(exception.getMessage(), containsString("Use LedgerTransactionCreator for standard transaction families")); - } - - /** - * Checks the Ledger-V6 scenario: for type accepts every defined ledger native entry type. - * The result must keep ledger truth and visible runtime rows consistent. - * This protects against duplicate truth or partial mutation. - */ - @Test - public void testForTypeAcceptsEveryDefinedLedgerNativeEntryType() - { - var assembler = LedgerNativeEntryAssembler.forClient(new Client()); - - for (var definition : LedgerEntryDefinitionRegistry.getDefinitions()) - { - assertTrue(definition.getEntryType().isLedgerNativeTargeted()); - assertThat(assembler.forType(definition.getEntryType()), is(notNullValue())); - } - } - - /** - * Checks the Ledger-V6 scenario: for type rejects every legacy fixed shape entry type. - * The result must keep ledger truth and visible runtime rows consistent. - * This protects against duplicate truth or partial mutation. - */ - @Test - public void testForTypeRejectsEveryLegacyFixedShapeEntryType() - { - var assembler = LedgerNativeEntryAssembler.forClient(new Client()); - - for (var entryType : LedgerEntryType.values()) - { - if (entryType.isLegacyFixedShape()) - { - var exception = assertThrows(LedgerNativeEntryAssemblyException.class, - () -> assembler.forType(entryType)); - - assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.ENTRY_TYPE_NOT_NATIVE)); - } - } - } - - /** - * Checks the Ledger-V6 scenario: spin off is convenience for for type. - * The result must keep ledger truth and visible runtime rows consistent. - * This protects against duplicate truth or partial mutation. - */ - @Test - public void testSpinOffIsConvenienceForForType() - { - var fixture = fixture(); - - assertThat(LedgerNativeEntryAssembler.forClient(fixture.client).spinOff(), is(notNullValue())); - assertThat(LedgerNativeEntryAssembler.forClient(fixture.client).forType(LedgerEntryType.CORPORATE_ACTION), - is(notNullValue())); - } - - /** - * Checks the Ledger-V6 scenario: definition registry schemas are readable by assembler consumers. - * The result must keep ledger truth and visible runtime rows consistent. - * This protects against duplicate truth or partial mutation. - */ - @Test - public void testDefinitionRegistrySchemasAreReadableByAssemblerConsumers() - { - for (var definition : LedgerEntryDefinitionRegistry.getDefinitions()) - { - assertThat(definition.getEntryType(), is(notNullValue())); - assertThat(definition.getPostingRules().isEmpty(), is(false)); - assertThat(definition.getEntryParameterRules().isEmpty(), is(false)); - assertThat(definition.getPostingParameterRules().isEmpty(), is(false)); - assertThat(definition.getReportingClass() != LedgerReportingClass.UNDEFINED, is(true)); - assertThat(definition.getPerformanceTreatment() != LedgerPerformanceTreatment.UNDEFINED, is(true)); - assertThat(definition.getDownstreamResultsNotPersisted().isEmpty(), is(false)); - } - } - - /** - * Checks the Ledger-V6 scenario: rejects missing entry definition. - * The result must keep ledger truth and visible runtime rows consistent. - * This protects against duplicate truth or partial mutation. - */ - @Test - public void testRejectsMissingEntryDefinition() - { - var exception = assertThrows(LedgerNativeEntryAssemblyException.class, - () -> new LedgerNativeEntryAssembler(new Client(), type -> Optional.empty()) - .forType(LedgerEntryType.CORPORATE_ACTION)); - - assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.ENTRY_DEFINITION_MISSING)); - } - - /** - * Checks the Ledger-V6 scenario: rejects posting type not in entry definition. - * The result must keep ledger truth and visible runtime rows consistent. - * This protects against duplicate truth or partial mutation. - */ - @Test - public void testRejectsPostingTypeNotInEntryDefinition() - { - var fixture = fixture(); - var invalidLeg = NativeSecurityLeg.ofType(LedgerPostingType.BOND) // - .portfolio(fixture.portfolio) // - .security(fixture.siemens) // - .shares(Values.Share.factorize(1)) // - .amount(money(1)) // - .build(); - - var exception = assertThrows(LedgerNativeEntryAssemblyException.class, - () -> baseSpinOff(fixture).securityLeg(invalidLeg).buildDetached()); - - assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.POSTING_TYPE_NOT_IN_ENTRY_DEFINITION)); - } - - /** - * Checks the Ledger-V6 scenario: rejects posting type outside each entry definition. - * The result must keep ledger truth and visible runtime rows consistent. - * This protects against duplicate truth or partial mutation. - */ - @Test - public void testRejectsPostingTypeOutsideEachEntryDefinition() - { - var fixture = fixture(); - - for (var definition : LedgerEntryDefinitionRegistry.getDefinitions()) - { - if (definition.getProjectionRules().isEmpty()) - continue; - - var invalidPostingType = java.util.Arrays.stream(LedgerPostingType.values()) // - .filter(postingType -> !definition.getPostingTypes().contains(postingType)) // - .findFirst().orElseThrow(); - var invalidLeg = NativeSecurityLeg.ofType(invalidPostingType) // - .portfolio(fixture.portfolio) // - .security(fixture.siemens) // - .shares(Values.Share.factorize(1)) // - .amount(money(1)) // - .build(); - - var exception = assertThrows(LedgerNativeEntryAssemblyException.class, - () -> LedgerNativeEntryAssembler.forClient(fixture.client) - .forType(definition.getEntryType()) // - .metadata(metadata()) // - .event(event()) // - .securityLeg(invalidLeg) // - .buildDetached()); - - assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.POSTING_TYPE_NOT_IN_ENTRY_DEFINITION)); - } - } - - /** - * Checks the Ledger-V6 scenario: rejects entry parameter not allowed by entry definition. - * The result must keep ledger truth and visible runtime rows consistent. - * This protects against duplicate truth or partial mutation. - */ - @Test - public void testRejectsEntryParameterNotAllowedByEntryDefinition() - { - var fixture = fixture(); - var event = NativeCorporateActionEvent.builder() // - .kind(CorporateActionKind.SPIN_OFF) // - .parameter(LedgerParameterType.SOURCE_ACCOUNT, fixture.account) // - .build(); - - var exception = assertThrows(LedgerNativeEntryAssemblyException.class, - () -> baseSpinOff(fixture).event(event).buildDetached()); - - assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.PARAMETER_NOT_IN_ENTRY_DEFINITION)); - } - - /** - * Checks the Ledger-V6 scenario: rejects posting parameter not meaningful for posting type definition. - * The result must keep ledger truth and visible runtime rows consistent. - * This protects against duplicate truth or partial mutation. - */ - @Test - public void testRejectsPostingParameterNotMeaningfulForPostingTypeDefinition() - { - var fixture = fixture(); - var sourceLeg = sourceLeg(fixture).parameter(LedgerParameterType.CASH_ACCOUNT, fixture.account).build(); - - var exception = assertThrows(LedgerNativeEntryAssemblyException.class, - () -> baseSpinOff(fixture).securityLeg(sourceLeg).buildDetached()); - - assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.PARAMETER_NOT_IN_POSTING_TYPE_DEFINITION)); - } - - /** - * Checks the Ledger-V6 scenario: rejects projection role not allowed by entry definition. - * The result must keep ledger truth and visible runtime rows consistent. - * This protects against duplicate truth or partial mutation. - */ - @Test - public void testRejectsProjectionRoleNotAllowedByEntryDefinition() - { - var fixture = fixture(); - var targetLeg = targetLeg(fixture).projectAs(LedgerProjectionRole.SOURCE_ACCOUNT).build(); - - var exception = assertThrows(LedgerNativeEntryAssemblyException.class, - () -> baseSpinOff(fixture).securityLeg(targetLeg).buildDetached()); - - assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.PROJECTION_ROLE_NOT_IN_ENTRY_DEFINITION)); - } - - /** - * Checks the Ledger-V6 scenario: rejects wrong value kind carrier. - * The result must keep ledger truth and visible runtime rows consistent. - * This protects against duplicate truth or partial mutation. - */ - @Test - public void testRejectsWrongValueKindCarrier() - { - var fixture = fixture(); - var event = NativeCorporateActionEvent.builder() // - .kind(CorporateActionKind.SPIN_OFF) // - .parameter(LedgerParameterType.EFFECTIVE_DATE, "2020-09-28") // - .build(); - - var exception = assertThrows(LedgerNativeEntryAssemblyException.class, - () -> baseSpinOff(fixture).event(event).buildDetached()); - - assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.VALUE_KIND_MISMATCH)); - } - - /** - * Checks the Ledger-V6 scenario: rejects invalid code domain value. - * The result must keep ledger truth and visible runtime rows consistent. - * This protects against duplicate truth or partial mutation. - */ - @Test - public void testRejectsInvalidCodeDomainValue() - { - var fixture = fixture(); - var event = NativeCorporateActionEvent.builder().kind("SOURCE").build(); - - var exception = assertThrows(LedgerNativeEntryAssemblyException.class, - () -> baseSpinOff(fixture).event(event).buildDetached()); - - assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.PARAMETER_CODE_NOT_ALLOWED)); - } - - /** - * Checks the Ledger-V6 scenario: build detached does not mutate client. - * The result must keep ledger truth and visible runtime rows consistent. - * This protects against duplicate truth or partial mutation. - */ - @Test - public void testBuildDetachedDoesNotMutateClient() - { - var fixture = fixture(); - - validSpinOff(fixture).buildDetached(); - - assertThat(fixture.client.getLedger().getEntries().size(), is(0)); - assertThat(fixture.account.getTransactions().size(), is(0)); - assertThat(fixture.portfolio.getTransactions().size(), is(0)); - } - - @Test - public void testNativeAssemblerRepeatedTargetDuplicateLocalKeyIsRejected() - { - var fixture = fixture(); - var secondTarget = new Security("Siemens Healthineers AG", CurrencyUnit.EUR); - fixture.client.addSecurity(secondTarget); - - var exception = assertThrows(LedgerNativeEntryAssemblyException.class, - () -> baseSpinOff(fixture) // - .securityLeg(sourceLeg(fixture).build()) // - .securityLeg(targetLeg(fixture).groupKey("main").localKey("target-1").build()) // - .securityLeg(targetLeg(fixture).security(secondTarget) - .targetSecurity(secondTarget).groupKey("main") - .localKey("target-1").build()) // - .buildDetached()); - - assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.NATIVE_DEFINITION_VALIDATION_FAILED)); - } - - @Test - public void testNativeAssemblerRepeatedTargetBlankLocalKeyIsRejected() - { - var fixture = fixture(); - var secondTarget = new Security("Siemens Healthineers AG", CurrencyUnit.EUR); - fixture.client.addSecurity(secondTarget); - - var exception = assertThrows(LedgerNativeEntryAssemblyException.class, - () -> baseSpinOff(fixture) // - .securityLeg(sourceLeg(fixture).build()) // - .securityLeg(targetLeg(fixture).groupKey("main").localKey("target-1").build()) // - .securityLeg(targetLeg(fixture).security(secondTarget) - .targetSecurity(secondTarget).groupKey("main") - .localKey(" ").build()) // - .buildDetached()); - - assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.NATIVE_DEFINITION_VALIDATION_FAILED)); - } - - @Test - public void testNativeAssemblerRepeatedCashDuplicateLocalKeyIsRejected() - { - var fixture = fixture(); - - var exception = assertThrows(LedgerNativeEntryAssemblyException.class, - () -> baseSpinOff(fixture) // - .securityLeg(sourceLeg(fixture).build()) // - .securityLeg(targetLeg(fixture).build()) // - .cashCompensation(cashCompensation(fixture, "cash-1", "cash-1", 5)) // - .cashCompensationMovement(cashCompensation(fixture, "cash-2", "cash-1", 7)) // - .buildDetached()); - - assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.NATIVE_DEFINITION_VALIDATION_FAILED)); - } - - /** - * Checks the Ledger-V6 scenario: build and add invalid code domain leaves client unchanged. - * The result must keep ledger truth and visible runtime rows consistent. - * This protects against duplicate truth or partial mutation. - */ - @Test - public void testBuildAndAddInvalidCodeDomainLeavesClientUnchanged() - { - var fixture = fixture(); - var event = NativeCorporateActionEvent.builder().kind("SOURCE").build(); - - var exception = assertThrows(LedgerNativeEntryAssemblyException.class, - () -> baseSpinOff(fixture).event(event).buildAndAdd()); - - assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.PARAMETER_CODE_NOT_ALLOWED)); - assertClientUnchanged(fixture); - } - - /** - * Checks the Ledger-V6 scenario: build and add invalid posting type leaves client unchanged. - * The result must keep ledger truth and visible runtime rows consistent. - * This protects against duplicate truth or partial mutation. - */ - @Test - public void testBuildAndAddInvalidPostingTypeLeavesClientUnchanged() - { - var fixture = fixture(); - var invalidLeg = NativeSecurityLeg.ofType(LedgerPostingType.BOND) // - .portfolio(fixture.portfolio) // - .security(fixture.siemens) // - .shares(Values.Share.factorize(1)) // - .amount(money(1)) // - .build(); - - var exception = assertThrows(LedgerNativeEntryAssemblyException.class, - () -> baseSpinOff(fixture).securityLeg(invalidLeg).buildAndAdd()); - - assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.POSTING_TYPE_NOT_IN_ENTRY_DEFINITION)); - assertClientUnchanged(fixture); - } - - /** - * Checks the Ledger-V6 scenario: build and add structural validation failure leaves client unchanged. - * The result must keep ledger truth and visible runtime rows consistent. - * This protects against duplicate truth or partial mutation. - */ - @Test - public void testBuildAndAddStructuralValidationFailureLeavesClientUnchanged() - { - var fixture = fixture(); - var invalidSourceLeg = sourceLeg(fixture).portfolio(null).build(); - - var exception = assertThrows(LedgerNativeEntryAssemblyException.class, - () -> baseSpinOff(fixture).securityLeg(invalidSourceLeg).buildAndAdd()); - - assertThat(exception.getIssue(), is(LedgerNativeEntryAssemblyIssue.NATIVE_DEFINITION_VALIDATION_FAILED)); - assertClientUnchanged(fixture); - } - - private static LedgerNativeEntryAssembler.EntryBuilder validSpinOff(Fixture fixture) - { - return baseSpinOff(fixture) // - .securityLeg(sourceLeg(fixture).build()) // - .securityLeg(contextLeg(fixture).build()) // - .securityLeg(targetLeg(fixture).build()) // - .cashCompensation(NativeCashCompensation.builder() // - .account(fixture.account) // - .amount(money(5)) // - .kind(CashCompensationKind.CASH_IN_LIEU) // - .applied(true) // - .fractionQuantity(new BigDecimal("0.5")) // - .fractionTreatment(FractionTreatment.CASH_IN_LIEU) // - .roundingMode(RoundingModeCode.FLOOR) // - .build()) // - .fee(NativeFee.of(fixture.account, money(2), - FeeReason.CORPORATE_ACTION_FEE)) // - .tax(NativeTax.builder() // - .account(fixture.account) // - .amount(money(1)) // - .reason(TaxReason.WITHHOLDING_TAX) // - .taxableDistribution(true) // - .withholdingTax(true) // - .transactionTax(false) // - .reclaimableTax(false) // - .build()); - } - - private static LedgerNativeEntryAssembler.EntryBuilder baseSpinOff(Fixture fixture) - { - return LedgerNativeEntryAssembler.forClient(fixture.client).spinOff() // - .metadata(metadata()) // - .event(event()); - } - - private static LedgerNativeEntryAssembler.EntryBuilder spinOffWithRetainedAndNewLegs(Fixture fixture) - { - return baseSpinOff(fixture) // - .securityLeg(sourceLeg(fixture).build()) // - .securityLeg(contextLeg(fixture).build()) // - .securityLeg(targetLeg(fixture).projectAs(LedgerProjectionRole.DELIVERY_INBOUND).build()) // - .securityLeg(targetLeg(fixture).projectAs(LedgerProjectionRole.NEW_SECURITY_LEG).build()) // - .cashCompensation(NativeCashCompensation.builder() // - .account(fixture.account) // - .amount(money(5)) // - .kind(CashCompensationKind.CASH_IN_LIEU) // - .applied(true) // - .fractionQuantity(new BigDecimal("0.5")) // - .fractionTreatment(FractionTreatment.CASH_IN_LIEU) // - .roundingMode(RoundingModeCode.FLOOR) // - .build()) // - .fee(NativeFee.of(fixture.account, money(2), FeeReason.CORPORATE_ACTION_FEE)) // - .tax(NativeTax.withholding(fixture.account, money(1))); - } - - private static LedgerNativeEntryAssembler.EntryBuilder repeatedSpinOff(Fixture fixture, Security secondTarget) - { - return baseSpinOff(fixture) // - .securityLeg(sourceLeg(fixture).build()) // - .securityLeg(contextLeg(fixture).build()) // - .securityLeg(targetLeg(fixture).groupKey("main").localKey("target-1").build()) // - .securityLeg(targetLeg(fixture) // - .security(secondTarget) // - .targetSecurity(secondTarget) // - .shares(Values.Share.factorize(7)) // - .amount(money(70)) // - .groupKey("main") // - .localKey("target-2") // - .build()) // - .cashCompensation(cashCompensation(fixture, "cash-1", "cash-1", 5)) // - .cashCompensationMovement(cashCompensation(fixture, "cash-2", "cash-2", 7)) // - .fee(NativeFee.builder() // - .account(fixture.account) // - .amount(money(2)) // - .reason(FeeReason.CORPORATE_ACTION_FEE) // - .groupKey("cash-1") // - .localKey("fee-1") // - .build()) // - .tax(NativeTax.builder() // - .account(fixture.account) // - .amount(money(1)) // - .reason(TaxReason.WITHHOLDING_TAX) // - .withholdingTax(true) // - .groupKey("cash-1") // - .localKey("tax-1") // - .build()); - } - - private static NativeCashCompensation cashCompensation(Fixture fixture, String groupKey, String localKey, - long amount) - { - return NativeCashCompensation.builder() // - .account(fixture.account) // - .amount(money(amount)) // - .kind(CashCompensationKind.CASH_IN_LIEU) // - .applied(true) // - .groupKey(groupKey) // - .localKey(localKey) // - .build(); - } - - private static NativeEntryMetadata metadata() - { - return NativeEntryMetadata.of(LocalDateTime.of(2020, 9, 28, 0, 0)) // - .note("Native corporate action") // - .source("native-entry-assembler-test"); - } - - private static NativeCorporateActionEvent event() - { - return NativeCorporateActionEvent.builder() // - .kind(CorporateActionKind.SPIN_OFF) // - .subtype(CorporateActionSubtype.STANDARD) // - .reference(CorporateActionKind.SPIN_OFF.getCode() + "-2020") // - .stage(EventStage.SETTLED) // - .effectiveDate(LocalDate.of(2020, 9, 28)) // - .build(); - } - - private static NativeSecurityLeg.Builder sourceLeg(Fixture fixture) - { - return NativeSecurityLeg.source() // - .portfolio(fixture.portfolio) // - .security(fixture.siemens) // - .shares(Values.Share.factorize(10)) // - .amount(money(100)) // - .sourceSecurity(fixture.siemens) // - .targetSecurity(fixture.siemensEnergy) // - .ratio(Ratio.of(BigDecimal.ONE, BigDecimal.valueOf(2))); - } - - private static NativeSecurityLeg.Builder targetLeg(Fixture fixture) - { - return NativeSecurityLeg.target() // - .portfolio(fixture.portfolio) // - .security(fixture.siemensEnergy) // - .shares(Values.Share.factorize(5)) // - .amount(money(50)) // - .sourceSecurity(fixture.siemens) // - .targetSecurity(fixture.siemensEnergy) // - .ratio(Ratio.of(BigDecimal.ONE, BigDecimal.valueOf(2))); - } - - private static NativeSecurityLeg.Builder contextLeg(Fixture fixture) - { - return NativeSecurityLeg.context() // - .portfolio(fixture.portfolio) // - .security(fixture.siemens) // - .shares(0L) // - .amount(money(0)) // - .groupKey("main") // - .localKey("context-1"); - } - - private static LedgerParameter parameter(Collection> parameters, LedgerParameterType type) - { - return parameters.stream().filter(parameter -> parameter.getType() == type).findFirst().orElseThrow(); - } - - private static DerivedProjectionDescriptor descriptor(name.abuchen.portfolio.model.ledger.LedgerEntry entry, - LedgerProjectionRole role) - { - return descriptors(entry).stream().filter(descriptor -> descriptor.getRole() == role).findFirst() - .orElseThrow(); - } - - private static DerivedProjectionDescriptor descriptor(name.abuchen.portfolio.model.ledger.LedgerEntry entry, - LedgerProjectionRole role, String semanticInstanceKey) - { - return descriptors(entry).stream() // - .filter(descriptor -> descriptor.getRole() == role) // - .filter(descriptor -> descriptor.getSemanticInstanceKey() - .filter(semanticInstanceKey::equals).isPresent()) - .findFirst().orElseThrow(); - } - - private static java.util.List descriptors( - name.abuchen.portfolio.model.ledger.LedgerEntry entry) - { - var result = new DerivedProjectionDescriptorService().derive(entry); - - assertTrue(result.formatDiagnostics(), result.isOK()); - - return result.getDescriptors(); - } - - private static java.util.Set roles(name.abuchen.portfolio.model.ledger.LedgerEntry entry) - { - return descriptors(entry).stream().map(DerivedProjectionDescriptor::getRole) - .collect(Collectors.toSet()); - } - - private static java.util.List postings(name.abuchen.portfolio.model.ledger.LedgerEntry entry, - LedgerPostingType type, CorporateActionLeg leg) - { - return entry.getPostings().stream() // - .filter(posting -> posting.getType() == type) // - .filter(posting -> posting.getCorporateActionLeg() == leg) // - .toList(); - } - - private static Ledger ledger(name.abuchen.portfolio.model.ledger.LedgerEntry entry) - { - var ledger = new Ledger(); - ledger.addEntry(entry); - return ledger; - } - - private static void assertPrimarySemantics(LedgerPosting posting, LedgerPostingSemanticRole semanticRole, - LedgerPostingDirection direction, CorporateActionLeg leg, LedgerProjectionRole role) - { - assertThat(posting.getSemanticRole(), is(semanticRole)); - assertThat(posting.getDirection(), is(direction)); - assertThat(posting.getCorporateActionLeg(), is(leg)); - assertThat(posting.getUnitRole(), is(LedgerPostingUnitRole.PRIMARY)); - assertThat(posting.getGroupKey(), is(role.name())); - assertThat(posting.getLocalKey(), is(role.name())); - } - - private static LedgerBackedTransaction portfolioProjection(Portfolio portfolio, LedgerProjectionRole role) - { - return portfolio.getTransactions().stream() // - .filter(LedgerBackedTransaction.class::isInstance) // - .map(LedgerBackedTransaction.class::cast) // - .filter(transaction -> transaction.getLedgerProjectionDescriptor().getRole() == role) // - .findFirst().orElseThrow(); - } - - private static LedgerBackedTransaction accountProjection(Account account, LedgerProjectionRole role) - { - return account.getTransactions().stream() // - .filter(LedgerBackedTransaction.class::isInstance) // - .map(LedgerBackedTransaction.class::cast) // - .filter(transaction -> transaction.getLedgerProjectionDescriptor().getRole() == role) // - .findFirst().orElseThrow(); - } - - private static java.util.Set runtimeProjectionUUIDs(Fixture fixture) - { - return java.util.stream.Stream.concat( - fixture.portfolio.getTransactions().stream() - .filter(LedgerBackedTransaction.class::isInstance) - .map(PortfolioTransaction::getUUID), - fixture.account.getTransactions().stream() - .filter(LedgerBackedTransaction.class::isInstance) - .map(AccountTransaction::getUUID)) - .collect(Collectors.toSet()); - } - - private static void assertClientUnchanged(Fixture fixture) - { - assertThat(fixture.client.getLedger().getEntries().size(), is(0)); - assertThat(fixture.account.getTransactions().size(), is(0)); - assertThat(fixture.portfolio.getTransactions().size(), is(0)); - } - - private static Money money(long amount) - { - return Money.of(CurrencyUnit.EUR, Values.Amount.factorize(amount)); - } - - private static Fixture fixture() - { - var client = new Client(); - var account = new Account(); - var portfolio = new Portfolio(); - var siemens = new Security("Siemens AG", CurrencyUnit.EUR); - var siemensEnergy = new Security("Siemens Energy AG", CurrencyUnit.EUR); - - account.setName("Cash"); - account.setCurrencyCode(CurrencyUnit.EUR); - portfolio.setName("Portfolio"); - siemens.setIsin("DE0007236101"); - siemensEnergy.setIsin("DE000ENER6Y0"); - - client.addAccount(account); - client.addPortfolio(portfolio); - client.addSecurity(siemens); - client.addSecurity(siemensEnergy); - - return new Fixture(client, account, portfolio, siemens, siemensEnergy); - } - - private static final class Fixture - { - private final Client client; - private final Account account; - private final Portfolio portfolio; - private final Security siemens; - private final Security siemensEnergy; - - private Fixture(Client client, Account account, Portfolio portfolio, Security siemens, Security siemensEnergy) - { - this.client = client; - this.account = account; - this.portfolio = portfolio; - this.siemens = siemens; - this.siemensEnergy = siemensEnergy; - } - } -}